@aliou/pi-guardrails 0.10.0 → 0.11.1
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 +46 -3
- package/docs/defaults.md +26 -1
- package/package.json +5 -2
- package/src/commands/onboarding-command.ts +19 -2
- package/src/commands/onboarding.ts +122 -6
- package/src/commands/settings-command.ts +197 -0
- package/src/config.ts +50 -1
- package/src/hooks/index.ts +4 -2
- package/src/hooks/path-access.ts +395 -0
- package/src/hooks/permission-gate/dangerous-commands.test.ts +336 -0
- package/src/hooks/permission-gate/dangerous-commands.ts +345 -0
- package/src/hooks/permission-gate/index.test.ts +332 -0
- package/src/hooks/{permission-gate.ts → permission-gate/index.ts} +35 -62
- package/src/utils/bash-paths.test.ts +91 -0
- package/src/utils/bash-paths.ts +96 -0
- package/src/utils/events.ts +1 -1
- package/src/utils/migration.test.ts +58 -0
- package/src/utils/migration.ts +45 -0
- package/src/utils/path-access.test.ts +154 -0
- package/src/utils/path-access.ts +62 -0
- package/src/utils/path.test.ts +177 -0
- package/src/utils/path.ts +63 -7
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
1
3
|
# Guardrails
|
|
2
4
|
|
|
3
5
|
Security hooks for Pi to reduce accidental destructive actions and secret-file access.
|
|
@@ -27,6 +29,7 @@ pi install git:github.com/aliou/pi-guardrails
|
|
|
27
29
|
|
|
28
30
|
- **policies**: named file-protection rules with per-rule protection levels.
|
|
29
31
|
- **permission-gate**: detects dangerous bash commands and asks for confirmation.
|
|
32
|
+
- **path-access**: restricts tool access to the current working directory with allow/ask/block modes.
|
|
30
33
|
- **optional command explainer**: can call a small LLM to explain a dangerous command inline in the confirmation dialog.
|
|
31
34
|
|
|
32
35
|
## Config locations
|
|
@@ -48,7 +51,12 @@ Use `/guardrails:settings` to edit config interactively.
|
|
|
48
51
|
"enabled": true,
|
|
49
52
|
"features": {
|
|
50
53
|
"policies": true,
|
|
51
|
-
"permissionGate": true
|
|
54
|
+
"permissionGate": true,
|
|
55
|
+
"pathAccess": false
|
|
56
|
+
},
|
|
57
|
+
"pathAccess": {
|
|
58
|
+
"mode": "ask",
|
|
59
|
+
"allowedPaths": []
|
|
52
60
|
},
|
|
53
61
|
"policies": {
|
|
54
62
|
"rules": [
|
|
@@ -121,6 +129,31 @@ Use:
|
|
|
121
129
|
|
|
122
130
|
This starts a subagent that helps build and save one policy rule.
|
|
123
131
|
|
|
132
|
+
## Path access
|
|
133
|
+
|
|
134
|
+
Restrict tool access to the current working directory. When enabled, any tool call targeting a path outside `cwd` is checked against the configured mode:
|
|
135
|
+
|
|
136
|
+
- **allow**: no restrictions
|
|
137
|
+
- **ask**: prompt with options to grant access (file or directory, for session or always)
|
|
138
|
+
- **block**: deny all outside access
|
|
139
|
+
|
|
140
|
+
```jsonc
|
|
141
|
+
{
|
|
142
|
+
"features": { "pathAccess": true },
|
|
143
|
+
"pathAccess": {
|
|
144
|
+
"mode": "ask",
|
|
145
|
+
"allowedPaths": ["~/code/shared-libs/", "~/.config/myapp"]
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Grants are stored in project config (always) or session memory (session). The `allowedPaths` array is merged across all config scopes.
|
|
151
|
+
|
|
152
|
+
Limitations:
|
|
153
|
+
- Symlinks are not resolved (lexical path comparison only).
|
|
154
|
+
- Bash path extraction is best-effort (AST-based heuristics).
|
|
155
|
+
- In non-interactive mode, `ask` mode degrades to `block`.
|
|
156
|
+
|
|
124
157
|
## Permission gate
|
|
125
158
|
|
|
126
159
|
Detects dangerous bash commands and prompts user confirmation.
|
|
@@ -161,6 +194,16 @@ Also note:
|
|
|
161
194
|
|
|
162
195
|
- `preventBrew`, `preventPython`, `enforcePackageManager`, `packageManager` were removed from guardrails and moved to `@aliou/pi-toolchain`.
|
|
163
196
|
|
|
197
|
+
## Development
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
pnpm test # Run tests
|
|
201
|
+
pnpm test:watch # Run tests in watch mode
|
|
202
|
+
pnpm typecheck # Type check
|
|
203
|
+
pnpm lint # Lint
|
|
204
|
+
pnpm format # Format
|
|
205
|
+
```
|
|
206
|
+
|
|
164
207
|
## Events
|
|
165
208
|
|
|
166
209
|
Guardrails emits events for other extensions:
|
|
@@ -169,7 +212,7 @@ Guardrails emits events for other extensions:
|
|
|
169
212
|
|
|
170
213
|
```ts
|
|
171
214
|
interface GuardrailsBlockedEvent {
|
|
172
|
-
feature: "policies" | "permissionGate";
|
|
215
|
+
feature: "policies" | "permissionGate" | "pathAccess";
|
|
173
216
|
toolName: string;
|
|
174
217
|
input: Record<string, unknown>;
|
|
175
218
|
reason: string;
|
|
@@ -185,4 +228,4 @@ interface GuardrailsDangerousEvent {
|
|
|
185
228
|
description: string;
|
|
186
229
|
pattern: string;
|
|
187
230
|
}
|
|
188
|
-
```
|
|
231
|
+
```
|
package/docs/defaults.md
CHANGED
|
@@ -101,6 +101,31 @@ Blocks access to GPG/GnuPG private keys, keyrings, and configuration. Disabled b
|
|
|
101
101
|
|
|
102
102
|
---
|
|
103
103
|
|
|
104
|
+
## Path Access
|
|
105
|
+
|
|
106
|
+
| Setting | Default |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `features.pathAccess` | `false` |
|
|
109
|
+
| `pathAccess.mode` | `"ask"` |
|
|
110
|
+
| `pathAccess.allowedPaths` | `[]` |
|
|
111
|
+
|
|
112
|
+
Modes:
|
|
113
|
+
- `allow` — no path restrictions
|
|
114
|
+
- `ask` — prompt when accessing paths outside working directory
|
|
115
|
+
- `block` — deny all access outside working directory
|
|
116
|
+
|
|
117
|
+
Allowed paths use trailing-slash convention:
|
|
118
|
+
- `/path/to/file` — exact file match
|
|
119
|
+
- `/path/to/dir/` — directory and all descendants
|
|
120
|
+
- Supports `~/` for home directory
|
|
121
|
+
|
|
122
|
+
Limitations:
|
|
123
|
+
- Bash path extraction is best-effort (AST-based heuristics). Tokens like `application/json` may trigger false-positive prompts.
|
|
124
|
+
- Symlinks are not resolved. Lexical path comparison only.
|
|
125
|
+
- In non-interactive mode (--print), `ask` mode degrades to `block`.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
104
129
|
## Default Permission Gate Patterns
|
|
105
130
|
|
|
106
131
|
These commands are detected using AST-based structural matching for accuracy.
|
|
@@ -109,7 +134,7 @@ These commands are detected using AST-based structural matching for accuracy.
|
|
|
109
134
|
|-----------------|--------------------------------|
|
|
110
135
|
| `rm -rf` | Recursive force delete |
|
|
111
136
|
| `sudo` | Superuser command |
|
|
112
|
-
| `dd
|
|
137
|
+
| `dd of=` | Disk write operation |
|
|
113
138
|
| `mkfs.` | Filesystem format |
|
|
114
139
|
| `chmod -R 777` | Insecure recursive permissions |
|
|
115
140
|
| `chown -R` | Recursive ownership change |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliou/pi-guardrails",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
"@sinclair/typebox": "^0.34.48",
|
|
50
50
|
"@types/node": "^25.0.10",
|
|
51
51
|
"husky": "^9.1.7",
|
|
52
|
-
"typescript": "^5.9.3"
|
|
52
|
+
"typescript": "^5.9.3",
|
|
53
|
+
"vitest": "^4.1.4"
|
|
53
54
|
},
|
|
54
55
|
"peerDependenciesMeta": {
|
|
55
56
|
"@mariozechner/pi-agent-core": {
|
|
@@ -67,6 +68,8 @@
|
|
|
67
68
|
},
|
|
68
69
|
"scripts": {
|
|
69
70
|
"typecheck": "tsc --noEmit",
|
|
71
|
+
"test": "vitest run",
|
|
72
|
+
"test:watch": "vitest",
|
|
70
73
|
"lint": "biome check",
|
|
71
74
|
"format": "biome check --write",
|
|
72
75
|
"check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
|
|
@@ -10,12 +10,25 @@ import {
|
|
|
10
10
|
function mergeOnboarding(
|
|
11
11
|
base: GuardrailsConfig | null,
|
|
12
12
|
applyBuiltinDefaults: boolean,
|
|
13
|
+
pathAccessEnabled?: boolean | null,
|
|
13
14
|
): GuardrailsConfig {
|
|
14
15
|
const next = structuredClone(base ?? {});
|
|
15
|
-
const onboarded = buildOnboardedConfig(
|
|
16
|
+
const onboarded = buildOnboardedConfig(
|
|
17
|
+
applyBuiltinDefaults,
|
|
18
|
+
pathAccessEnabled,
|
|
19
|
+
);
|
|
16
20
|
next.applyBuiltinDefaults = onboarded.applyBuiltinDefaults;
|
|
17
21
|
next.version = onboarded.version;
|
|
18
22
|
next.onboarding = onboarded.onboarding;
|
|
23
|
+
if (onboarded.features?.pathAccess !== undefined) {
|
|
24
|
+
next.features = {
|
|
25
|
+
...next.features,
|
|
26
|
+
pathAccess: onboarded.features.pathAccess,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (onboarded.pathAccess) {
|
|
30
|
+
next.pathAccess = onboarded.pathAccess;
|
|
31
|
+
}
|
|
19
32
|
return next;
|
|
20
33
|
}
|
|
21
34
|
|
|
@@ -48,7 +61,11 @@ export function registerGuardrailsOnboardingCommand(
|
|
|
48
61
|
return;
|
|
49
62
|
}
|
|
50
63
|
|
|
51
|
-
const merged = mergeOnboarding(
|
|
64
|
+
const merged = mergeOnboarding(
|
|
65
|
+
globalConfig,
|
|
66
|
+
result.applyBuiltinDefaults,
|
|
67
|
+
result.pathAccessEnabled,
|
|
68
|
+
);
|
|
52
69
|
await configLoader.save("global", merged);
|
|
53
70
|
await configLoader.load();
|
|
54
71
|
|
|
@@ -11,11 +11,13 @@ import { CURRENT_VERSION } from "../utils/migration";
|
|
|
11
11
|
|
|
12
12
|
interface OnboardingState {
|
|
13
13
|
applyBuiltinDefaults: boolean | null;
|
|
14
|
+
pathAccessEnabled: boolean | null;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface OnboardingResult {
|
|
17
18
|
completed: boolean;
|
|
18
19
|
applyBuiltinDefaults: boolean | null;
|
|
20
|
+
pathAccessEnabled: boolean | null;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
class IntroStep implements Component {
|
|
@@ -68,7 +70,7 @@ class DefaultsChoiceStep implements Component {
|
|
|
68
70
|
"",
|
|
69
71
|
"- Protect secret files like `.env`, `.env.local`, `.env.production`, and `.dev.vars`",
|
|
70
72
|
"- Keep safe exceptions like `.env.example` and `*.sample.env`",
|
|
71
|
-
"- Require confirmation before running dangerous commands like `rm -rf`, `sudo`, and `dd
|
|
73
|
+
"- Require confirmation before running dangerous commands like `rm -rf`, `sudo`, and `dd of=`",
|
|
72
74
|
].join("\n"),
|
|
73
75
|
[
|
|
74
76
|
"Start with no built-in file policy defaults.",
|
|
@@ -129,6 +131,88 @@ class DefaultsChoiceStep implements Component {
|
|
|
129
131
|
}
|
|
130
132
|
}
|
|
131
133
|
|
|
134
|
+
class PathAccessStep implements Component {
|
|
135
|
+
private selectedIndex = 0;
|
|
136
|
+
private readonly settingsTheme: SettingsTheme;
|
|
137
|
+
|
|
138
|
+
constructor(
|
|
139
|
+
private readonly theme: Theme,
|
|
140
|
+
private readonly state: OnboardingState,
|
|
141
|
+
private readonly onSelect: () => void,
|
|
142
|
+
) {
|
|
143
|
+
this.settingsTheme = getSettingsTheme(theme);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
invalidate() {}
|
|
147
|
+
|
|
148
|
+
render(width: number): string[] {
|
|
149
|
+
const options = ["Ask before accessing outside files", "No restrictions"];
|
|
150
|
+
const explanations = [
|
|
151
|
+
[
|
|
152
|
+
"When enabled, guardrails will prompt you before the agent accesses files outside the current working directory.",
|
|
153
|
+
"",
|
|
154
|
+
"- You can grant access per-file or per-directory",
|
|
155
|
+
"- Grants can be session-only or permanent",
|
|
156
|
+
"- In non-interactive mode, outside access is blocked",
|
|
157
|
+
].join("\n"),
|
|
158
|
+
[
|
|
159
|
+
"The agent can access any path on your system without prompting.",
|
|
160
|
+
"",
|
|
161
|
+
"- You can enable path access later in `/guardrails:settings`",
|
|
162
|
+
].join("\n"),
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
const lines: string[] = [
|
|
166
|
+
" Restrict access to your project directory?",
|
|
167
|
+
"",
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
for (let i = 0; i < options.length; i++) {
|
|
171
|
+
const option = options[i];
|
|
172
|
+
if (!option) continue;
|
|
173
|
+
const selected = i === this.selectedIndex;
|
|
174
|
+
const prefix = selected ? this.settingsTheme.cursor : " ";
|
|
175
|
+
const label = this.settingsTheme.value(` ${option}`, selected);
|
|
176
|
+
lines.push(`${prefix}${label}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
lines.push("");
|
|
180
|
+
|
|
181
|
+
const explanationBox = new Box(1, 0, (s: string) => s);
|
|
182
|
+
explanationBox.addChild(
|
|
183
|
+
new Markdown(
|
|
184
|
+
explanations[this.selectedIndex] ?? "",
|
|
185
|
+
0,
|
|
186
|
+
0,
|
|
187
|
+
getMarkdownTheme(),
|
|
188
|
+
{
|
|
189
|
+
color: (s: string) => this.theme.fg("text", s),
|
|
190
|
+
},
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
lines.push(...explanationBox.render(Math.max(1, width)));
|
|
195
|
+
|
|
196
|
+
return lines;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
handleInput(data: string): void {
|
|
200
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
201
|
+
this.selectedIndex = this.selectedIndex === 0 ? 1 : 0;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (matchesKey(data, Key.down) || data === "j") {
|
|
205
|
+
this.selectedIndex = this.selectedIndex === 1 ? 0 : 1;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (matchesKey(data, Key.enter)) {
|
|
210
|
+
this.state.pathAccessEnabled = this.selectedIndex === 0;
|
|
211
|
+
this.onSelect();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
132
216
|
class FinishStep implements Component {
|
|
133
217
|
private readonly recapMarkdown = new Markdown("", 2, 0, getMarkdownTheme());
|
|
134
218
|
|
|
@@ -142,7 +226,7 @@ class FinishStep implements Component {
|
|
|
142
226
|
}
|
|
143
227
|
|
|
144
228
|
render(width: number): string[] {
|
|
145
|
-
const
|
|
229
|
+
const defaultsPart =
|
|
146
230
|
this.state.applyBuiltinDefaults === true
|
|
147
231
|
? [
|
|
148
232
|
"You selected **Recommended defaults**.",
|
|
@@ -150,7 +234,7 @@ class FinishStep implements Component {
|
|
|
150
234
|
"Guardrails will start with built-in protection, including:",
|
|
151
235
|
"- secret files like `.env`, `.env.local`, `.env.production`, `.dev.vars`",
|
|
152
236
|
"- safe exceptions like `.env.example` and `*.sample.env`",
|
|
153
|
-
"- confirmation before running dangerous commands like `rm -rf`, `sudo`, `dd
|
|
237
|
+
"- confirmation before running dangerous commands like `rm -rf`, `sudo`, `dd of=`",
|
|
154
238
|
].join("\n")
|
|
155
239
|
: [
|
|
156
240
|
"You selected **Minimal setup**.",
|
|
@@ -160,6 +244,12 @@ class FinishStep implements Component {
|
|
|
160
244
|
"You can configure policies later with `/guardrails:settings`.",
|
|
161
245
|
].join("\n");
|
|
162
246
|
|
|
247
|
+
const pathAccessPart = this.state.pathAccessEnabled
|
|
248
|
+
? "\n\n**Path access**: enabled (ask mode). The agent will prompt before accessing files outside the working directory."
|
|
249
|
+
: "\n\n**Path access**: disabled. No path restrictions.";
|
|
250
|
+
|
|
251
|
+
const content = defaultsPart + pathAccessPart;
|
|
252
|
+
|
|
163
253
|
this.recapMarkdown.setText(content);
|
|
164
254
|
return [...this.recapMarkdown.render(Math.max(1, width)), ""];
|
|
165
255
|
}
|
|
@@ -177,6 +267,7 @@ export function createOnboardingWizard(
|
|
|
177
267
|
): Component {
|
|
178
268
|
const state: OnboardingState = {
|
|
179
269
|
applyBuiltinDefaults: null,
|
|
270
|
+
pathAccessEnabled: null,
|
|
180
271
|
};
|
|
181
272
|
|
|
182
273
|
let markWelcomeComplete: (() => void) | null = null;
|
|
@@ -210,6 +301,14 @@ export function createOnboardingWizard(
|
|
|
210
301
|
ctx.goNext();
|
|
211
302
|
}),
|
|
212
303
|
},
|
|
304
|
+
{
|
|
305
|
+
label: "Path access",
|
|
306
|
+
build: (ctx) =>
|
|
307
|
+
new PathAccessStep(theme, state, () => {
|
|
308
|
+
ctx.markComplete();
|
|
309
|
+
ctx.goNext();
|
|
310
|
+
}),
|
|
311
|
+
},
|
|
213
312
|
{
|
|
214
313
|
label: "Recap",
|
|
215
314
|
build: (ctx) =>
|
|
@@ -219,21 +318,32 @@ export function createOnboardingWizard(
|
|
|
219
318
|
finalize({
|
|
220
319
|
completed: true,
|
|
221
320
|
applyBuiltinDefaults: state.applyBuiltinDefaults,
|
|
321
|
+
pathAccessEnabled: state.pathAccessEnabled,
|
|
222
322
|
});
|
|
223
323
|
}),
|
|
224
324
|
},
|
|
225
325
|
],
|
|
226
326
|
onComplete: () => {
|
|
227
327
|
if (state.applyBuiltinDefaults === null) {
|
|
228
|
-
finalize({
|
|
328
|
+
finalize({
|
|
329
|
+
completed: false,
|
|
330
|
+
applyBuiltinDefaults: null,
|
|
331
|
+
pathAccessEnabled: null,
|
|
332
|
+
});
|
|
229
333
|
return;
|
|
230
334
|
}
|
|
231
335
|
finalize({
|
|
232
336
|
completed: true,
|
|
233
337
|
applyBuiltinDefaults: state.applyBuiltinDefaults,
|
|
338
|
+
pathAccessEnabled: state.pathAccessEnabled,
|
|
234
339
|
});
|
|
235
340
|
},
|
|
236
|
-
onCancel: () =>
|
|
341
|
+
onCancel: () =>
|
|
342
|
+
finalize({
|
|
343
|
+
completed: false,
|
|
344
|
+
applyBuiltinDefaults: null,
|
|
345
|
+
pathAccessEnabled: null,
|
|
346
|
+
}),
|
|
237
347
|
hintSuffix: "Enter select/continue",
|
|
238
348
|
minContentHeight: 12,
|
|
239
349
|
});
|
|
@@ -256,8 +366,9 @@ export function createOnboardingWizard(
|
|
|
256
366
|
|
|
257
367
|
export function buildOnboardedConfig(
|
|
258
368
|
applyBuiltinDefaults: boolean,
|
|
369
|
+
pathAccessEnabled?: boolean | null,
|
|
259
370
|
): GuardrailsConfig {
|
|
260
|
-
|
|
371
|
+
const config: GuardrailsConfig = {
|
|
261
372
|
version: CURRENT_VERSION,
|
|
262
373
|
applyBuiltinDefaults,
|
|
263
374
|
onboarding: {
|
|
@@ -266,6 +377,11 @@ export function buildOnboardedConfig(
|
|
|
266
377
|
version: CURRENT_VERSION,
|
|
267
378
|
},
|
|
268
379
|
};
|
|
380
|
+
if (pathAccessEnabled) {
|
|
381
|
+
config.features = { ...config.features, pathAccess: true };
|
|
382
|
+
config.pathAccess = { mode: "ask" };
|
|
383
|
+
}
|
|
384
|
+
return config;
|
|
269
385
|
}
|
|
270
386
|
|
|
271
387
|
export function isOnboardingPending(config: GuardrailsConfig | null): boolean {
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
ResolvedConfig,
|
|
28
28
|
} from "../config";
|
|
29
29
|
import { configLoader } from "../config";
|
|
30
|
+
import { normalizeAllowedPaths } from "../utils/migration";
|
|
30
31
|
|
|
31
32
|
type FeatureKey = keyof ResolvedConfig["features"];
|
|
32
33
|
|
|
@@ -40,6 +41,10 @@ const FEATURE_UI: Record<FeatureKey, { label: string; description: string }> = {
|
|
|
40
41
|
description:
|
|
41
42
|
"Prompt for confirmation on dangerous commands (rm -rf, sudo, etc.)",
|
|
42
43
|
},
|
|
44
|
+
pathAccess: {
|
|
45
|
+
label: "Path access",
|
|
46
|
+
description: "Restrict tool access to the current working directory",
|
|
47
|
+
},
|
|
43
48
|
};
|
|
44
49
|
|
|
45
50
|
const POLICY_EXAMPLES: Array<{
|
|
@@ -687,6 +692,157 @@ class AddRuleSubmenu implements Component {
|
|
|
687
692
|
}
|
|
688
693
|
}
|
|
689
694
|
|
|
695
|
+
class PathListEditor implements Component {
|
|
696
|
+
private readonly input = new Input();
|
|
697
|
+
private items: string[];
|
|
698
|
+
private selectedIndex = 0;
|
|
699
|
+
private mode: "list" | "add" | "edit" = "list";
|
|
700
|
+
private editIndex = -1;
|
|
701
|
+
|
|
702
|
+
constructor(
|
|
703
|
+
private readonly options: {
|
|
704
|
+
label: string;
|
|
705
|
+
items: string[];
|
|
706
|
+
theme: SettingsListTheme;
|
|
707
|
+
onSave: (items: string[]) => void;
|
|
708
|
+
onDone: () => void;
|
|
709
|
+
maxVisible?: number;
|
|
710
|
+
},
|
|
711
|
+
) {
|
|
712
|
+
this.items = [...options.items];
|
|
713
|
+
this.input.onSubmit = () => this.submit();
|
|
714
|
+
this.input.onEscape = () => this.cancel();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
invalidate() {}
|
|
718
|
+
|
|
719
|
+
render(width: number): string[] {
|
|
720
|
+
const lines = [
|
|
721
|
+
this.options.theme.label(` ${this.options.label}`, true),
|
|
722
|
+
"",
|
|
723
|
+
];
|
|
724
|
+
if (this.mode === "add" || this.mode === "edit") {
|
|
725
|
+
lines.push(
|
|
726
|
+
this.options.theme.hint(
|
|
727
|
+
this.mode === "edit" ? " Edit path:" : " New path:",
|
|
728
|
+
),
|
|
729
|
+
"",
|
|
730
|
+
...this.input.render(Math.max(1, width - 4)).map((line) => ` ${line}`),
|
|
731
|
+
"",
|
|
732
|
+
this.options.theme.hint(" Enter: save · Esc: cancel"),
|
|
733
|
+
);
|
|
734
|
+
return lines;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (this.items.length === 0) {
|
|
738
|
+
lines.push(this.options.theme.hint(" (empty)"));
|
|
739
|
+
} else {
|
|
740
|
+
const maxVisible = this.options.maxVisible ?? 10;
|
|
741
|
+
const startIndex = Math.max(
|
|
742
|
+
0,
|
|
743
|
+
Math.min(
|
|
744
|
+
this.selectedIndex - Math.floor(maxVisible / 2),
|
|
745
|
+
this.items.length - maxVisible,
|
|
746
|
+
),
|
|
747
|
+
);
|
|
748
|
+
const endIndex = Math.min(startIndex + maxVisible, this.items.length);
|
|
749
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
750
|
+
const item = this.items[i];
|
|
751
|
+
if (!item) continue;
|
|
752
|
+
const isSelected = i === this.selectedIndex;
|
|
753
|
+
const prefix = isSelected ? this.options.theme.cursor : " ";
|
|
754
|
+
lines.push(prefix + this.options.theme.value(item, isSelected));
|
|
755
|
+
}
|
|
756
|
+
if (startIndex > 0 || endIndex < this.items.length) {
|
|
757
|
+
lines.push(
|
|
758
|
+
this.options.theme.hint(
|
|
759
|
+
` (${this.selectedIndex + 1}/${this.items.length})`,
|
|
760
|
+
),
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
lines.push("");
|
|
766
|
+
lines.push(
|
|
767
|
+
this.options.theme.hint(
|
|
768
|
+
" a: add · e/Enter: edit · d: delete · Esc: back",
|
|
769
|
+
),
|
|
770
|
+
);
|
|
771
|
+
return lines;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
handleInput(data: string): void {
|
|
775
|
+
if (this.mode === "add" || this.mode === "edit") {
|
|
776
|
+
this.input.handleInput(data);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
781
|
+
if (this.items.length === 0) return;
|
|
782
|
+
this.selectedIndex =
|
|
783
|
+
this.selectedIndex === 0
|
|
784
|
+
? this.items.length - 1
|
|
785
|
+
: this.selectedIndex - 1;
|
|
786
|
+
} else if (matchesKey(data, Key.down) || data === "j") {
|
|
787
|
+
if (this.items.length === 0) return;
|
|
788
|
+
this.selectedIndex =
|
|
789
|
+
this.selectedIndex === this.items.length - 1
|
|
790
|
+
? 0
|
|
791
|
+
: this.selectedIndex + 1;
|
|
792
|
+
} else if (data === "a" || data === "A") {
|
|
793
|
+
this.mode = "add";
|
|
794
|
+
this.input.setValue("");
|
|
795
|
+
} else if (data === "e" || data === "E" || matchesKey(data, Key.enter)) {
|
|
796
|
+
this.startEdit();
|
|
797
|
+
} else if (data === "d" || data === "D") {
|
|
798
|
+
this.deleteSelected();
|
|
799
|
+
} else if (matchesKey(data, Key.escape)) {
|
|
800
|
+
this.options.onDone();
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private startEdit(): void {
|
|
805
|
+
const item = this.items[this.selectedIndex];
|
|
806
|
+
if (!item) return;
|
|
807
|
+
this.mode = "edit";
|
|
808
|
+
this.editIndex = this.selectedIndex;
|
|
809
|
+
this.input.setValue(item);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
private submit(): void {
|
|
813
|
+
const path = this.input.getValue().trim();
|
|
814
|
+
if (!path) {
|
|
815
|
+
this.cancel();
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (this.mode === "edit") {
|
|
820
|
+
this.items[this.editIndex] = path;
|
|
821
|
+
} else {
|
|
822
|
+
this.items.push(path);
|
|
823
|
+
this.selectedIndex = this.items.length - 1;
|
|
824
|
+
}
|
|
825
|
+
this.items = [...new Set(this.items)];
|
|
826
|
+
this.options.onSave([...this.items]);
|
|
827
|
+
this.cancel();
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private deleteSelected(): void {
|
|
831
|
+
if (this.items.length === 0) return;
|
|
832
|
+
this.items.splice(this.selectedIndex, 1);
|
|
833
|
+
if (this.selectedIndex >= this.items.length) {
|
|
834
|
+
this.selectedIndex = Math.max(0, this.items.length - 1);
|
|
835
|
+
}
|
|
836
|
+
this.options.onSave([...this.items]);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
private cancel(): void {
|
|
840
|
+
this.mode = "list";
|
|
841
|
+
this.editIndex = -1;
|
|
842
|
+
this.input.setValue("");
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
690
846
|
class ScopePickerSubmenu implements Component {
|
|
691
847
|
private selectedIndex = 0;
|
|
692
848
|
|
|
@@ -1063,6 +1219,23 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
|
|
|
1063
1219
|
};
|
|
1064
1220
|
}
|
|
1065
1221
|
|
|
1222
|
+
function pathListSubmenu(id: string, label: string) {
|
|
1223
|
+
return (_val: string, submenuDone: (v?: string) => void) => {
|
|
1224
|
+
const items = normalizeAllowedPaths(getNestedValue(scopedConfig, id));
|
|
1225
|
+
let latestCount = items.length;
|
|
1226
|
+
return new PathListEditor({
|
|
1227
|
+
label,
|
|
1228
|
+
items,
|
|
1229
|
+
theme: settingsTheme,
|
|
1230
|
+
onSave: (newItems) => {
|
|
1231
|
+
latestCount = newItems.length;
|
|
1232
|
+
applyDraft(id, newItems);
|
|
1233
|
+
},
|
|
1234
|
+
onDone: () => submenuDone(`${latestCount} items`),
|
|
1235
|
+
});
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1066
1239
|
function patternConfigSubmenu(
|
|
1067
1240
|
id: string,
|
|
1068
1241
|
label: string,
|
|
@@ -1210,6 +1383,30 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
|
|
|
1210
1383
|
label: `Policies (${policyRules.length})`,
|
|
1211
1384
|
items: policyItems,
|
|
1212
1385
|
},
|
|
1386
|
+
{
|
|
1387
|
+
label: "Path Access",
|
|
1388
|
+
items: [
|
|
1389
|
+
{
|
|
1390
|
+
id: "pathAccess.mode",
|
|
1391
|
+
label: "Mode",
|
|
1392
|
+
description:
|
|
1393
|
+
"allow: no restrictions, ask: prompt for outside paths, block: deny all outside paths",
|
|
1394
|
+
currentValue: scopedConfig.pathAccess?.mode ?? "(inherited)",
|
|
1395
|
+
values: ["allow", "ask", "block"],
|
|
1396
|
+
},
|
|
1397
|
+
{
|
|
1398
|
+
id: "pathAccess.allowedPaths",
|
|
1399
|
+
label: "Allowed paths",
|
|
1400
|
+
description:
|
|
1401
|
+
"Paths always allowed (trailing / for directories). Supports ~/",
|
|
1402
|
+
currentValue: count("pathAccess.allowedPaths"),
|
|
1403
|
+
submenu: pathListSubmenu(
|
|
1404
|
+
"pathAccess.allowedPaths",
|
|
1405
|
+
"Allowed Paths",
|
|
1406
|
+
),
|
|
1407
|
+
},
|
|
1408
|
+
],
|
|
1409
|
+
},
|
|
1213
1410
|
{
|
|
1214
1411
|
label: "Permission Gate",
|
|
1215
1412
|
items: [
|