pi-gitbox 0.1.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/LICENSE.md +9 -0
- package/README.md +135 -0
- package/index.ts +77 -0
- package/package.json +42 -0
- package/src/commands.ts +168 -0
- package/src/config-types.ts +23 -0
- package/src/defaults.ts +55 -0
- package/src/detector.ts +131 -0
- package/src/gitbox.ts +180 -0
- package/src/impersonator.ts +264 -0
- package/src/prompts.ts +48 -0
- package/src/renderer.ts +65 -0
- package/src/settings.ts +134 -0
- package/tsconfig.json +12 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gabriel Sanhueza (https://github.com/gsanhueza)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# pi-gitbox
|
|
2
|
+
|
|
3
|
+
A [Pi Coding Agent](https://pi.dev/) extension that automatically redirects gitignored files and directories into an isolated **gitbox** — a local impersonation layer that makes them accessible to the AI agent without exposing your secrets. (_"gitbox"_ is a portmanteau of _"git"_ + _"sandbox"_.)
|
|
4
|
+
|
|
5
|
+
> **⚠️ DISCLAIMER:** This extension uses best-effort impersonation of gitignored paths. It is **your responsibility** to verify that secrets are not exposed to the agent. If absolute isolation is required, consider using a local model, [bubblewrap](https://github.com/containers/bubblewrap), or a fully isolated environment.
|
|
6
|
+
|
|
7
|
+
## Security considerations
|
|
8
|
+
|
|
9
|
+
After enabling gitbox, verify that impersonations are working correctly:
|
|
10
|
+
|
|
11
|
+
1. **Check impersonated files** — Open `~/.pi/agent/gitbox/<project>/` and confirm that gitignored files appear as empty placeholders (or `{}` for `.json` files).
|
|
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
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Gitignored file impersonation** — gitignored files and directories are automatically mirrored into a private gitbox directory
|
|
17
|
+
- **Command & path interception** — bash commands and file operations (read, edit, write, find, grep, ls) are internally redirected to the impersonated paths
|
|
18
|
+
- **Directory access control** — restricts agent access to allowed directories by default; prompts for approval when accessing paths outside the allowed list
|
|
19
|
+
- **Configurable directory bypass** — optionally disable directory restrictions
|
|
20
|
+
- **Status bar indicators** — color-coded status showing whether the gitbox is enabled, available, not required, unavailable or bypassed
|
|
21
|
+
- **Auto cleanup** — optionally delete the gitbox when the session exits
|
|
22
|
+
|
|
23
|
+
## Status Bar
|
|
24
|
+
|
|
25
|
+
The status bar displays `📦 Gitbox:` followed by the current status:
|
|
26
|
+
|
|
27
|
+
| Status | Meaning | Color |
|
|
28
|
+
| ------------ | ----------------------------------------------- | ------------------ |
|
|
29
|
+
| Enabled | Gitbox active — gitignored paths exist | `#00ff88` (green) |
|
|
30
|
+
| Available | Gitbox created but no gitignored paths detected | `#ffaa00` (orange) |
|
|
31
|
+
| Not required | Current directory is not a git repository | `#ff8800` (orange) |
|
|
32
|
+
| Unavailable | `git` command not found | `#ff4444` (red) |
|
|
33
|
+
| Bypassed | Impersonation disabled by configuration | `#44ddff` (cyan) |
|
|
34
|
+
|
|
35
|
+
When `bypassPaths` is enabled, the status bar appends ` (unrestricted)` to indicate that directory access restrictions are disabled.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
This package is a Pi extension. Install it with
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install pi-gitbox
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
or
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pi install https://github.com/gsanhueza/pi-gitbox
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
You can customize Gitbox options via the interactive menu (`/gitbox`) for common settings, or by adding a `gitbox` section to your `~/.pi/agent/settings.json` for all options:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"gitbox": {
|
|
58
|
+
"baseDir": "~/.pi/agent/gitbox",
|
|
59
|
+
"statusBar": true,
|
|
60
|
+
"deleteOnExit": false,
|
|
61
|
+
"bypassGitbox": false,
|
|
62
|
+
"bypassPaths": false,
|
|
63
|
+
"allowedPaths": []
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Configuration Options
|
|
69
|
+
|
|
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 |
|
|
78
|
+
|
|
79
|
+
> **Note:** The interactive menu (`/gitbox`) exposes `statusBar`, `deleteOnExit`, `bypassGitbox`, and `bypassPaths`. The remaining options (`baseDir`, `allowedPaths`) must be configured directly in `settings.json`.
|
|
80
|
+
|
|
81
|
+
### Directory Access
|
|
82
|
+
|
|
83
|
+
By default, the extension allows access to:
|
|
84
|
+
|
|
85
|
+
- The current working directory (`process.cwd()`)
|
|
86
|
+
- The Pi agent directory (`~/.pi/agent`)
|
|
87
|
+
- The extension package directory
|
|
88
|
+
- `/dev/null`
|
|
89
|
+
|
|
90
|
+
If the agent attempts to access a path outside these allowed directories, a confirmation dialog appears:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
[pi-gitbox]: Allow "/some/path" to be accessed?
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Options:
|
|
97
|
+
|
|
98
|
+
- **Allow** — Access the path for this session
|
|
99
|
+
- **Deny** — Block access
|
|
100
|
+
- **Bypass (session only)** — Add the path to allowed paths for this session
|
|
101
|
+
- **Bypass (saved globally)** — Add the path to allowed paths permanently
|
|
102
|
+
|
|
103
|
+
Set `bypassPaths: true` to skip this check entirely.
|
|
104
|
+
|
|
105
|
+
> **Note:** When Pi doesn't have access to a UI, access will be automatically blocked.
|
|
106
|
+
|
|
107
|
+
## Commands
|
|
108
|
+
|
|
109
|
+
| Command | Description |
|
|
110
|
+
| --------------- | ------------------------------------------------- |
|
|
111
|
+
| `/gitbox` | Open settings menu — configure gitbox options |
|
|
112
|
+
| `/gitbox paths` | Show impersonated paths (source → target mapping) |
|
|
113
|
+
|
|
114
|
+
## How It Works
|
|
115
|
+
|
|
116
|
+
1. **Session Start** — On `session_start`, the extension verifies that `git` is available and checks if the current directory is a git repository
|
|
117
|
+
2. **Gitignored Path Detection** — Uses git-specific commands to discover all gitignored files and directories
|
|
118
|
+
3. **Gitbox Creation** — 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
|
+
4. **Path Mapping** — Builds a mapper from original absolute paths to their impersonated counterparts
|
|
120
|
+
5. **Event Interception** — On every `tool_call` event:
|
|
121
|
+
- **Bash commands** — Extracts paths from the command using `shell-quote`, checks directory restrictions, then rewrites paths to their impersonated versions
|
|
122
|
+
- **Path-based tools** (read, edit, write, find, grep, ls) — Checks directory restrictions, then resolves the path to its impersonated equivalent
|
|
123
|
+
6. **Status Bar** — Updates the status bar with the current gitbox state (enabled, available, not required, or unavailable)
|
|
124
|
+
7. **Session Shutdown** — Optionally removes the gitbox directory if `deleteOnExit` is enabled
|
|
125
|
+
|
|
126
|
+
## Dependencies
|
|
127
|
+
|
|
128
|
+
| Peer dependency | Purpose |
|
|
129
|
+
| --------------------------------- | ------------------- |
|
|
130
|
+
| `@earendil-works/pi-coding-agent` | Pi Coding Agent SDK |
|
|
131
|
+
| `@earendil-works/pi-tui` | Pi TUI SDK |
|
|
132
|
+
|
|
133
|
+
| Dependency | Purpose |
|
|
134
|
+
| ------------- | ------------------- |
|
|
135
|
+
| `shell-quote` | Parse bash commands |
|
package/index.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ExtensionCommandContext,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
ToolCallEvent,
|
|
5
|
+
type ExtensionAPI,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { CommandManager } from "./src/commands";
|
|
8
|
+
import { BASE_ALLOWED_PATHS } from "./src/defaults";
|
|
9
|
+
import { Detector } from "./src/detector";
|
|
10
|
+
import { Gitbox } from "./src/gitbox";
|
|
11
|
+
import { Impersonator } from "./src/impersonator";
|
|
12
|
+
import { askUserOrBlock } from "./src/prompts";
|
|
13
|
+
import { settings } from "./src/settings";
|
|
14
|
+
|
|
15
|
+
export default async (pi: ExtensionAPI) => {
|
|
16
|
+
const impersonator = new Impersonator();
|
|
17
|
+
const gitbox = new Gitbox(impersonator);
|
|
18
|
+
|
|
19
|
+
const commandManager = new CommandManager(gitbox);
|
|
20
|
+
|
|
21
|
+
// Command registration
|
|
22
|
+
pi.registerCommand("gitbox", {
|
|
23
|
+
description: "Open settings menu to configure Gitbox options",
|
|
24
|
+
getArgumentCompletions: commandManager.getArgumentCompletions,
|
|
25
|
+
handler: async (args: string, ctx: ExtensionCommandContext) =>
|
|
26
|
+
await commandManager.runGitbox(args, ctx),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Events
|
|
30
|
+
pi.on("session_start", async (_, ctx: ExtensionContext) => {
|
|
31
|
+
await gitbox.initialize(ctx);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
pi.on("session_shutdown", async (_, ctx: ExtensionContext) => {
|
|
35
|
+
await gitbox.shutdown(ctx);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
pi.on("tool_call", async (event: ToolCallEvent, ctx: ExtensionContext) => {
|
|
39
|
+
const { config } = await settings.getConfig();
|
|
40
|
+
|
|
41
|
+
const resolvedDirs = [...BASE_ALLOWED_PATHS, ...config.allowedPaths];
|
|
42
|
+
|
|
43
|
+
if (gitbox.isBashEvent(event)) {
|
|
44
|
+
const { command } = event.input;
|
|
45
|
+
|
|
46
|
+
// First, scan if we can even access the paths
|
|
47
|
+
if (!config.bypassPaths) {
|
|
48
|
+
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
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Then, impersonate the command
|
|
59
|
+
if (!config.bypassGitbox)
|
|
60
|
+
event.input.command = await gitbox.resolveCommand(command, ctx);
|
|
61
|
+
} else if (gitbox.isPathEvent(event)) {
|
|
62
|
+
const { path } = event.input as { path: string };
|
|
63
|
+
|
|
64
|
+
// 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
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Then, impersonate the path
|
|
73
|
+
if (!config.bypassGitbox)
|
|
74
|
+
event.input.path = await gitbox.resolvePath(path, ctx);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-gitbox",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension that impersonates gitignored paths to reduce secrets exposure.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi",
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension"
|
|
9
|
+
],
|
|
10
|
+
"author": "Gabriel Sanhueza",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/gsanhueza/pi-gitbox.git"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/gsanhueza/pi-gitbox#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/gsanhueza/pi-gitbox/issues"
|
|
19
|
+
},
|
|
20
|
+
"pi": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"./index.ts"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"prettier": {
|
|
26
|
+
"plugins": [
|
|
27
|
+
"prettier-plugin-organize-imports"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
32
|
+
"@earendil-works/pi-tui": "*"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^25.9.3",
|
|
36
|
+
"@types/shell-quote": "^1.7.5",
|
|
37
|
+
"prettier-plugin-organize-imports": "^4.3.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"shell-quote": "^1.8.4"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
AutocompleteItem,
|
|
5
|
+
SettingsList,
|
|
6
|
+
type SettingItem,
|
|
7
|
+
} from "@earendil-works/pi-tui";
|
|
8
|
+
import { GitboxConfig } from "./config-types";
|
|
9
|
+
import { Gitbox } from "./gitbox";
|
|
10
|
+
import { settings } from "./settings";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Configuration options
|
|
14
|
+
*/
|
|
15
|
+
enum Options {
|
|
16
|
+
STATUS_BAR = "statusBar",
|
|
17
|
+
DELETE_ON_EXIT = "deleteOnExit",
|
|
18
|
+
BYPASS_GITBOX = "bypassGitbox",
|
|
19
|
+
BYPASS_PATHS = "bypassPaths",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Handles commands for the pi-gitbox extension.
|
|
24
|
+
*/
|
|
25
|
+
export class CommandManager {
|
|
26
|
+
constructor(private readonly gitbox: Gitbox) {}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sets up the argument completions for the `/gitbox` command
|
|
30
|
+
*
|
|
31
|
+
* @param prefix Prefix written by the user
|
|
32
|
+
* @returns Completions with that prefix
|
|
33
|
+
*/
|
|
34
|
+
getArgumentCompletions(prefix: string): AutocompleteItem[] | null {
|
|
35
|
+
const available = [
|
|
36
|
+
{
|
|
37
|
+
value: "paths",
|
|
38
|
+
label: "paths",
|
|
39
|
+
description: "Show impersonated paths",
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
const filtered = available.filter((a) => a.value.startsWith(prefix));
|
|
43
|
+
return filtered.length > 0 ? filtered : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Handles the `/gitbox` command — opens a SettingsList or shows impersonated paths
|
|
48
|
+
*
|
|
49
|
+
* @param args The subcommand argument (e.g. "paths")
|
|
50
|
+
* @param ctx The extension context
|
|
51
|
+
*/
|
|
52
|
+
async runGitbox(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
53
|
+
if (args === "paths") {
|
|
54
|
+
return await this.runGitboxPaths(ctx);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { config } = await settings.getConfig();
|
|
58
|
+
const items = this.buildSettingsItems(config);
|
|
59
|
+
|
|
60
|
+
await ctx.ui.custom<void>((_tui, _theme, _kb, done) =>
|
|
61
|
+
this.createSettingsList(
|
|
62
|
+
items,
|
|
63
|
+
async (id, newValue) => this.handleSettingChange(id, newValue, ctx),
|
|
64
|
+
done,
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Handles the `/gitbox paths` subcommand — shows impersonated paths
|
|
71
|
+
*
|
|
72
|
+
* @param ctx The extension context
|
|
73
|
+
*/
|
|
74
|
+
private async runGitboxPaths(ctx: ExtensionCommandContext): Promise<void> {
|
|
75
|
+
const mapper = this.gitbox.getMapper(ctx);
|
|
76
|
+
|
|
77
|
+
const lines = Object.entries(mapper)
|
|
78
|
+
.map(([source, target]) => ` ${source} -> ${target}`)
|
|
79
|
+
.join("\n");
|
|
80
|
+
|
|
81
|
+
const content = lines
|
|
82
|
+
? `Impersonated paths:\n${lines}`
|
|
83
|
+
: "No impersonated paths available.";
|
|
84
|
+
|
|
85
|
+
ctx.ui.notify(content);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Handles a settings value change — writes the new value and re-renders.
|
|
90
|
+
*
|
|
91
|
+
* @param id The setting identifier
|
|
92
|
+
* @param newValue The new value to apply
|
|
93
|
+
* @param ctx The extension command context
|
|
94
|
+
*/
|
|
95
|
+
private async handleSettingChange(
|
|
96
|
+
id: string,
|
|
97
|
+
newValue: string,
|
|
98
|
+
ctx: ExtensionCommandContext,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const key: string = Object.values(Options).find((o) => o === id)!;
|
|
101
|
+
await settings.setConfig({ [key]: newValue === "on" });
|
|
102
|
+
|
|
103
|
+
// Update the status bar with the new status
|
|
104
|
+
await this.gitbox.setStatus(ctx);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Creates the SettingsList for the menu.
|
|
109
|
+
*
|
|
110
|
+
* @param items The settings items to display
|
|
111
|
+
* @param onChange Callback when a setting value changes
|
|
112
|
+
* @param onClose Callback when the dialog closes
|
|
113
|
+
* @returns The configured SettingsList instance
|
|
114
|
+
*/
|
|
115
|
+
private createSettingsList(
|
|
116
|
+
items: SettingItem[],
|
|
117
|
+
onChange: (id: string, newValue: string) => void,
|
|
118
|
+
onClose: () => void,
|
|
119
|
+
): SettingsList {
|
|
120
|
+
return new SettingsList(
|
|
121
|
+
items,
|
|
122
|
+
items.length,
|
|
123
|
+
getSettingsListTheme(),
|
|
124
|
+
onChange,
|
|
125
|
+
onClose,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Builds the SettingsList items for the menu.
|
|
131
|
+
*
|
|
132
|
+
* @param config The resolved configuration
|
|
133
|
+
* @returns The array of SettingItem objects
|
|
134
|
+
*/
|
|
135
|
+
private buildSettingsItems(config: GitboxConfig): SettingItem[] {
|
|
136
|
+
return [
|
|
137
|
+
{
|
|
138
|
+
id: Options.STATUS_BAR,
|
|
139
|
+
label: "Show in status bar",
|
|
140
|
+
description: "Shows gitbox status in the status bar",
|
|
141
|
+
currentValue: config.statusBar ? "on" : "off",
|
|
142
|
+
values: ["on", "off"],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: Options.DELETE_ON_EXIT,
|
|
146
|
+
label: "Delete on exit",
|
|
147
|
+
description: "When exiting Pi, delete the gitbox",
|
|
148
|
+
currentValue: config.deleteOnExit ? "on" : "off",
|
|
149
|
+
values: ["on", "off"],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: Options.BYPASS_GITBOX,
|
|
153
|
+
label: "Bypass impersonation",
|
|
154
|
+
description:
|
|
155
|
+
"Skip impersonation of gitignored paths (keeps original paths)",
|
|
156
|
+
currentValue: config.bypassGitbox ? "on" : "off",
|
|
157
|
+
values: ["on", "off"],
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: Options.BYPASS_PATHS,
|
|
161
|
+
label: "Bypass directories",
|
|
162
|
+
description: "Bypass the restrictions on allowed directories",
|
|
163
|
+
currentValue: config.bypassPaths ? "on" : "off",
|
|
164
|
+
values: ["on", "off"],
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for the pi-gitbox extension.
|
|
3
|
+
* All fields can be overridden via ~/.pi/agent/settings.json under the "gitbox" key.
|
|
4
|
+
*/
|
|
5
|
+
export interface GitboxConfig {
|
|
6
|
+
baseDir: string;
|
|
7
|
+
statusBar: boolean;
|
|
8
|
+
deleteOnExit: boolean;
|
|
9
|
+
bypassGitbox: boolean;
|
|
10
|
+
bypassPaths: boolean;
|
|
11
|
+
allowedPaths: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Texts for the status bar
|
|
16
|
+
*/
|
|
17
|
+
export enum Status {
|
|
18
|
+
ENABLED = "Enabled",
|
|
19
|
+
AVAILABLE = "Available",
|
|
20
|
+
NOT_REQUIRED = "Not required",
|
|
21
|
+
UNAVAILABLE = "Unavailable",
|
|
22
|
+
BYPASSED = "Bypassed",
|
|
23
|
+
}
|
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Identifier for the status bar entry
|
|
6
|
+
*/
|
|
7
|
+
export const STATUS_KEY = "gitbox";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default base directory for gitboxes
|
|
11
|
+
*/
|
|
12
|
+
export const GITBOX_BASEDIR = join(getAgentDir(), "gitbox");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether to add a text in the status bar
|
|
16
|
+
*/
|
|
17
|
+
export const GITBOX_STATUSBAR = true;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether to delete the gitbox when the extension exits
|
|
21
|
+
*/
|
|
22
|
+
export const DELETE_ON_EXIT = false;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default paths that are always allowed.
|
|
26
|
+
*/
|
|
27
|
+
export const BASE_ALLOWED_PATHS: string[] = [
|
|
28
|
+
// The current working directory
|
|
29
|
+
process.cwd(),
|
|
30
|
+
|
|
31
|
+
// Pi's agent library location,
|
|
32
|
+
getPackageDir(),
|
|
33
|
+
|
|
34
|
+
// User settings
|
|
35
|
+
getAgentDir(),
|
|
36
|
+
|
|
37
|
+
// Common paths
|
|
38
|
+
"/dev/null",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Allowed paths, configurable by the user
|
|
43
|
+
* Extra paths can be added via `allowedPaths` in settings.
|
|
44
|
+
*/
|
|
45
|
+
export const ALLOWED_PATHS: string[] = [];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether to bypass path restrictions
|
|
49
|
+
*/
|
|
50
|
+
export const BYPASS_PATHS = false;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether to bypass impersonation entirely
|
|
54
|
+
*/
|
|
55
|
+
export const BYPASS_GITBOX = false;
|
package/src/detector.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import { access } from "fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { resolve, sep } from "node:path";
|
|
6
|
+
|
|
7
|
+
export class Detector {
|
|
8
|
+
/**
|
|
9
|
+
* Determines if the user has `git` as a usable command
|
|
10
|
+
*
|
|
11
|
+
* @returns True if git exists
|
|
12
|
+
*/
|
|
13
|
+
static isGitAvailable(): boolean {
|
|
14
|
+
try {
|
|
15
|
+
execSync("git -v", { stdio: "pipe" });
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Determines if the current working directory is a git repository
|
|
24
|
+
* @returns True if it's a git repo
|
|
25
|
+
*/
|
|
26
|
+
static isGitProject(): boolean {
|
|
27
|
+
try {
|
|
28
|
+
execSync("git rev-parse --is-inside-work-tree", { stdio: "pipe" });
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns all gitignored files
|
|
37
|
+
*
|
|
38
|
+
* @returns Relative paths for files
|
|
39
|
+
*/
|
|
40
|
+
static getGitignoredFiles = () => {
|
|
41
|
+
const paths = Detector.getGitignoredPaths();
|
|
42
|
+
|
|
43
|
+
return paths.filter((path) => !path.endsWith("/"));
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns all gitignored directories
|
|
48
|
+
*
|
|
49
|
+
* @returns Relative paths for directories
|
|
50
|
+
*/
|
|
51
|
+
static getGitignoredDirectories = () => {
|
|
52
|
+
const paths = this.getGitignoredPaths();
|
|
53
|
+
|
|
54
|
+
return paths.filter((path) => path.endsWith("/"));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Runs the git command to get all git-ignored paths (both files and directories).
|
|
59
|
+
* Returns an array of paths that are ignored by git.
|
|
60
|
+
*
|
|
61
|
+
* @returns Array of git-ignored paths (relative to repo root)
|
|
62
|
+
*/
|
|
63
|
+
private static getGitignoredPaths(): string[] {
|
|
64
|
+
try {
|
|
65
|
+
const command =
|
|
66
|
+
"git ls-files --directory --no-empty-directory --others --ignored --exclude-standard";
|
|
67
|
+
const output = execSync(command, {
|
|
68
|
+
encoding: "utf-8",
|
|
69
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
70
|
+
});
|
|
71
|
+
const lines = output
|
|
72
|
+
.trim()
|
|
73
|
+
.split("\n")
|
|
74
|
+
.filter((line) => line.length > 0);
|
|
75
|
+
return lines;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// Silently ignore if not a git repository
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Checks if a path exists
|
|
84
|
+
*
|
|
85
|
+
* @param path The path to check
|
|
86
|
+
* @returns True if path exists
|
|
87
|
+
*/
|
|
88
|
+
static async pathExists(path: string): Promise<boolean> {
|
|
89
|
+
try {
|
|
90
|
+
await access(path);
|
|
91
|
+
return true;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Checks if a path is contained within any of the allowed directories.
|
|
99
|
+
* The check is recursive: if `/a` is allowed, then `/a/b/c` also passes.
|
|
100
|
+
*
|
|
101
|
+
* @param dirs Allowed directories
|
|
102
|
+
* @param path The path to check (will be resolved relative to cwd)
|
|
103
|
+
* @param ctx The extension context
|
|
104
|
+
* @returns True if the path is within at least one allowed directory
|
|
105
|
+
*/
|
|
106
|
+
static isPathAllowed(
|
|
107
|
+
dirs: string[],
|
|
108
|
+
path: string,
|
|
109
|
+
ctx: ExtensionContext,
|
|
110
|
+
): boolean {
|
|
111
|
+
// Expand ~ to home directory before resolving
|
|
112
|
+
const normalizedPath = Detector.normalizePath(path);
|
|
113
|
+
const absPath = resolve(ctx.cwd, normalizedPath);
|
|
114
|
+
const absDirs = dirs.map((dir) =>
|
|
115
|
+
resolve(ctx.cwd, Detector.normalizePath(dir)),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return absDirs.some(
|
|
119
|
+
(dir) => absPath === dir || absPath.startsWith(dir + sep),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Normalizes the path when it comes with a tilde (representing HOME)
|
|
125
|
+
* @param path The path
|
|
126
|
+
* @returns A normalized path
|
|
127
|
+
*/
|
|
128
|
+
private static normalizePath(path: string): string {
|
|
129
|
+
return path.replace(/^~\//g, homedir() + sep);
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/gitbox.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BashToolCallEvent,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
isToolCallEventType,
|
|
5
|
+
ToolCallEvent,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
8
|
+
import { basename, resolve } from "node:path";
|
|
9
|
+
import { GitboxConfig, Status } from "./config-types";
|
|
10
|
+
import { Detector } from "./detector";
|
|
11
|
+
import { Impersonator } from "./impersonator";
|
|
12
|
+
import { Renderer } from "./renderer";
|
|
13
|
+
import { settings } from "./settings";
|
|
14
|
+
|
|
15
|
+
export class Gitbox {
|
|
16
|
+
constructor(private readonly impersonator: Impersonator) {}
|
|
17
|
+
|
|
18
|
+
async initialize(ctx: ExtensionContext) {
|
|
19
|
+
await this.verifySettings(ctx);
|
|
20
|
+
|
|
21
|
+
if (!Detector.isGitAvailable()) {
|
|
22
|
+
await this.setStatus(ctx);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await this.impersonator.initialize(ctx);
|
|
27
|
+
await this.getOrCreate(ctx);
|
|
28
|
+
await this.setStatus(ctx);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async shutdown(ctx: ExtensionContext) {
|
|
32
|
+
const { config } = await settings.getConfig();
|
|
33
|
+
const { deleteOnExit } = config;
|
|
34
|
+
|
|
35
|
+
if (deleteOnExit) await this.removeGitbox(ctx);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Determines if the event is a "bash" tool call
|
|
40
|
+
* @param event The event
|
|
41
|
+
* @returns True if "bash" event
|
|
42
|
+
*/
|
|
43
|
+
isBashEvent(event: ToolCallEvent): event is BashToolCallEvent {
|
|
44
|
+
return isToolCallEventType("bash", event);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Determines if the event is a tool call where "path" exists
|
|
49
|
+
* @param event The event
|
|
50
|
+
* @returns True if "path" exists
|
|
51
|
+
*/
|
|
52
|
+
isPathEvent(event: ToolCallEvent): boolean {
|
|
53
|
+
const pathTools = ["read", "edit", "write", "find", "grep", "ls"];
|
|
54
|
+
return pathTools.some((tool) => isToolCallEventType(tool, event));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Impersonates the bash command, if possible
|
|
59
|
+
*
|
|
60
|
+
* @param cmd The bash command whose paths will be impersonated
|
|
61
|
+
* @param ctx The extension context
|
|
62
|
+
* @returns The command with impersonated paths
|
|
63
|
+
*/
|
|
64
|
+
async resolveCommand(cmd: string, ctx: ExtensionContext): Promise<string> {
|
|
65
|
+
return await this.impersonator.resolveCommand(cmd, ctx);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Impersonates the path, if possible
|
|
70
|
+
*
|
|
71
|
+
* @param path The path to be impersonated
|
|
72
|
+
* @param ctx The extension context
|
|
73
|
+
* @returns The impersonated path, if available
|
|
74
|
+
*/
|
|
75
|
+
async resolvePath(path: string, ctx: ExtensionContext): Promise<string> {
|
|
76
|
+
return await this.impersonator.resolvePath(path, ctx);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns the mapper used by the impersonator.
|
|
81
|
+
* Delegates to impersonator instance
|
|
82
|
+
*
|
|
83
|
+
* @param ctx The extension context
|
|
84
|
+
* @returns The source -> target path mapping
|
|
85
|
+
*/
|
|
86
|
+
getMapper(ctx: ExtensionContext): Record<string, string> {
|
|
87
|
+
return this.impersonator.getMapper(ctx.cwd);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validates the configuration settings
|
|
92
|
+
*
|
|
93
|
+
* @param ctx The extension context
|
|
94
|
+
* @returns The validated configuration
|
|
95
|
+
*/
|
|
96
|
+
private async verifySettings(ctx: ExtensionContext): Promise<GitboxConfig> {
|
|
97
|
+
const { config, errors } = await settings.getConfig();
|
|
98
|
+
if (errors.length > 0) {
|
|
99
|
+
const message = ["[pi-gitbox]", ...errors].join("\n");
|
|
100
|
+
ctx.ui.notify(message, "warning");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return config;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Creates the base gitbox
|
|
108
|
+
*
|
|
109
|
+
* @param ctx The extension context
|
|
110
|
+
* @returns The path for the gitbox
|
|
111
|
+
*/
|
|
112
|
+
private async getOrCreate(ctx: ExtensionContext): Promise<string> {
|
|
113
|
+
const { config } = await settings.getConfig();
|
|
114
|
+
const { baseDir } = config;
|
|
115
|
+
const cwd = basename(ctx.cwd);
|
|
116
|
+
|
|
117
|
+
const impersonationDir = resolve(baseDir, cwd);
|
|
118
|
+
if (await Detector.pathExists(impersonationDir)) return impersonationDir;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
await mkdir(impersonationDir, { recursive: true });
|
|
122
|
+
return impersonationDir;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
throw new Error(`Failed to create gitbox: ${error}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Removes the gitbox
|
|
130
|
+
*
|
|
131
|
+
* @param ctx The extension context
|
|
132
|
+
*/
|
|
133
|
+
private async removeGitbox(ctx: ExtensionContext): Promise<void> {
|
|
134
|
+
const gitboxPath = await this.getOrCreate(ctx);
|
|
135
|
+
|
|
136
|
+
if (!(await Detector.pathExists(gitboxPath))) return;
|
|
137
|
+
try {
|
|
138
|
+
await rm(gitboxPath, { recursive: true });
|
|
139
|
+
} catch (error) {
|
|
140
|
+
ctx.ui.notify(`Failed to remove gitbox: ${error}`, "error");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Determines the current status of the gitbox based on its existence.
|
|
146
|
+
*
|
|
147
|
+
* @returns Status
|
|
148
|
+
* - "BYPASSED" if bypassGitbox is enabled
|
|
149
|
+
* - "ENABLED" if the gitbox was created and gitignored paths exist
|
|
150
|
+
* - "AVAILABLE" if the gitbox was created, but there are no gitignored paths
|
|
151
|
+
* - "NOT_REQUIRED" if the current working directory is not a git repository
|
|
152
|
+
* - "UNAVAILABLE" if `git` command is not found
|
|
153
|
+
*/
|
|
154
|
+
private async getStatus(): Promise<Status> {
|
|
155
|
+
const { config } = await settings.getConfig();
|
|
156
|
+
const { bypassGitbox } = config;
|
|
157
|
+
|
|
158
|
+
// If bypass mode is enabled, return bypassed status
|
|
159
|
+
if (bypassGitbox) return Status.BYPASSED;
|
|
160
|
+
|
|
161
|
+
if (!Detector.isGitAvailable()) return Status.UNAVAILABLE;
|
|
162
|
+
if (!Detector.isGitProject()) return Status.NOT_REQUIRED;
|
|
163
|
+
|
|
164
|
+
const paths = Detector.getGitignoredFiles();
|
|
165
|
+
if (paths.length === 0) return Status.AVAILABLE;
|
|
166
|
+
|
|
167
|
+
return Status.ENABLED;
|
|
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
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { parse } from "shell-quote";
|
|
5
|
+
import { Detector } from "./detector";
|
|
6
|
+
import { settings } from "./settings";
|
|
7
|
+
|
|
8
|
+
export class Impersonator {
|
|
9
|
+
private mapper: Record<string, string> = {};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fills the mapper with paths that are gitignored
|
|
13
|
+
*
|
|
14
|
+
* @param ctx The extension context
|
|
15
|
+
*/
|
|
16
|
+
async initialize(ctx: ExtensionContext): Promise<void> {
|
|
17
|
+
this.mapper = {};
|
|
18
|
+
const { config } = await settings.getConfig();
|
|
19
|
+
|
|
20
|
+
await this.initializeDirectories(config.baseDir, ctx);
|
|
21
|
+
await this.initializeFiles(config.baseDir, ctx);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Initializes the mapper for directories
|
|
26
|
+
*
|
|
27
|
+
* @param baseDir Gitbox basedir
|
|
28
|
+
* @param ctx The extension context
|
|
29
|
+
*/
|
|
30
|
+
private async initializeDirectories(baseDir: string, ctx: ExtensionContext) {
|
|
31
|
+
const gitignoredDirectories = Detector.getGitignoredDirectories();
|
|
32
|
+
const projectDir = join(baseDir, basename(ctx.cwd));
|
|
33
|
+
|
|
34
|
+
for (const path of gitignoredDirectories) {
|
|
35
|
+
const impersonation = await this.createDirectory(path, projectDir, ctx);
|
|
36
|
+
const absPath = resolve(ctx.cwd, path);
|
|
37
|
+
|
|
38
|
+
if (impersonation) {
|
|
39
|
+
this.mapper[absPath] = impersonation;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Initializes the mapper for files
|
|
46
|
+
*
|
|
47
|
+
* @param baseDir Gitbox basedir
|
|
48
|
+
* @param ctx The extension context
|
|
49
|
+
*/
|
|
50
|
+
private async initializeFiles(baseDir: string, ctx: ExtensionContext) {
|
|
51
|
+
const gitignoredFiles = Detector.getGitignoredFiles();
|
|
52
|
+
const projectDir = join(baseDir, basename(ctx.cwd));
|
|
53
|
+
|
|
54
|
+
for (const path of gitignoredFiles) {
|
|
55
|
+
const impersonation = await this.createFile(path, projectDir, ctx);
|
|
56
|
+
const absPath = resolve(ctx.cwd, path);
|
|
57
|
+
|
|
58
|
+
if (impersonation) {
|
|
59
|
+
this.mapper[absPath] = impersonation;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Creates an impersonated directory for a git-ignored path.
|
|
66
|
+
*
|
|
67
|
+
* @param relativePath The original path
|
|
68
|
+
* @param parentDir The parent path to use with relativePath
|
|
69
|
+
* @param ctx The extension context
|
|
70
|
+
* @returns Absolute path to impersonated directory
|
|
71
|
+
*/
|
|
72
|
+
private async createDirectory(
|
|
73
|
+
relativePath: string,
|
|
74
|
+
parentDir: string,
|
|
75
|
+
ctx: ExtensionContext,
|
|
76
|
+
): Promise<string> {
|
|
77
|
+
// Setup the gitbox for the project
|
|
78
|
+
const impersonatingPath = resolve(parentDir, relativePath);
|
|
79
|
+
|
|
80
|
+
// Create directory if it doesn't exist
|
|
81
|
+
if (!(await Detector.pathExists(impersonatingPath))) {
|
|
82
|
+
try {
|
|
83
|
+
await mkdir(impersonatingPath, { recursive: true });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
ctx.ui.notify(
|
|
86
|
+
`Failed to create impersonated directory: ${error}`,
|
|
87
|
+
"error",
|
|
88
|
+
);
|
|
89
|
+
return relativePath;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return impersonatingPath;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Creates an impersonated file for a git-ignored path.
|
|
98
|
+
*
|
|
99
|
+
* @param relativePath The original path
|
|
100
|
+
* @param parentDir The parent path to use with relativePath
|
|
101
|
+
* @param ctx The extension context
|
|
102
|
+
* @returns Absolute path to impersonated file
|
|
103
|
+
*/
|
|
104
|
+
private async createFile(
|
|
105
|
+
relativePath: string,
|
|
106
|
+
parentDir: string,
|
|
107
|
+
ctx: ExtensionContext,
|
|
108
|
+
): Promise<string> {
|
|
109
|
+
// Create the impersonated file
|
|
110
|
+
const content = relativePath.endsWith(".json") ? "{}" : " ";
|
|
111
|
+
|
|
112
|
+
// Setup the gitbox for the project
|
|
113
|
+
const impersonatingPath = resolve(parentDir, relativePath);
|
|
114
|
+
|
|
115
|
+
// Create file if it doesn't exist
|
|
116
|
+
if (!(await Detector.pathExists(impersonatingPath))) {
|
|
117
|
+
try {
|
|
118
|
+
// Ensure parent directories exist first
|
|
119
|
+
const parentDir = resolve(dirname(impersonatingPath));
|
|
120
|
+
await mkdir(parentDir, { recursive: true });
|
|
121
|
+
|
|
122
|
+
await writeFile(impersonatingPath, content);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
ctx.ui.notify(`Failed to create impersonated file: ${error}`, "error");
|
|
125
|
+
return relativePath;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return impersonatingPath;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Returns a copy of the current mapper of impersonated paths.
|
|
134
|
+
* Sources are relative paths from the base directory to the original file/directory.
|
|
135
|
+
*
|
|
136
|
+
* @param baseDir The base directory to resolve relative paths against
|
|
137
|
+
* @returns The source -> target path mapping
|
|
138
|
+
*/
|
|
139
|
+
getMapper(baseDir: string): Record<string, string> {
|
|
140
|
+
const result: Record<string, string> = {};
|
|
141
|
+
|
|
142
|
+
for (const [absSource, target] of Object.entries(this.mapper)) {
|
|
143
|
+
result[relative(baseDir, absSource)] = target;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Impersonates the bash command, if possible
|
|
151
|
+
*
|
|
152
|
+
* @param cmd The bash command whose paths will be impersonated
|
|
153
|
+
* @param ctx The extension context
|
|
154
|
+
* @returns The command with impersonated paths
|
|
155
|
+
*/
|
|
156
|
+
async resolveCommand(cmd: string, ctx: ExtensionContext): Promise<string> {
|
|
157
|
+
const paths = await this.extractFromCommand(cmd);
|
|
158
|
+
|
|
159
|
+
let response = cmd;
|
|
160
|
+
for (const path of paths) {
|
|
161
|
+
const impersonation = await this.resolvePath(path, ctx);
|
|
162
|
+
response = response.replace(path, impersonation);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return response;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Impersonates the path, if possible
|
|
170
|
+
*
|
|
171
|
+
* @param path The path to be impersonated
|
|
172
|
+
* @param ctx The extension context
|
|
173
|
+
* @returns The impersonated path, if available
|
|
174
|
+
*/
|
|
175
|
+
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;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Couldn't find anything to impersonate, return the original path
|
|
200
|
+
return path;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Extracts potential file paths from a bash command string.
|
|
205
|
+
*
|
|
206
|
+
* @param command - The bash command string to parse
|
|
207
|
+
* @returns Array of path arguments found in the command
|
|
208
|
+
*/
|
|
209
|
+
async extractFromCommand(command: string): Promise<string[]> {
|
|
210
|
+
// Tokenize the command using shell-quote
|
|
211
|
+
const tokens = parse(command);
|
|
212
|
+
|
|
213
|
+
// Collect tokens that are not operators or globs - these could be paths
|
|
214
|
+
const paths = tokens
|
|
215
|
+
.filter((token) => typeof token === "string")
|
|
216
|
+
.filter(this.isPathLike);
|
|
217
|
+
|
|
218
|
+
return paths;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Detects if a string is a possible path (heuristic approach)
|
|
223
|
+
*
|
|
224
|
+
* Takes a cautious approach, only rejecting candidates that are
|
|
225
|
+
* impossible to be paths.
|
|
226
|
+
*
|
|
227
|
+
* @param candidate Candidate path to check
|
|
228
|
+
* @returns True if it looks like a path
|
|
229
|
+
*/
|
|
230
|
+
private isPathLike(candidate: string): boolean {
|
|
231
|
+
// Empty strings
|
|
232
|
+
if (!candidate) return false;
|
|
233
|
+
|
|
234
|
+
// Command flags and options
|
|
235
|
+
if (candidate.startsWith("-")) return false;
|
|
236
|
+
|
|
237
|
+
// Pure numbers (e.g., 42, 3.14, -0)
|
|
238
|
+
if (/^-?\d+(\.\d+)?$/.test(candidate)) return false;
|
|
239
|
+
|
|
240
|
+
// URLs (e.g., https://example.com, ftp://...)
|
|
241
|
+
if (/^[a-z]+:\/\//i.test(candidate)) return false;
|
|
242
|
+
|
|
243
|
+
// Environment variable assignments (e.g., NODE_ENV=production)
|
|
244
|
+
if (/^[A-Z_]+=/.test(candidate)) return false;
|
|
245
|
+
|
|
246
|
+
// Shell reserved words and syntax keywords that can never be file paths.
|
|
247
|
+
const shellReservedWords = new Set([
|
|
248
|
+
"then",
|
|
249
|
+
"else",
|
|
250
|
+
"fi",
|
|
251
|
+
"do",
|
|
252
|
+
"done",
|
|
253
|
+
"esac",
|
|
254
|
+
"endif",
|
|
255
|
+
"end",
|
|
256
|
+
"true",
|
|
257
|
+
"false",
|
|
258
|
+
]);
|
|
259
|
+
if (shellReservedWords.has(candidate)) return false;
|
|
260
|
+
|
|
261
|
+
// Default: assume it's path-like (prefer false positives)
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { settings } from "./settings";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Prompt options
|
|
6
|
+
*/
|
|
7
|
+
enum Options {
|
|
8
|
+
ALLOW = "Allow",
|
|
9
|
+
DENY = "Deny",
|
|
10
|
+
BYPASS_SESSION = "Bypass (session only)",
|
|
11
|
+
BYPASS_SAVE = "Bypass (saved globally)",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Asks the user if they want to allow access to a path outside the allowed directories.
|
|
16
|
+
* If the user denies, or if UI is not available, the function blocks with a reason.
|
|
17
|
+
*
|
|
18
|
+
* @param ctx The extension context
|
|
19
|
+
* @param path The path to check
|
|
20
|
+
* @returns An object with `block: true` and a `reason` if blocked, or `{ block: false }` if allowed
|
|
21
|
+
*/
|
|
22
|
+
export async function askUserOrBlock(
|
|
23
|
+
ctx: ExtensionContext,
|
|
24
|
+
path: string,
|
|
25
|
+
): Promise<{ block: boolean; reason?: string }> {
|
|
26
|
+
const reason = `Path "${path}" is outside allowed directories and was denied.`;
|
|
27
|
+
|
|
28
|
+
// Check if UI is available
|
|
29
|
+
const { config } = await settings.getConfig();
|
|
30
|
+
if (!ctx.hasUI) {
|
|
31
|
+
return { block: true, reason };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const prompt = `[pi-gitbox]: Allow "${path}" to be accessed?`;
|
|
35
|
+
const allowed = await ctx.ui.select(prompt, Object.values(Options));
|
|
36
|
+
|
|
37
|
+
// Process the selected option
|
|
38
|
+
if (!allowed || allowed === Options.DENY) return { block: true, reason };
|
|
39
|
+
|
|
40
|
+
// Handle the bypass options.
|
|
41
|
+
if (allowed === Options.BYPASS_SESSION) {
|
|
42
|
+
config.allowedPaths = [...config.allowedPaths, path];
|
|
43
|
+
} else if (allowed === Options.BYPASS_SAVE) {
|
|
44
|
+
await settings.setConfig({ allowedPaths: [...config.allowedPaths, path] });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { block: false };
|
|
48
|
+
}
|
package/src/renderer.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Status } from "./config-types";
|
|
3
|
+
import { STATUS_KEY } from "./defaults";
|
|
4
|
+
import { settings } from "./settings";
|
|
5
|
+
|
|
6
|
+
export class Renderer {
|
|
7
|
+
private static lastStatus: string = "";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sets the status of the gitbox in the extension UI.
|
|
11
|
+
* @param ctx The extension context.
|
|
12
|
+
* @param status The status to set
|
|
13
|
+
*/
|
|
14
|
+
static async setStatus(ctx: ExtensionContext, status: Status): Promise<void> {
|
|
15
|
+
// Color the status with HEX codes
|
|
16
|
+
const colorMapper: Record<Status, string> = {
|
|
17
|
+
[Status.ENABLED]: "#00ff88",
|
|
18
|
+
[Status.AVAILABLE]: "#ffaa00",
|
|
19
|
+
[Status.NOT_REQUIRED]: "#ff8800",
|
|
20
|
+
[Status.UNAVAILABLE]: "#ff4444",
|
|
21
|
+
[Status.BYPASSED]: "#44ddff",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const theme = ctx.ui.theme;
|
|
25
|
+
const coloredStatus = Renderer.colorHex(status, colorMapper[status]);
|
|
26
|
+
|
|
27
|
+
Renderer.lastStatus = `${theme.fg("dim", "📦 Gitbox:")} ${coloredStatus}`;
|
|
28
|
+
await this.update(ctx);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Updates the status bar with the current status and any bypass indicators.
|
|
33
|
+
*
|
|
34
|
+
* @param ctx The extension context
|
|
35
|
+
*/
|
|
36
|
+
static async update(ctx: ExtensionContext): Promise<void> {
|
|
37
|
+
const { config } = await settings.getConfig();
|
|
38
|
+
const { statusBar, bypassPaths } = config;
|
|
39
|
+
|
|
40
|
+
// Verify bypass status to announce it in status bar
|
|
41
|
+
let statusText = Renderer.lastStatus;
|
|
42
|
+
if (bypassPaths) statusText += " (unrestricted)";
|
|
43
|
+
|
|
44
|
+
if (statusBar) {
|
|
45
|
+
ctx.ui.setStatus(STATUS_KEY, statusText);
|
|
46
|
+
} else {
|
|
47
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Applies a custom hex color using 24-bit truecolor ANSI escape codes.
|
|
53
|
+
*
|
|
54
|
+
* @param text The text to colorize
|
|
55
|
+
* @param hex The hex color string, e.g. "#abcdef"
|
|
56
|
+
* @returns The colored text
|
|
57
|
+
*/
|
|
58
|
+
private static colorHex(text: string, hex: string): string {
|
|
59
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
60
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
61
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
62
|
+
|
|
63
|
+
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m\u200b`;
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { GitboxConfig } from "./config-types";
|
|
5
|
+
import {
|
|
6
|
+
ALLOWED_PATHS,
|
|
7
|
+
BYPASS_GITBOX,
|
|
8
|
+
BYPASS_PATHS,
|
|
9
|
+
DELETE_ON_EXIT,
|
|
10
|
+
GITBOX_BASEDIR,
|
|
11
|
+
GITBOX_STATUSBAR,
|
|
12
|
+
STATUS_KEY,
|
|
13
|
+
} from "./defaults";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Manages Gitbox configuration: defaults, user settings, validation,
|
|
17
|
+
* caching, and persistence to ~/.pi/agent/settings.json.
|
|
18
|
+
*/
|
|
19
|
+
class Settings {
|
|
20
|
+
private cachedConfig: GitboxConfig | null = null;
|
|
21
|
+
private cachedErrors: string[] = [];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Manages Gitbox configuration: defaults, user settings, validation,
|
|
25
|
+
* caching, and persistence to ~/.pi/agent/settings.json.
|
|
26
|
+
*
|
|
27
|
+
* @internal Use the exported `settings` singleton instead.
|
|
28
|
+
*/
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly path: string = join(getAgentDir(), "settings.json"),
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Retrieves the default configuration object.
|
|
35
|
+
*
|
|
36
|
+
* @returns The default configuration.
|
|
37
|
+
*/
|
|
38
|
+
private getDefaultConfig(): GitboxConfig {
|
|
39
|
+
return {
|
|
40
|
+
baseDir: GITBOX_BASEDIR,
|
|
41
|
+
statusBar: GITBOX_STATUSBAR,
|
|
42
|
+
deleteOnExit: DELETE_ON_EXIT,
|
|
43
|
+
bypassGitbox: BYPASS_GITBOX,
|
|
44
|
+
bypassPaths: BYPASS_PATHS,
|
|
45
|
+
allowedPaths: ALLOWED_PATHS,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolves the final config, merging user settings with built-in defaults,
|
|
51
|
+
* validating, and caching the result.
|
|
52
|
+
*/
|
|
53
|
+
async getConfig(): Promise<{ config: GitboxConfig; errors: string[] }> {
|
|
54
|
+
if (this.cachedConfig)
|
|
55
|
+
return { config: this.cachedConfig, errors: this.cachedErrors };
|
|
56
|
+
|
|
57
|
+
const defaults = this.getDefaultConfig();
|
|
58
|
+
const userSettings = await this.readExtensionSettings();
|
|
59
|
+
|
|
60
|
+
this.cachedConfig = { ...defaults, ...userSettings };
|
|
61
|
+
|
|
62
|
+
return { config: this.cachedConfig, errors: this.cachedErrors };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Writes a partial GitboxConfig, invalidating the cache.
|
|
67
|
+
*/
|
|
68
|
+
async setConfig(partial: Partial<GitboxConfig>): Promise<void> {
|
|
69
|
+
await this.writeExtensionSettings(partial);
|
|
70
|
+
this.resetConfigCache();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resets the cached config, forcing a fresh read from disk on the next call.
|
|
75
|
+
*/
|
|
76
|
+
resetConfigCache(): void {
|
|
77
|
+
this.cachedConfig = null;
|
|
78
|
+
this.cachedErrors = [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Reads ~/.pi/agent/settings.json and extracts the "gitbox" settings block.
|
|
83
|
+
*
|
|
84
|
+
* @returns The Gitbox settings object.
|
|
85
|
+
*/
|
|
86
|
+
private async readExtensionSettings(): Promise<GitboxConfig> {
|
|
87
|
+
const settings = await this.readSettings();
|
|
88
|
+
return (settings[STATUS_KEY] || {}) as GitboxConfig;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Writes a partial GitboxConfig to ~/.pi/agent/settings.json,
|
|
93
|
+
* merging it with existing values.
|
|
94
|
+
*
|
|
95
|
+
* @param partial The partial GitboxConfig to write.
|
|
96
|
+
*/
|
|
97
|
+
private async writeExtensionSettings(
|
|
98
|
+
partial: Partial<GitboxConfig>,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const settings = await this.readSettings();
|
|
101
|
+
const current = (settings[STATUS_KEY] as Record<string, unknown>) || {};
|
|
102
|
+
settings[STATUS_KEY] = { ...current, ...partial };
|
|
103
|
+
|
|
104
|
+
await this.writeSettings(settings);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Reads and parses a JSON file.
|
|
109
|
+
*
|
|
110
|
+
* @returns The parsed JSON object, or an empty object on failure.
|
|
111
|
+
*/
|
|
112
|
+
async readSettings(): Promise<Record<string, unknown>> {
|
|
113
|
+
try {
|
|
114
|
+
const raw = await readFile(this.path, "utf-8");
|
|
115
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
116
|
+
} catch {
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Writes a JSON object to the file with 2-space indentation.
|
|
123
|
+
*
|
|
124
|
+
* @param data The object to serialize and write.
|
|
125
|
+
*/
|
|
126
|
+
async writeSettings(data: Record<string, unknown>): Promise<void> {
|
|
127
|
+
await writeFile(this.path, JSON.stringify(data, null, 2), "utf-8");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Shared singleton instance used across the extension.
|
|
133
|
+
*/
|
|
134
|
+
export const settings = new Settings();
|
package/tsconfig.json
ADDED