pi-gitbox 0.1.0 → 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 +4 -3
- package/package.json +2 -2
- package/src/commands.ts +2 -2
- package/src/compat.ts +46 -0
- package/src/detector.ts +58 -30
- package/src/gitbox.ts +7 -18
- package/src/impersonator.ts +29 -24
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
|
|
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
|
|
@@ -113,13 +113,14 @@ Set `bypassPaths: true` to skip this check entirely.
|
|
|
113
113
|
|
|
114
114
|
## How It Works
|
|
115
115
|
|
|
116
|
-
1. **Session Start** — On `session_start`, the extension
|
|
116
|
+
1. **Session Start** — On `session_start`, the extension checks whether the current directory is a git repository with `git` available
|
|
117
117
|
2. **Gitignored Path Detection** — Uses git-specific commands to discover all gitignored files and directories
|
|
118
|
-
3. **Gitbox Creation** —
|
|
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)
|
|
119
119
|
4. **Path Mapping** — Builds a mapper from original absolute paths to their impersonated counterparts
|
|
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.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Pi extension that impersonates gitignored paths to reduce secrets exposure.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@earendil-works/pi-tui": "*"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@types/node": "^
|
|
35
|
+
"@types/node": "^26.0.0",
|
|
36
36
|
"@types/shell-quote": "^1.7.5",
|
|
37
37
|
"prettier-plugin-organize-imports": "^4.3.0"
|
|
38
38
|
},
|
package/src/commands.ts
CHANGED
|
@@ -100,8 +100,8 @@ export class CommandManager {
|
|
|
100
100
|
const key: string = Object.values(Options).find((o) => o === id)!;
|
|
101
101
|
await settings.setConfig({ [key]: newValue === "on" });
|
|
102
102
|
|
|
103
|
-
//
|
|
104
|
-
await this.gitbox.
|
|
103
|
+
// Re-initialize to pick up the new configuration
|
|
104
|
+
await this.gitbox.initialize(ctx);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/**
|
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 {
|
|
5
|
-
import {
|
|
4
|
+
import { sep } from "node:path";
|
|
5
|
+
import { normalizePath, PATH_SEP, resolvePaths } from "./compat";
|
|
6
6
|
|
|
7
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
const paths =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
113
|
-
const absPath =
|
|
106
|
+
const normalizedPath = normalizePath(path);
|
|
107
|
+
const absPath = resolvePaths(ctx.cwd, normalizedPath);
|
|
114
108
|
const absDirs = dirs.map((dir) =>
|
|
115
|
-
|
|
109
|
+
resolvePaths(ctx.cwd, normalizePath(dir)),
|
|
116
110
|
);
|
|
117
111
|
|
|
118
|
-
|
|
119
|
-
(dir) => absPath === dir || absPath.startsWith(dir +
|
|
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
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* @
|
|
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
|
-
|
|
129
|
-
return path.
|
|
156
|
+
isDirectory(path: string): boolean {
|
|
157
|
+
return path.endsWith(PATH_SEP) || path.endsWith(sep);
|
|
130
158
|
}
|
|
131
|
-
}
|
|
159
|
+
})();
|
package/src/gitbox.ts
CHANGED
|
@@ -18,14 +18,14 @@ export class Gitbox {
|
|
|
18
18
|
async initialize(ctx: ExtensionContext) {
|
|
19
19
|
await this.verifySettings(ctx);
|
|
20
20
|
|
|
21
|
-
if
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
// Only create the gitbox folder if it makes sense
|
|
22
|
+
const status = await this.getStatus();
|
|
23
|
+
if (status === Status.AVAILABLE || status === Status.ENABLED) {
|
|
24
|
+
await this.impersonator.initialize(ctx);
|
|
25
|
+
await this.getOrCreate(ctx);
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
await
|
|
27
|
-
await this.getOrCreate(ctx);
|
|
28
|
-
await this.setStatus(ctx);
|
|
28
|
+
await Renderer.setStatus(ctx, status);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
async shutdown(ctx: ExtensionContext) {
|
|
@@ -161,20 +161,9 @@ 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.
|
|
164
|
+
const paths = Detector.getGitignoredPaths();
|
|
165
165
|
if (paths.length === 0) return Status.AVAILABLE;
|
|
166
166
|
|
|
167
167
|
return Status.ENABLED;
|
|
168
168
|
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Sets the current status in the status bar.
|
|
172
|
-
* No-op if the settings prevent it.
|
|
173
|
-
*
|
|
174
|
-
* @param ctx The extension context
|
|
175
|
-
*/
|
|
176
|
-
async setStatus(ctx: ExtensionContext) {
|
|
177
|
-
const status = await this.getStatus();
|
|
178
|
-
await Renderer.setStatus(ctx, status);
|
|
179
|
-
}
|
|
180
169
|
}
|
package/src/impersonator.ts
CHANGED
|
@@ -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,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
this.mapper[absPath] =
|
|
194
|
-
|
|
195
|
-
|
|
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
|