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 +20 -14
- package/index.ts +9 -14
- package/package.json +8 -4
- package/src/commands.ts +15 -3
- package/src/compat.ts +46 -0
- package/src/config-types.ts +3 -0
- package/src/defaults.ts +10 -5
- package/src/detector.ts +63 -30
- package/src/gitbox.ts +23 -1
- package/src/impersonator.ts +126 -38
- package/src/prompts.ts +26 -3
- package/src/settings.ts +2 -0
- package/tests/impersonator.test.ts +92 -0
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
|
|
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
|
|
16
|
+
- **Gitignored file impersonation** — gitignored files are automatically mirrored into a private gitbox directory
|
|
17
|
+
- **Directory impersonation** — gitignored directories can also be mirrored (opt-in)
|
|
17
18
|
- **Command & path interception** — bash commands and file operations (read, edit, write, find, grep, ls) are internally redirected to the impersonated paths
|
|
18
19
|
- **Directory access control** — restricts agent access to allowed directories by default; prompts for approval when accessing paths outside the allowed list
|
|
19
20
|
- **Configurable directory bypass** — optionally disable directory restrictions
|
|
20
21
|
- **Status bar indicators** — color-coded status showing whether the gitbox is enabled, available, not required, unavailable or bypassed
|
|
21
22
|
- **Auto cleanup** — optionally delete the gitbox when the session exits
|
|
22
23
|
|
|
24
|
+
> **Note on directory impersonation:** By default, only gitignored files are impersonated. Enabling `impersonateDirs` also mirrors directories into the gitbox. This is useful when you want the agent to operate on the project without disrupting your current folders — for example, a Node project with `node_modules/` ignored by git can be used from the working directory (when `impersonateDirs: false`), or can be recreated to be available in the gitbox instead (when `impersonateDirs: true`).
|
|
25
|
+
|
|
23
26
|
## Status Bar
|
|
24
27
|
|
|
25
28
|
The status bar displays `📦 Gitbox:` followed by the current status:
|
|
@@ -58,6 +61,7 @@ You can customize Gitbox options via the interactive menu (`/gitbox`) for common
|
|
|
58
61
|
"baseDir": "~/.pi/agent/gitbox",
|
|
59
62
|
"statusBar": true,
|
|
60
63
|
"deleteOnExit": false,
|
|
64
|
+
"impersonateDirs": false,
|
|
61
65
|
"bypassGitbox": false,
|
|
62
66
|
"bypassPaths": false,
|
|
63
67
|
"allowedPaths": []
|
|
@@ -67,24 +71,25 @@ You can customize Gitbox options via the interactive menu (`/gitbox`) for common
|
|
|
67
71
|
|
|
68
72
|
### Configuration Options
|
|
69
73
|
|
|
70
|
-
| Option
|
|
71
|
-
|
|
|
72
|
-
| `baseDir`
|
|
73
|
-
| `statusBar`
|
|
74
|
-
| `deleteOnExit`
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
74
|
+
| Option | Type | Default | Description |
|
|
75
|
+
| ----------------- | -------- | -------------------- | ----------------------------------------- |
|
|
76
|
+
| `baseDir` | string | `~/.pi/agent/gitbox` | Base directory where gitboxes are created |
|
|
77
|
+
| `statusBar` | boolean | `true` | Show gitbox status in the status bar |
|
|
78
|
+
| `deleteOnExit` | boolean | `false` | Delete the gitbox when the session exits |
|
|
79
|
+
| `impersonateDirs` | boolean | `false` | Also impersonate gitignored directories |
|
|
80
|
+
| `bypassGitbox` | boolean | `false` | Skip impersonation of gitignored paths |
|
|
81
|
+
| `bypassPaths` | boolean | `false` | Bypass path access restrictions entirely |
|
|
82
|
+
| `allowedPaths` | string[] | `[]` | Additional paths to allow access to |
|
|
78
83
|
|
|
79
|
-
> **Note:** The interactive menu (`/gitbox`) exposes
|
|
84
|
+
> **Note:** The interactive menu (`/gitbox`) only exposes boolean keys. The remaining options (`baseDir`, `allowedPaths`) must be configured directly in `settings.json`.
|
|
80
85
|
|
|
81
86
|
### Directory Access
|
|
82
87
|
|
|
83
88
|
By default, the extension allows access to:
|
|
84
89
|
|
|
85
90
|
- The current working directory (`process.cwd()`)
|
|
86
|
-
- The Pi agent directory (`~/.pi/agent
|
|
87
|
-
- The
|
|
91
|
+
- The Pi agent directory (`~/.pi/agent/`)
|
|
92
|
+
- The Pi package directory (`<...>/@earendil-works/pi-coding-agent`)
|
|
88
93
|
- `/dev/null`
|
|
89
94
|
|
|
90
95
|
If the agent attempts to access a path outside these allowed directories, a confirmation dialog appears:
|
|
@@ -115,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
|
|
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
|
-
|
|
51
|
-
if (Detector.isPathAllowed(resolvedDirs, path, ctx)) continue;
|
|
52
|
-
|
|
53
|
-
const response = await askUserOrBlock(ctx, path);
|
|
54
|
-
if (response.block) return response;
|
|
55
|
-
}
|
|
48
|
+
const blocked = await checkPathsAccess(paths, resolvedDirs, ctx);
|
|
49
|
+
if (blocked) return blocked;
|
|
56
50
|
}
|
|
57
51
|
|
|
58
52
|
// Then, impersonate the command
|
|
@@ -62,11 +56,12 @@ export default async (pi: ExtensionAPI) => {
|
|
|
62
56
|
const { path } = event.input as { path: string };
|
|
63
57
|
|
|
64
58
|
// First, scan if we can even access the paths
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
59
|
+
if (
|
|
60
|
+
!config.bypassPaths &&
|
|
61
|
+
!Detector.isPathAllowed(resolvedDirs, path, ctx)
|
|
62
|
+
) {
|
|
63
|
+
const response = await askUserOrBlock(ctx, path);
|
|
64
|
+
if (response.block) return response;
|
|
70
65
|
}
|
|
71
66
|
|
|
72
67
|
// Then, impersonate the path
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-gitbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pi extension that impersonates gitignored paths to reduce secrets exposure.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
"./index.ts"
|
|
23
23
|
]
|
|
24
24
|
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "vitest run"
|
|
27
|
+
},
|
|
25
28
|
"prettier": {
|
|
26
29
|
"plugins": [
|
|
27
30
|
"prettier-plugin-organize-imports"
|
|
@@ -32,11 +35,12 @@
|
|
|
32
35
|
"@earendil-works/pi-tui": "*"
|
|
33
36
|
},
|
|
34
37
|
"devDependencies": {
|
|
35
|
-
"@types/node": "^26.0.
|
|
38
|
+
"@types/node": "^26.0.1",
|
|
36
39
|
"@types/shell-quote": "^1.7.5",
|
|
37
|
-
"prettier-plugin-organize-imports": "^4.3.0"
|
|
40
|
+
"prettier-plugin-organize-imports": "^4.3.0",
|
|
41
|
+
"vitest": "^4.1.9"
|
|
38
42
|
},
|
|
39
43
|
"dependencies": {
|
|
40
|
-
"shell-quote": "^1.
|
|
44
|
+
"shell-quote": "^1.9.0"
|
|
41
45
|
}
|
|
42
46
|
}
|
package/src/commands.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { settings } from "./settings";
|
|
|
15
15
|
enum Options {
|
|
16
16
|
STATUS_BAR = "statusBar",
|
|
17
17
|
DELETE_ON_EXIT = "deleteOnExit",
|
|
18
|
+
IMPERSONATE_DIRS = "impersonateDirs",
|
|
18
19
|
BYPASS_GITBOX = "bypassGitbox",
|
|
19
20
|
BYPASS_PATHS = "bypassPaths",
|
|
20
21
|
}
|
|
@@ -72,10 +73,14 @@ export class CommandManager {
|
|
|
72
73
|
* @param ctx The extension context
|
|
73
74
|
*/
|
|
74
75
|
private async runGitboxPaths(ctx: ExtensionCommandContext): Promise<void> {
|
|
75
|
-
const
|
|
76
|
+
const fileMapper = this.gitbox.getFileMapper(ctx);
|
|
77
|
+
const dirMapper = this.gitbox.getDirMapper(ctx);
|
|
76
78
|
|
|
77
|
-
const lines = Object.entries(
|
|
78
|
-
.map(([source, target]) =>
|
|
79
|
+
const lines = Object.entries({ ...fileMapper, ...dirMapper })
|
|
80
|
+
.map(([source, target]) => {
|
|
81
|
+
const icon = source in dirMapper ? "📁" : "📄";
|
|
82
|
+
return ` ${icon} ${source} -> ${target}`;
|
|
83
|
+
})
|
|
79
84
|
.join("\n");
|
|
80
85
|
|
|
81
86
|
const content = lines
|
|
@@ -148,6 +153,13 @@ export class CommandManager {
|
|
|
148
153
|
currentValue: config.deleteOnExit ? "on" : "off",
|
|
149
154
|
values: ["on", "off"],
|
|
150
155
|
},
|
|
156
|
+
{
|
|
157
|
+
id: Options.IMPERSONATE_DIRS,
|
|
158
|
+
label: "Impersonate directories",
|
|
159
|
+
description: "Also impersonate gitignored directories",
|
|
160
|
+
currentValue: config.impersonateDirs ? "on" : "off",
|
|
161
|
+
values: ["on", "off"],
|
|
162
|
+
},
|
|
151
163
|
{
|
|
152
164
|
id: Options.BYPASS_GITBOX,
|
|
153
165
|
label: "Bypass impersonation",
|
package/src/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/config-types.ts
CHANGED
package/src/defaults.ts
CHANGED
|
@@ -21,6 +21,16 @@ export const GITBOX_STATUSBAR = true;
|
|
|
21
21
|
*/
|
|
22
22
|
export const DELETE_ON_EXIT = false;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Whether to also impersonate gitignored directories
|
|
26
|
+
*/
|
|
27
|
+
export const IMPERSONATE_DIRS = false;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether to bypass impersonation entirely
|
|
31
|
+
*/
|
|
32
|
+
export const BYPASS_GITBOX = false;
|
|
33
|
+
|
|
24
34
|
/**
|
|
25
35
|
* Default paths that are always allowed.
|
|
26
36
|
*/
|
|
@@ -48,8 +58,3 @@ export const ALLOWED_PATHS: string[] = [];
|
|
|
48
58
|
* Whether to bypass path restrictions
|
|
49
59
|
*/
|
|
50
60
|
export const BYPASS_PATHS = false;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Whether to bypass impersonation entirely
|
|
54
|
-
*/
|
|
55
|
-
export const BYPASS_GITBOX = false;
|
package/src/detector.ts
CHANGED
|
@@ -1,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 {
|
|
5
|
-
import { resolve, 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,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
|
-
|
|
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
|
-
|
|
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.
|
|
186
|
+
const paths = Detector.getGitignoredPaths();
|
|
165
187
|
if (paths.length === 0) return Status.AVAILABLE;
|
|
166
188
|
|
|
167
189
|
return Status.ENABLED;
|
package/src/impersonator.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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.
|
|
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 =
|
|
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 =
|
|
51
|
+
const absPath = resolvePaths(ctx.cwd, path);
|
|
37
52
|
|
|
38
53
|
if (impersonation) {
|
|
39
|
-
this.
|
|
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 =
|
|
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 =
|
|
71
|
+
const absPath = resolvePaths(ctx.cwd, path);
|
|
57
72
|
|
|
58
73
|
if (impersonation) {
|
|
59
|
-
this.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|