@aliou/pi-guardrails 0.13.3 → 0.14.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 +2 -0
- package/extensions/guardrails/commands/onboarding/config.ts +5 -1
- package/extensions/guardrails/commands/settings/index.ts +9 -2
- package/extensions/guardrails/commands/settings/path-list-editor.ts +55 -9
- package/extensions/guardrails/index.ts +1 -2
- package/extensions/path-access/dynamic-resources.ts +37 -0
- package/extensions/path-access/grants.ts +32 -19
- package/extensions/path-access/index.ts +21 -0
- package/package.json +4 -4
- package/schema.json +41 -1
- package/src/core/paths/access.ts +9 -9
- package/src/core/paths/index.ts +2 -1
- package/src/core/paths/path.ts +34 -5
- package/src/shared/config/defaults.ts +1 -1
- package/src/shared/config/index.ts +1 -0
- package/src/shared/config/loader.ts +9 -5
- package/src/shared/config/migration/001-v0-format-upgrade.ts +1 -2
- package/src/shared/config/migration/002-strip-toolchain-fields.ts +0 -7
- package/src/shared/config/migration/003-strip-command-explainer-fields.ts +0 -6
- package/src/shared/config/migration/004-env-files-to-policies.ts +19 -6
- package/src/shared/config/migration/005-normalize-allowed-paths.ts +10 -6
- package/src/shared/config/migration/006-apply-builtin-defaults.ts +0 -5
- package/src/shared/config/migration/007-mark-onboarding-done.ts +0 -4
- package/src/shared/config/migration/008-normalize-string-booleans.ts +0 -4
- package/src/shared/config/migration/009-allow-dev-null.ts +43 -0
- package/src/shared/config/migration/010-allowed-paths-objects.ts +65 -0
- package/src/shared/config/migration/index.ts +29 -1
- package/src/shared/config/migration/version.ts +1 -1
- package/src/shared/config/types.ts +15 -2
- package/src/shared/index.ts +0 -1
- package/src/shared/matching.ts +8 -7
- package/src/shared/warnings.ts +0 -17
package/README.md
CHANGED
|
@@ -56,6 +56,8 @@ The `path-access` extension checks tool calls that target paths outside the curr
|
|
|
56
56
|
|
|
57
57
|
It can allow, block, or ask before Pi accesses files elsewhere on your machine. In ask mode, you can allow one file or a directory once, for the session, or always.
|
|
58
58
|
|
|
59
|
+
Granted paths are stored in `pathAccess.allowedPaths` as explicit `{ kind, path }` entries: `file` matches the exact path, `directory` matches the directory and its descendants. Edit them through `/guardrails:settings` (Path Access → Allowed paths, Tab toggles file/directory) or directly in the settings file. Paths support `~/` for home. Existing configs using the legacy string form (trailing `/` for directories) are migrated automatically.
|
|
60
|
+
|
|
59
61
|
[](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/path-access.mp4)
|
|
60
62
|
|
|
61
63
|
### permission-gate
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
CURRENT_VERSION,
|
|
3
3
|
type GuardrailsConfig,
|
|
4
4
|
} from "../../../../src/shared/config";
|
|
5
|
+
import { DEFAULT_CONFIG } from "../../../../src/shared/config/defaults";
|
|
5
6
|
|
|
6
7
|
export function buildOnboardedConfig(
|
|
7
8
|
applyBuiltinDefaults: boolean,
|
|
@@ -18,7 +19,10 @@ export function buildOnboardedConfig(
|
|
|
18
19
|
};
|
|
19
20
|
if (pathAccessEnabled) {
|
|
20
21
|
config.features = { ...config.features, pathAccess: true };
|
|
21
|
-
config.pathAccess = {
|
|
22
|
+
config.pathAccess = {
|
|
23
|
+
mode: "ask",
|
|
24
|
+
allowedPaths: [...DEFAULT_CONFIG.pathAccess.allowedPaths],
|
|
25
|
+
};
|
|
22
26
|
}
|
|
23
27
|
return config;
|
|
24
28
|
}
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
SettingsListTheme,
|
|
15
15
|
} from "@earendil-works/pi-tui";
|
|
16
16
|
import type {
|
|
17
|
+
AllowedPath,
|
|
17
18
|
DangerousPattern,
|
|
18
19
|
GuardrailsConfig,
|
|
19
20
|
PatternConfig,
|
|
@@ -355,7 +356,13 @@ export function registerGuardrailsSettings(
|
|
|
355
356
|
return (_val: string, submenuDone: (v?: string) => void) => {
|
|
356
357
|
const value = getNestedValue(scopedConfig, id);
|
|
357
358
|
const items = Array.isArray(value)
|
|
358
|
-
? value.filter(
|
|
359
|
+
? value.filter(
|
|
360
|
+
(entry): entry is AllowedPath =>
|
|
361
|
+
typeof entry === "object" &&
|
|
362
|
+
entry !== null &&
|
|
363
|
+
(entry.kind === "file" || entry.kind === "directory") &&
|
|
364
|
+
typeof entry.path === "string",
|
|
365
|
+
)
|
|
359
366
|
: [];
|
|
360
367
|
let latestCount = items.length;
|
|
361
368
|
return new PathListEditor({
|
|
@@ -531,7 +538,7 @@ export function registerGuardrailsSettings(
|
|
|
531
538
|
id: "pathAccess.allowedPaths",
|
|
532
539
|
label: "Allowed paths",
|
|
533
540
|
description:
|
|
534
|
-
"Paths always allowed
|
|
541
|
+
"Paths always allowed outside cwd. Each entry is { kind, path }: file matches exactly, directory matches the tree. Supports ~/",
|
|
535
542
|
currentValue: count("pathAccess.allowedPaths"),
|
|
536
543
|
submenu: pathListSubmenu(
|
|
537
544
|
"pathAccess.allowedPaths",
|
|
@@ -5,25 +5,45 @@ import {
|
|
|
5
5
|
matchesKey,
|
|
6
6
|
type SettingsListTheme,
|
|
7
7
|
} from "@earendil-works/pi-tui";
|
|
8
|
+
import type { AllowedPath } from "../../../../src/core/paths";
|
|
9
|
+
|
|
10
|
+
type PathKind = "file" | "directory";
|
|
11
|
+
|
|
12
|
+
function kindLabel(kind: PathKind): string {
|
|
13
|
+
return kind === "directory" ? "dir" : "file";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isAllowedPath(value: unknown): value is AllowedPath {
|
|
17
|
+
if (!value || typeof value !== "object") return false;
|
|
18
|
+
const entry = value as { kind?: unknown; path?: unknown };
|
|
19
|
+
return (
|
|
20
|
+
(entry.kind === "file" || entry.kind === "directory") &&
|
|
21
|
+
typeof entry.path === "string"
|
|
22
|
+
);
|
|
23
|
+
}
|
|
8
24
|
|
|
9
25
|
export class PathListEditor implements Component {
|
|
10
26
|
private readonly input = new Input();
|
|
11
|
-
private items:
|
|
27
|
+
private items: AllowedPath[];
|
|
12
28
|
private selectedIndex = 0;
|
|
13
29
|
private mode: "list" | "add" | "edit" = "list";
|
|
14
30
|
private editIndex = -1;
|
|
31
|
+
private draftKind: PathKind = "file";
|
|
15
32
|
|
|
16
33
|
constructor(
|
|
17
34
|
private readonly options: {
|
|
18
35
|
label: string;
|
|
19
|
-
items:
|
|
36
|
+
items: AllowedPath[];
|
|
20
37
|
theme: SettingsListTheme;
|
|
21
|
-
onSave: (items:
|
|
38
|
+
onSave: (items: AllowedPath[]) => void;
|
|
22
39
|
onDone: () => void;
|
|
23
40
|
maxVisible?: number;
|
|
24
41
|
},
|
|
25
42
|
) {
|
|
26
|
-
this.items =
|
|
43
|
+
this.items = options.items.filter(isAllowedPath).map((item) => ({
|
|
44
|
+
kind: item.kind,
|
|
45
|
+
path: item.path,
|
|
46
|
+
}));
|
|
27
47
|
this.input.onSubmit = () => this.submit();
|
|
28
48
|
this.input.onEscape = () => this.cancel();
|
|
29
49
|
}
|
|
@@ -40,6 +60,9 @@ export class PathListEditor implements Component {
|
|
|
40
60
|
this.options.theme.hint(
|
|
41
61
|
this.mode === "edit" ? " Edit path:" : " New path:",
|
|
42
62
|
),
|
|
63
|
+
this.options.theme.hint(
|
|
64
|
+
` kind: ${kindLabel(this.draftKind)} (Tab to toggle file/dir)`,
|
|
65
|
+
),
|
|
43
66
|
"",
|
|
44
67
|
...this.input.render(Math.max(1, width - 4)).map((line) => ` ${line}`),
|
|
45
68
|
"",
|
|
@@ -65,7 +88,9 @@ export class PathListEditor implements Component {
|
|
|
65
88
|
if (!item) continue;
|
|
66
89
|
const isSelected = i === this.selectedIndex;
|
|
67
90
|
const prefix = isSelected ? this.options.theme.cursor : " ";
|
|
68
|
-
|
|
91
|
+
const kind = this.options.theme.hint(`[${kindLabel(item.kind)}]`);
|
|
92
|
+
const value = this.options.theme.value(item.path, isSelected);
|
|
93
|
+
lines.push(`${prefix}${kind} ${value}`);
|
|
69
94
|
}
|
|
70
95
|
if (startIndex > 0 || endIndex < this.items.length) {
|
|
71
96
|
lines.push(
|
|
@@ -87,6 +112,10 @@ export class PathListEditor implements Component {
|
|
|
87
112
|
|
|
88
113
|
handleInput(data: string): void {
|
|
89
114
|
if (this.mode === "add" || this.mode === "edit") {
|
|
115
|
+
if (matchesKey(data, Key.tab)) {
|
|
116
|
+
this.draftKind = this.draftKind === "file" ? "directory" : "file";
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
90
119
|
this.input.handleInput(data);
|
|
91
120
|
return;
|
|
92
121
|
}
|
|
@@ -105,6 +134,7 @@ export class PathListEditor implements Component {
|
|
|
105
134
|
: this.selectedIndex + 1;
|
|
106
135
|
} else if (data === "a" || data === "A") {
|
|
107
136
|
this.mode = "add";
|
|
137
|
+
this.draftKind = "file";
|
|
108
138
|
this.input.setValue("");
|
|
109
139
|
} else if (data === "e" || data === "E" || matchesKey(data, Key.enter)) {
|
|
110
140
|
this.startEdit();
|
|
@@ -120,7 +150,8 @@ export class PathListEditor implements Component {
|
|
|
120
150
|
if (!item) return;
|
|
121
151
|
this.mode = "edit";
|
|
122
152
|
this.editIndex = this.selectedIndex;
|
|
123
|
-
this.
|
|
153
|
+
this.draftKind = item.kind;
|
|
154
|
+
this.input.setValue(item.path);
|
|
124
155
|
}
|
|
125
156
|
|
|
126
157
|
private submit(): void {
|
|
@@ -130,13 +161,15 @@ export class PathListEditor implements Component {
|
|
|
130
161
|
return;
|
|
131
162
|
}
|
|
132
163
|
|
|
164
|
+
const entry: AllowedPath = { kind: this.draftKind, path };
|
|
165
|
+
|
|
133
166
|
if (this.mode === "edit") {
|
|
134
|
-
this.items[this.editIndex] =
|
|
167
|
+
this.items[this.editIndex] = entry;
|
|
135
168
|
} else {
|
|
136
|
-
this.items.push(
|
|
169
|
+
this.items.push(entry);
|
|
137
170
|
this.selectedIndex = this.items.length - 1;
|
|
138
171
|
}
|
|
139
|
-
this.items =
|
|
172
|
+
this.items = dedupe(this.items);
|
|
140
173
|
this.options.onSave([...this.items]);
|
|
141
174
|
this.cancel();
|
|
142
175
|
}
|
|
@@ -153,6 +186,19 @@ export class PathListEditor implements Component {
|
|
|
153
186
|
private cancel(): void {
|
|
154
187
|
this.mode = "list";
|
|
155
188
|
this.editIndex = -1;
|
|
189
|
+
this.draftKind = "file";
|
|
156
190
|
this.input.setValue("");
|
|
157
191
|
}
|
|
158
192
|
}
|
|
193
|
+
|
|
194
|
+
function dedupe(items: AllowedPath[]): AllowedPath[] {
|
|
195
|
+
const seen = new Set<string>();
|
|
196
|
+
const result: AllowedPath[] = [];
|
|
197
|
+
for (const item of items) {
|
|
198
|
+
const key = `${item.kind}:${item.path}`;
|
|
199
|
+
if (seen.has(key)) continue;
|
|
200
|
+
seen.add(key);
|
|
201
|
+
result.push(item);
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
type GuardrailsFeatureId,
|
|
10
10
|
type GuardrailsFeatureRegisterPayload,
|
|
11
11
|
} from "../../src/shared/events";
|
|
12
|
-
import { drainPendingWarnings } from "../../src/shared/warnings";
|
|
13
12
|
import { registerGuardrailsExamplesCommand } from "./commands/examples";
|
|
14
13
|
import { registerGuardrailsOnboardingCommand } from "./commands/onboarding";
|
|
15
14
|
import { isOnboardingPending } from "./commands/onboarding/config";
|
|
@@ -90,7 +89,7 @@ export default async function guardrails(pi: ExtensionAPI) {
|
|
|
90
89
|
createFeatureRequestPayload(),
|
|
91
90
|
);
|
|
92
91
|
|
|
93
|
-
const warnings =
|
|
92
|
+
const warnings = configLoader.drainMessages();
|
|
94
93
|
if (warnings.length === 1) {
|
|
95
94
|
ctx.ui.notify(warnings[0], "warning");
|
|
96
95
|
} else if (warnings.length > 1) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve Pi documentation paths dynamically from the running Pi runtime.
|
|
3
|
+
*
|
|
4
|
+
* Pi bakes its docs/examples paths into the system prompt text at launch time,
|
|
5
|
+
* but those paths change between Pi versions (e.g. Nix store hashes), so we
|
|
6
|
+
* resolve them directly from Pi's package asset path helpers instead of
|
|
7
|
+
* scraping the system prompt or persisting concrete paths in config.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
getDocsPath,
|
|
11
|
+
getExamplesPath,
|
|
12
|
+
getReadmePath,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import type { AllowedPath } from "../../src/core/paths";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve Pi documentation paths dynamically from the running Pi runtime.
|
|
18
|
+
*
|
|
19
|
+
* Pi bakes its docs/examples paths into the system prompt text at launch time,
|
|
20
|
+
* but those paths change between Pi versions (e.g. Nix store hashes), so we
|
|
21
|
+
* resolve them directly from Pi's package asset path helpers instead of
|
|
22
|
+
* scraping the system prompt or persisting concrete paths in config.
|
|
23
|
+
*
|
|
24
|
+
* These helpers honor `PI_PACKAGE_DIR` (Nix/Guix) and walk to the package
|
|
25
|
+
* root for npm global installs and Bun binaries, so they always match the
|
|
26
|
+
* Pi version actually running. They depend only on the process environment
|
|
27
|
+
* and are fixed for the process lifetime — resolve once, no per-turn work.
|
|
28
|
+
*
|
|
29
|
+
* The README is a single file grant; docs and examples are directory grants.
|
|
30
|
+
*/
|
|
31
|
+
export function piDocumentationPaths(): AllowedPath[] {
|
|
32
|
+
return [
|
|
33
|
+
{ kind: "file", path: getReadmePath() },
|
|
34
|
+
{ kind: "directory", path: getDocsPath() },
|
|
35
|
+
{ kind: "directory", path: getExamplesPath() },
|
|
36
|
+
];
|
|
37
|
+
}
|
|
@@ -1,30 +1,33 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type AllowedPath,
|
|
4
|
+
resolveFromCwd,
|
|
5
|
+
toStorageGrant,
|
|
6
|
+
} from "../../src/core/paths";
|
|
3
7
|
import { configLoader } from "../../src/shared/config";
|
|
4
8
|
|
|
5
9
|
export type PendingPathGrant = {
|
|
6
|
-
|
|
10
|
+
kind: "file" | "directory";
|
|
11
|
+
storageGrant: AllowedPath;
|
|
7
12
|
scope: "memory" | "local";
|
|
8
13
|
absolutePath: string;
|
|
9
14
|
};
|
|
10
15
|
|
|
11
16
|
export function resolveAllowedPaths(
|
|
12
|
-
allowedPaths:
|
|
17
|
+
allowedPaths: AllowedPath[],
|
|
13
18
|
cwd: string,
|
|
14
|
-
):
|
|
15
|
-
return allowedPaths.map((
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
});
|
|
19
|
+
): AllowedPath[] {
|
|
20
|
+
return allowedPaths.map((entry) => ({
|
|
21
|
+
kind: entry.kind,
|
|
22
|
+
path: resolveFromCwd(entry.path, cwd),
|
|
23
|
+
}));
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
export function pendingAllowedPaths(grants: PendingPathGrant[]):
|
|
23
|
-
return grants.map((grant) =>
|
|
24
|
-
grant.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
);
|
|
26
|
+
export function pendingAllowedPaths(grants: PendingPathGrant[]): AllowedPath[] {
|
|
27
|
+
return grants.map((grant) => ({
|
|
28
|
+
kind: grant.kind,
|
|
29
|
+
path: grant.absolutePath,
|
|
30
|
+
}));
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
export function isGrantTooBroad(absPath: string): boolean {
|
|
@@ -38,9 +41,10 @@ export function createPendingGrant(
|
|
|
38
41
|
scope: "memory" | "local",
|
|
39
42
|
): PendingPathGrant {
|
|
40
43
|
return {
|
|
44
|
+
kind: isDirectory ? "directory" : "file",
|
|
41
45
|
absolutePath,
|
|
42
46
|
scope,
|
|
43
|
-
|
|
47
|
+
storageGrant: toStorageGrant(absolutePath, isDirectory),
|
|
44
48
|
};
|
|
45
49
|
}
|
|
46
50
|
|
|
@@ -52,17 +56,26 @@ export async function persistGrant(grant: PendingPathGrant): Promise<void> {
|
|
|
52
56
|
const pathAccess = (raw.pathAccess ?? {}) as Record<string, unknown>;
|
|
53
57
|
const existing = Array.isArray(pathAccess.allowedPaths)
|
|
54
58
|
? pathAccess.allowedPaths.filter(
|
|
55
|
-
(
|
|
59
|
+
(entry): entry is AllowedPath =>
|
|
60
|
+
typeof entry === "object" &&
|
|
61
|
+
entry !== null &&
|
|
62
|
+
(entry.kind === "file" || entry.kind === "directory") &&
|
|
63
|
+
typeof (entry as { path?: unknown }).path === "string",
|
|
56
64
|
)
|
|
57
65
|
: [];
|
|
58
66
|
|
|
59
|
-
|
|
67
|
+
const alreadyPresent = existing.some(
|
|
68
|
+
(entry) =>
|
|
69
|
+
entry.kind === grant.storageGrant.kind &&
|
|
70
|
+
entry.path === grant.storageGrant.path,
|
|
71
|
+
);
|
|
72
|
+
if (alreadyPresent) return;
|
|
60
73
|
|
|
61
74
|
await configLoader.save(grant.scope, {
|
|
62
75
|
...raw,
|
|
63
76
|
pathAccess: {
|
|
64
77
|
...pathAccess,
|
|
65
|
-
allowedPaths: [...existing, grant.
|
|
78
|
+
allowedPaths: [...existing, grant.storageGrant],
|
|
66
79
|
},
|
|
67
80
|
});
|
|
68
81
|
}
|
|
@@ -2,6 +2,7 @@ import { dirname } from "node:path";
|
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { checkAction } from "../../src/core";
|
|
4
4
|
import {
|
|
5
|
+
type AllowedPath,
|
|
5
6
|
normalizeForDisplay,
|
|
6
7
|
type PathAccessState,
|
|
7
8
|
} from "../../src/core/paths";
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
GUARDRAILS_FEATURE_REGISTER_EVENT,
|
|
14
15
|
GUARDRAILS_FEATURE_REQUEST_EVENT,
|
|
15
16
|
} from "../../src/shared/events";
|
|
17
|
+
import { piDocumentationPaths } from "./dynamic-resources";
|
|
16
18
|
import {
|
|
17
19
|
createPendingGrant,
|
|
18
20
|
isGrantTooBroad,
|
|
@@ -28,6 +30,23 @@ import { targetsForTool } from "./targets";
|
|
|
28
30
|
export default async function pathAccess(pi: ExtensionAPI) {
|
|
29
31
|
await configLoader.load();
|
|
30
32
|
|
|
33
|
+
// Pi docs paths depend only on `PI_PACKAGE_DIR` / the package root and are
|
|
34
|
+
// fixed for the process lifetime, so resolve once at setup.
|
|
35
|
+
const piDocsPaths = piDocumentationPaths();
|
|
36
|
+
|
|
37
|
+
let currentSkillAllowedPaths: AllowedPath[] = [];
|
|
38
|
+
|
|
39
|
+
pi.on("before_agent_start", (event) => {
|
|
40
|
+
const skills = event.systemPromptOptions.skills;
|
|
41
|
+
|
|
42
|
+
if (!skills || skills.length === 0) return;
|
|
43
|
+
|
|
44
|
+
currentSkillAllowedPaths = skills.flatMap((skill) => [
|
|
45
|
+
{ kind: "file", path: skill.filePath },
|
|
46
|
+
{ kind: "directory", path: skill.baseDir },
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
31
50
|
pi.events.on(GUARDRAILS_FEATURE_REQUEST_EVENT, () => {
|
|
32
51
|
pi.events.emit(
|
|
33
52
|
GUARDRAILS_FEATURE_REGISTER_EVENT,
|
|
@@ -62,6 +81,8 @@ export default async function pathAccess(pi: ExtensionAPI) {
|
|
|
62
81
|
mode: config.pathAccess.mode,
|
|
63
82
|
allowedPaths: [
|
|
64
83
|
...resolveAllowedPaths(config.pathAccess.allowedPaths, ctx.cwd),
|
|
84
|
+
...piDocsPaths,
|
|
85
|
+
...currentSkillAllowedPaths,
|
|
65
86
|
...pendingAllowedPaths(acceptedGrants),
|
|
66
87
|
],
|
|
67
88
|
hasUI: ctx.hasUI,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliou/pi-guardrails",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"!extensions/**/*.test.ts"
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@aliou/pi-utils-settings": "^0.
|
|
38
|
+
"@aliou/pi-utils-settings": "^0.17.0",
|
|
39
39
|
"@aliou/sh": "^0.1.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"@aliou/biome-plugins": "^0.8.1",
|
|
47
47
|
"@biomejs/biome": "^2.4.15",
|
|
48
48
|
"@changesets/cli": "^2.27.11",
|
|
49
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
50
|
-
"@earendil-works/pi-tui": "0.
|
|
49
|
+
"@earendil-works/pi-coding-agent": "0.79.6",
|
|
50
|
+
"@earendil-works/pi-tui": "0.79.6",
|
|
51
51
|
"@sinclair/typebox": "^0.34.48",
|
|
52
52
|
"@types/node": "^25.0.10",
|
|
53
53
|
"husky": "^9.1.7",
|
package/schema.json
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
2
2
|
"$ref": "#/definitions/GuardrailsConfig",
|
|
3
3
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
4
|
"definitions": {
|
|
5
|
+
"AllowedPath": {
|
|
6
|
+
"anyOf": [
|
|
7
|
+
{
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"kind": {
|
|
11
|
+
"const": "file",
|
|
12
|
+
"type": "string"
|
|
13
|
+
},
|
|
14
|
+
"path": {
|
|
15
|
+
"type": "string"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"required": [
|
|
19
|
+
"kind",
|
|
20
|
+
"path"
|
|
21
|
+
],
|
|
22
|
+
"type": "object"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"additionalProperties": false,
|
|
26
|
+
"properties": {
|
|
27
|
+
"kind": {
|
|
28
|
+
"const": "directory",
|
|
29
|
+
"type": "string"
|
|
30
|
+
},
|
|
31
|
+
"path": {
|
|
32
|
+
"type": "string"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"required": [
|
|
36
|
+
"kind",
|
|
37
|
+
"path"
|
|
38
|
+
],
|
|
39
|
+
"type": "object"
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
"description": "A path grant with an explicit kind.\n\n`file` matches the exact path only. `directory` matches the directory itself and any descendant (boundary/prefix match).\n\nThis replaces the previous trailing-slash convention on a flat `string[]`, where the kind was inferred from whether a path ended in `/`."
|
|
43
|
+
},
|
|
5
44
|
"DangerousPattern": {
|
|
6
45
|
"additionalProperties": false,
|
|
7
46
|
"description": "Permission gate pattern. When regex is false (default), the pattern is matched as substring against the raw command string. When regex is true, uses full regex against the raw string.",
|
|
@@ -180,8 +219,9 @@
|
|
|
180
219
|
"additionalProperties": false,
|
|
181
220
|
"properties": {
|
|
182
221
|
"allowedPaths": {
|
|
222
|
+
"description": "Paths always allowed, regardless of cwd. Each entry carries an explicit `kind`: `file` matches the exact path, `directory` matches the directory and its descendants.",
|
|
183
223
|
"items": {
|
|
184
|
-
"
|
|
224
|
+
"$ref": "#/definitions/AllowedPath"
|
|
185
225
|
},
|
|
186
226
|
"type": "array"
|
|
187
227
|
},
|
package/src/core/paths/access.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isWithinBoundary } from "./path";
|
|
1
|
+
import { type AllowedPath, isWithinBoundary } from "./path";
|
|
2
2
|
|
|
3
3
|
export type PathDecision =
|
|
4
4
|
| { kind: "allow" }
|
|
@@ -8,25 +8,25 @@ export type PathDecision =
|
|
|
8
8
|
export interface PathAccessState {
|
|
9
9
|
cwd: string;
|
|
10
10
|
mode: "allow" | "ask" | "block";
|
|
11
|
-
allowedPaths:
|
|
11
|
+
allowedPaths: AllowedPath[]; // already resolved to absolute
|
|
12
12
|
hasUI: boolean;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Check if an absolute path is covered by the allowedPaths list.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
*
|
|
18
|
+
* `directory` grants match the directory itself and any descendant (boundary
|
|
19
|
+
* match). `file` grants match the exact path only.
|
|
19
20
|
*/
|
|
20
21
|
export function isPathAllowed(
|
|
21
22
|
absPath: string,
|
|
22
|
-
allowedPaths:
|
|
23
|
+
allowedPaths: AllowedPath[],
|
|
23
24
|
): boolean {
|
|
24
25
|
for (const entry of allowedPaths) {
|
|
25
|
-
if (entry.
|
|
26
|
-
|
|
27
|
-
if (isWithinBoundary(absPath, dirPath)) return true;
|
|
26
|
+
if (entry.kind === "directory") {
|
|
27
|
+
if (isWithinBoundary(absPath, entry.path)) return true;
|
|
28
28
|
} else {
|
|
29
|
-
if (absPath === entry) return true;
|
|
29
|
+
if (absPath === entry.path) return true;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
return false;
|
package/src/core/paths/index.ts
CHANGED
package/src/core/paths/path.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* A path grant with an explicit kind.
|
|
6
|
+
*
|
|
7
|
+
* `file` matches the exact path only. `directory` matches the directory itself
|
|
8
|
+
* and any descendant (boundary/prefix match).
|
|
9
|
+
*
|
|
10
|
+
* This replaces the previous trailing-slash convention on a flat `string[]`,
|
|
11
|
+
* where the kind was inferred from whether a path ended in `/`.
|
|
12
|
+
*/
|
|
13
|
+
export type AllowedPath =
|
|
14
|
+
| { kind: "file"; path: string }
|
|
15
|
+
| { kind: "directory"; path: string };
|
|
16
|
+
|
|
4
17
|
/**
|
|
5
18
|
* Expand a leading tilde to the current user's home directory.
|
|
6
19
|
* Preserves all other paths unchanged.
|
|
@@ -52,9 +65,27 @@ export function normalizeForDisplay(absPath: string, cwd: string): string {
|
|
|
52
65
|
|
|
53
66
|
/**
|
|
54
67
|
* Convert an absolute path to storage form for config persistence.
|
|
55
|
-
*
|
|
68
|
+
*
|
|
69
|
+
* Uses `~/` for home paths, absolute otherwise, and normalizes separators to
|
|
70
|
+
* forward slash. The kind (file vs directory) is carried explicitly on the
|
|
71
|
+
* returned `AllowedPath` instead of via a trailing slash.
|
|
72
|
+
*/
|
|
73
|
+
export function toStorageGrant(
|
|
74
|
+
absPath: string,
|
|
75
|
+
isDirectory: boolean,
|
|
76
|
+
): AllowedPath {
|
|
77
|
+
return {
|
|
78
|
+
kind: isDirectory ? "directory" : "file",
|
|
79
|
+
path: toStoragePath(absPath),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Normalize an absolute path for storage: expand home to `~/`, collapse
|
|
85
|
+
* backslashes to forward slashes, and strip any trailing slash (the kind now
|
|
86
|
+
* lives on `AllowedPath`, not on the path string).
|
|
56
87
|
*/
|
|
57
|
-
|
|
88
|
+
function toStoragePath(absPath: string): string {
|
|
58
89
|
const home = homedir();
|
|
59
90
|
let stored: string;
|
|
60
91
|
if (
|
|
@@ -66,10 +97,8 @@ export function toStorageForm(absPath: string, isDirectory: boolean): string {
|
|
|
66
97
|
} else {
|
|
67
98
|
stored = absPath;
|
|
68
99
|
}
|
|
69
|
-
// Normalize separators to forward slash for storage
|
|
70
100
|
stored = stored.replace(/\\/g, "/");
|
|
71
|
-
|
|
72
|
-
if (!isDirectory && stored.endsWith("/")) stored = stored.slice(0, -1);
|
|
101
|
+
stored = stored.replace(/\/+$/, "");
|
|
73
102
|
return stored;
|
|
74
103
|
}
|
|
75
104
|
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type Scope,
|
|
5
5
|
} from "@aliou/pi-utils-settings";
|
|
6
6
|
import pkg from "../../../package.json" with { type: "json" };
|
|
7
|
+
import type { AllowedPath } from "../../core/paths/path";
|
|
7
8
|
import { DEFAULT_CONFIG } from "./defaults";
|
|
8
9
|
import { migrations } from "./migration";
|
|
9
10
|
import type { GuardrailsConfig, PolicyRule, ResolvedConfig } from "./types";
|
|
@@ -63,18 +64,21 @@ export function createGuardrailsConfigLoader(): GuardrailsConfigLoader {
|
|
|
63
64
|
resolved.permissionGate.useBuiltinMatchers = false;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
const mergedPaths = new
|
|
67
|
+
const mergedPaths = new Map<string, AllowedPath>();
|
|
67
68
|
for (const paths of [
|
|
68
69
|
global?.pathAccess?.allowedPaths,
|
|
69
70
|
local?.pathAccess?.allowedPaths,
|
|
70
71
|
memory?.pathAccess?.allowedPaths,
|
|
71
72
|
]) {
|
|
72
|
-
for (const
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
for (const entry of paths ?? []) {
|
|
74
|
+
if (!entry || typeof entry !== "object") continue;
|
|
75
|
+
const path = typeof entry.path === "string" ? entry.path.trim() : "";
|
|
76
|
+
if (!path) continue;
|
|
77
|
+
const kind = entry.kind === "directory" ? "directory" : "file";
|
|
78
|
+
mergedPaths.set(`${kind}:${path}`, { kind, path });
|
|
75
79
|
}
|
|
76
80
|
}
|
|
77
|
-
resolved.pathAccess.allowedPaths = [...mergedPaths];
|
|
81
|
+
resolved.pathAccess.allowedPaths = [...mergedPaths.values()];
|
|
78
82
|
|
|
79
83
|
return resolved;
|
|
80
84
|
},
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { copyFile, stat } from "node:fs/promises";
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
|
-
import { addPendingWarning } from "../../warnings";
|
|
4
3
|
import type {
|
|
5
4
|
DangerousPattern,
|
|
6
5
|
GuardrailsConfig,
|
|
@@ -101,7 +100,7 @@ async function backupConfig(configPath: string): Promise<void> {
|
|
|
101
100
|
try {
|
|
102
101
|
await copyFile(configPath, backupPath);
|
|
103
102
|
} catch (err) {
|
|
104
|
-
|
|
103
|
+
console.error(`[guardrails] could not back up config: ${err}`);
|
|
105
104
|
}
|
|
106
105
|
}
|
|
107
106
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { addPendingWarning } from "../../warnings";
|
|
2
1
|
import type { GuardrailsConfig } from "../types";
|
|
3
2
|
import { CURRENT_VERSION } from "./version";
|
|
4
3
|
|
|
@@ -20,12 +19,6 @@ export function shouldRun(config: GuardrailsConfig): boolean {
|
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
export function run(config: GuardrailsConfig): GuardrailsConfig {
|
|
23
|
-
addPendingWarning(
|
|
24
|
-
"[guardrails] preventBrew, preventPython, enforcePackageManager, and packageManager " +
|
|
25
|
-
"have been removed from guardrails and moved to @aliou/pi-toolchain. " +
|
|
26
|
-
"These fields will be stripped from your config.",
|
|
27
|
-
);
|
|
28
|
-
|
|
29
22
|
const cleaned = structuredClone(config) as Record<string, unknown>;
|
|
30
23
|
const features = cleaned.features as Record<string, unknown> | undefined;
|
|
31
24
|
if (features) {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { addPendingWarning } from "../../warnings";
|
|
2
1
|
import type { GuardrailsConfig } from "../types";
|
|
3
2
|
import { CURRENT_VERSION } from "./version";
|
|
4
3
|
|
|
@@ -23,11 +22,6 @@ export function shouldRun(config: GuardrailsConfig): boolean {
|
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
export function run(config: GuardrailsConfig): GuardrailsConfig {
|
|
26
|
-
addPendingWarning(
|
|
27
|
-
"[guardrails] permissionGate.explainCommands, explainModel, and explainTimeout " +
|
|
28
|
-
"have been removed. These fields will be stripped from your config.",
|
|
29
|
-
);
|
|
30
|
-
|
|
31
25
|
const cleaned = structuredClone(config) as Record<string, unknown>;
|
|
32
26
|
const permissionGate = cleaned.permissionGate as
|
|
33
27
|
| Record<string, unknown>
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { addPendingWarning } from "../../warnings";
|
|
2
1
|
import type { GuardrailsConfig } from "../types";
|
|
3
2
|
import { CURRENT_VERSION } from "./version";
|
|
4
3
|
|
|
@@ -15,7 +14,6 @@ export function run(config: GuardrailsConfig): GuardrailsConfig {
|
|
|
15
14
|
const raw = migrated as Record<string, unknown>;
|
|
16
15
|
const features = raw.features as Record<string, unknown> | undefined;
|
|
17
16
|
const envFiles = raw.envFiles as Record<string, unknown> | undefined;
|
|
18
|
-
|
|
19
17
|
if (features?.protectEnvFiles !== undefined) {
|
|
20
18
|
features.policies = features.protectEnvFiles;
|
|
21
19
|
delete features.protectEnvFiles;
|
|
@@ -62,10 +60,9 @@ export function run(config: GuardrailsConfig): GuardrailsConfig {
|
|
|
62
60
|
}
|
|
63
61
|
|
|
64
62
|
if (Array.isArray(envFiles.protectedTools)) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
);
|
|
63
|
+
// protectedTools has no policies equivalent; the migrated secret-files
|
|
64
|
+
// rule uses protection=noAccess. The deprecation note is surfaced via
|
|
65
|
+
// the `message` factory exported below.
|
|
69
66
|
}
|
|
70
67
|
|
|
71
68
|
if (!Array.isArray(rule.patterns) || rule.patterns.length === 0) {
|
|
@@ -85,3 +82,19 @@ export function run(config: GuardrailsConfig): GuardrailsConfig {
|
|
|
85
82
|
raw.version = CURRENT_VERSION;
|
|
86
83
|
return migrated as GuardrailsConfig;
|
|
87
84
|
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Message for the envFiles-to-policies migration. Only surface the
|
|
88
|
+
* protectedTools deprecation note when the pre-migration config actually had
|
|
89
|
+
* protectedTools set; return undefined otherwise so no message is queued.
|
|
90
|
+
*/
|
|
91
|
+
export function message(before: GuardrailsConfig): string | undefined {
|
|
92
|
+
const envFiles = (before as Record<string, unknown>).envFiles as
|
|
93
|
+
| Record<string, unknown>
|
|
94
|
+
| undefined;
|
|
95
|
+
if (!Array.isArray(envFiles?.protectedTools)) return undefined;
|
|
96
|
+
return (
|
|
97
|
+
"envFiles.protectedTools is deprecated and has no direct policies equivalent. " +
|
|
98
|
+
"The migrated secret-files rule uses protection=noAccess."
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { addPendingWarning } from "../../warnings";
|
|
2
1
|
import type { GuardrailsConfig } from "../types";
|
|
3
2
|
import { CURRENT_VERSION } from "./version";
|
|
4
3
|
|
|
@@ -6,7 +5,12 @@ export function shouldRun(config: GuardrailsConfig): boolean {
|
|
|
6
5
|
const raw = config as Record<string, unknown>;
|
|
7
6
|
const pathAccess = raw.pathAccess as Record<string, unknown> | undefined;
|
|
8
7
|
if (!Array.isArray(pathAccess?.allowedPaths)) return false;
|
|
9
|
-
return pathAccess.allowedPaths.some(
|
|
8
|
+
return pathAccess.allowedPaths.some(
|
|
9
|
+
(item) =>
|
|
10
|
+
typeof item === "object" &&
|
|
11
|
+
item !== null &&
|
|
12
|
+
typeof (item as Record<string, unknown>).pattern === "string",
|
|
13
|
+
);
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
export function run(config: GuardrailsConfig): GuardrailsConfig {
|
|
@@ -16,9 +20,6 @@ export function run(config: GuardrailsConfig): GuardrailsConfig {
|
|
|
16
20
|
|
|
17
21
|
pathAccess.allowedPaths = normalizeAllowedPaths(pathAccess.allowedPaths);
|
|
18
22
|
migrated.version = CURRENT_VERSION;
|
|
19
|
-
addPendingWarning(
|
|
20
|
-
"[guardrails] pathAccess.allowedPaths was migrated from pattern objects to path strings.",
|
|
21
|
-
);
|
|
22
23
|
return migrated as GuardrailsConfig;
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -31,8 +32,11 @@ function normalizeAllowedPaths(items: unknown): string[] {
|
|
|
31
32
|
if (typeof item === "string") {
|
|
32
33
|
path = item;
|
|
33
34
|
} else if (typeof item === "object" && item !== null) {
|
|
34
|
-
const
|
|
35
|
+
const obj = item as Record<string, unknown>;
|
|
36
|
+
const pattern = obj.pattern;
|
|
37
|
+
const objectPath = obj.path;
|
|
35
38
|
if (typeof pattern === "string") path = pattern;
|
|
39
|
+
else if (typeof objectPath === "string") path = objectPath;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
const normalized = path?.trim();
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { addPendingWarning } from "../../warnings";
|
|
2
1
|
import type { GuardrailsConfig } from "../types";
|
|
3
2
|
import { CURRENT_VERSION } from "./version";
|
|
4
3
|
|
|
@@ -11,9 +10,5 @@ export function run(config: GuardrailsConfig): GuardrailsConfig {
|
|
|
11
10
|
migrated.applyBuiltinDefaults = true;
|
|
12
11
|
migrated.version = CURRENT_VERSION;
|
|
13
12
|
|
|
14
|
-
addPendingWarning(
|
|
15
|
-
"Guardrails config was migrated. `applyBuiltinDefaults` was set to `true` to preserve current behavior.",
|
|
16
|
-
);
|
|
17
|
-
|
|
18
13
|
return migrated;
|
|
19
14
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { addPendingWarning } from "../../warnings";
|
|
2
1
|
import type { GuardrailsConfig } from "../types";
|
|
3
2
|
import { CURRENT_VERSION } from "./version";
|
|
4
3
|
|
|
@@ -11,9 +10,6 @@ export function shouldRun(config: GuardrailsConfig): boolean {
|
|
|
11
10
|
|
|
12
11
|
export function run(config: GuardrailsConfig): GuardrailsConfig {
|
|
13
12
|
const migrated = structuredClone(config);
|
|
14
|
-
addPendingWarning(
|
|
15
|
-
"Guardrails config was migrated. Existing setup marked as onboarding-complete.",
|
|
16
|
-
);
|
|
17
13
|
migrated.onboarding = {
|
|
18
14
|
...(migrated.onboarding ?? {}),
|
|
19
15
|
completed: true,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { addPendingWarning } from "../../warnings";
|
|
2
1
|
import type { GuardrailsConfig } from "../types";
|
|
3
2
|
import { CURRENT_VERSION } from "./version";
|
|
4
3
|
|
|
@@ -48,9 +47,6 @@ export function run(config: GuardrailsConfig): GuardrailsConfig {
|
|
|
48
47
|
|
|
49
48
|
if (changed) {
|
|
50
49
|
migrated.version = CURRENT_VERSION;
|
|
51
|
-
addPendingWarning(
|
|
52
|
-
"[guardrails] Config migrated: boolean settings stored as strings were converted to true/false.",
|
|
53
|
-
);
|
|
54
50
|
}
|
|
55
51
|
|
|
56
52
|
return migrated as GuardrailsConfig;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { GuardrailsConfig } from "../types";
|
|
2
|
+
import { CURRENT_VERSION } from "./version";
|
|
3
|
+
|
|
4
|
+
const DEV_NULL = "/dev/null";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Does an allowedPaths entry (legacy string or { kind, path } object) refer
|
|
8
|
+
* to /dev/null? Format-agnostic so this migration does not re-run on configs
|
|
9
|
+
* already migrated to the object form.
|
|
10
|
+
*/
|
|
11
|
+
function includesDevNull(allowedPaths: unknown[] | undefined): boolean {
|
|
12
|
+
return (allowedPaths ?? []).some((entry) => {
|
|
13
|
+
if (typeof entry === "string") return entry === DEV_NULL;
|
|
14
|
+
if (entry && typeof entry === "object") {
|
|
15
|
+
const obj = entry as { path?: unknown };
|
|
16
|
+
return obj.path === DEV_NULL;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function shouldRun(config: GuardrailsConfig): boolean {
|
|
23
|
+
return (
|
|
24
|
+
config.onboarding?.completed === true &&
|
|
25
|
+
config.features?.pathAccess === true &&
|
|
26
|
+
config.pathAccess?.mode === "ask" &&
|
|
27
|
+
!includesDevNull(config.pathAccess?.allowedPaths)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function run(config: GuardrailsConfig): GuardrailsConfig {
|
|
32
|
+
const migrated = structuredClone(config);
|
|
33
|
+
const pathAccess = migrated.pathAccess ?? {};
|
|
34
|
+
const allowedPaths = pathAccess.allowedPaths ?? [];
|
|
35
|
+
|
|
36
|
+
migrated.pathAccess = {
|
|
37
|
+
...pathAccess,
|
|
38
|
+
allowedPaths: [...allowedPaths, { kind: "file", path: DEV_NULL }],
|
|
39
|
+
};
|
|
40
|
+
migrated.version = CURRENT_VERSION;
|
|
41
|
+
|
|
42
|
+
return migrated;
|
|
43
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { GuardrailsConfig } from "../types";
|
|
2
|
+
import { CURRENT_VERSION } from "./version";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Migrate `pathAccess.allowedPaths` from the legacy flat `string[]` (where the
|
|
6
|
+
* kind was inferred from a trailing `/`) to the explicit `{ kind, path }`
|
|
7
|
+
* object form.
|
|
8
|
+
*
|
|
9
|
+
* - Strings ending in `/` become `{ kind: "directory", path }` (slash stripped).
|
|
10
|
+
* - Other strings become `{ kind: "file", path }`.
|
|
11
|
+
* - Entries already in object form are normalized (kind validated, trailing
|
|
12
|
+
* slash stripped from directory paths).
|
|
13
|
+
*
|
|
14
|
+
* Runtime code only handles the object form; this migration is the exclusive
|
|
15
|
+
* owner of the legacy string shape.
|
|
16
|
+
*/
|
|
17
|
+
export function shouldRun(config: GuardrailsConfig): boolean {
|
|
18
|
+
const raw = config as Record<string, unknown>;
|
|
19
|
+
const pathAccess = raw.pathAccess as Record<string, unknown> | undefined;
|
|
20
|
+
if (!Array.isArray(pathAccess?.allowedPaths)) return false;
|
|
21
|
+
return pathAccess.allowedPaths.some((item) => typeof item === "string");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function run(config: GuardrailsConfig): GuardrailsConfig {
|
|
25
|
+
const migrated = structuredClone(config) as Record<string, unknown>;
|
|
26
|
+
const pathAccess = migrated.pathAccess as Record<string, unknown> | undefined;
|
|
27
|
+
if (pathAccess && Array.isArray(pathAccess.allowedPaths)) {
|
|
28
|
+
pathAccess.allowedPaths = (pathAccess.allowedPaths as unknown[])
|
|
29
|
+
.map((item) => toAllowedPath(item))
|
|
30
|
+
.filter(
|
|
31
|
+
(item): item is { kind: "file" | "directory"; path: string } =>
|
|
32
|
+
item !== null,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
migrated.version = CURRENT_VERSION;
|
|
36
|
+
return migrated as GuardrailsConfig;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toAllowedPath(
|
|
40
|
+
item: unknown,
|
|
41
|
+
): { kind: "file" | "directory"; path: string } | null {
|
|
42
|
+
if (typeof item === "string") {
|
|
43
|
+
const trimmed = item.trim();
|
|
44
|
+
if (!trimmed) return null;
|
|
45
|
+
if (trimmed.endsWith("/")) {
|
|
46
|
+
return { kind: "directory", path: trimmed.slice(0, -1) };
|
|
47
|
+
}
|
|
48
|
+
return { kind: "file", path: trimmed };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (item && typeof item === "object") {
|
|
52
|
+
const obj = item as { kind?: unknown; path?: unknown };
|
|
53
|
+
const path = typeof obj.path === "string" ? obj.path.trim() : "";
|
|
54
|
+
if (!path) return null;
|
|
55
|
+
if (obj.kind === "file" || obj.kind === "directory") {
|
|
56
|
+
const cleanPath =
|
|
57
|
+
obj.kind === "directory" && path.endsWith("/")
|
|
58
|
+
? path.slice(0, -1)
|
|
59
|
+
: path;
|
|
60
|
+
return { kind: obj.kind, path: cleanPath };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
@@ -8,6 +8,8 @@ import * as normalizeAllowedPaths from "./005-normalize-allowed-paths";
|
|
|
8
8
|
import * as applyBuiltinDefaults from "./006-apply-builtin-defaults";
|
|
9
9
|
import * as markOnboardingDone from "./007-mark-onboarding-done";
|
|
10
10
|
import * as normalizeStringBooleans from "./008-normalize-string-booleans";
|
|
11
|
+
import * as allowDevNull from "./009-allow-dev-null";
|
|
12
|
+
import * as allowedPathsObjects from "./010-allowed-paths-objects";
|
|
11
13
|
|
|
12
14
|
export { CURRENT_VERSION } from "./version";
|
|
13
15
|
|
|
@@ -21,26 +23,52 @@ export const migrations: Migration<GuardrailsConfig>[] = [
|
|
|
21
23
|
name: "strip-toolchain-fields",
|
|
22
24
|
shouldRun: stripToolchainFields.shouldRun,
|
|
23
25
|
run: stripToolchainFields.run,
|
|
26
|
+
message:
|
|
27
|
+
"preventBrew, preventPython, enforcePackageManager, and packageManager " +
|
|
28
|
+
"have been removed from guardrails and moved to @aliou/pi-toolchain. " +
|
|
29
|
+
"These fields will be stripped from your config.",
|
|
24
30
|
},
|
|
25
31
|
{
|
|
26
32
|
name: "strip-command-explainer-fields",
|
|
27
33
|
shouldRun: stripCommandExplainerFields.shouldRun,
|
|
28
34
|
run: stripCommandExplainerFields.run,
|
|
35
|
+
message:
|
|
36
|
+
"permissionGate.explainCommands, explainModel, and explainTimeout " +
|
|
37
|
+
"have been removed. These fields will be stripped from your config.",
|
|
29
38
|
},
|
|
30
39
|
{
|
|
31
|
-
name: "
|
|
40
|
+
name: "env-files-to-policies",
|
|
32
41
|
shouldRun: envFilesToPolicies.shouldRun,
|
|
33
42
|
run: envFilesToPolicies.run,
|
|
43
|
+
message: envFilesToPolicies.message,
|
|
34
44
|
},
|
|
35
45
|
{
|
|
36
46
|
name: "normalize-allowed-paths",
|
|
37
47
|
shouldRun: normalizeAllowedPaths.shouldRun,
|
|
38
48
|
run: normalizeAllowedPaths.run,
|
|
49
|
+
message:
|
|
50
|
+
"pathAccess.allowedPaths was migrated from pattern objects to path strings.",
|
|
39
51
|
},
|
|
40
52
|
{
|
|
41
53
|
name: "normalize-string-booleans",
|
|
42
54
|
shouldRun: normalizeStringBooleans.shouldRun,
|
|
43
55
|
run: normalizeStringBooleans.run,
|
|
56
|
+
message:
|
|
57
|
+
"Config migrated: boolean settings stored as strings were converted to true/false.",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "allow-dev-null",
|
|
61
|
+
shouldRun: allowDevNull.shouldRun,
|
|
62
|
+
run: allowDevNull.run,
|
|
63
|
+
message:
|
|
64
|
+
"pathAccess.allowedPaths was migrated to allow /dev/null by default.",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "allowed-paths-objects",
|
|
68
|
+
shouldRun: allowedPathsObjects.shouldRun,
|
|
69
|
+
run: allowedPathsObjects.run,
|
|
70
|
+
message:
|
|
71
|
+
"pathAccess.allowedPaths was migrated from path strings to { kind, path } objects.",
|
|
44
72
|
},
|
|
45
73
|
];
|
|
46
74
|
|
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { GuardrailsFeatureId } from "../events";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* A path grant with an explicit kind. Re-exported from the core path module so
|
|
11
|
+
* config consumers can import it from one place.
|
|
12
|
+
*/
|
|
13
|
+
export type { AllowedPath } from "../../core/paths/path";
|
|
14
|
+
|
|
15
|
+
import type { AllowedPath } from "../../core/paths/path";
|
|
16
|
+
|
|
9
17
|
/**
|
|
10
18
|
* A pattern with explicit matching mode.
|
|
11
19
|
* Default: glob for files, substring for commands.
|
|
@@ -60,7 +68,12 @@ export type PathAccessMode = "allow" | "ask" | "block";
|
|
|
60
68
|
|
|
61
69
|
export interface PathAccessConfig {
|
|
62
70
|
mode?: PathAccessMode;
|
|
63
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Paths always allowed, regardless of cwd. Each entry carries an explicit
|
|
73
|
+
* `kind`: `file` matches the exact path, `directory` matches the directory
|
|
74
|
+
* and its descendants.
|
|
75
|
+
*/
|
|
76
|
+
allowedPaths?: AllowedPath[];
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
export interface GuardrailsConfig {
|
|
@@ -127,7 +140,7 @@ export interface ResolvedConfig {
|
|
|
127
140
|
};
|
|
128
141
|
pathAccess: {
|
|
129
142
|
mode: PathAccessMode;
|
|
130
|
-
allowedPaths:
|
|
143
|
+
allowedPaths: AllowedPath[];
|
|
131
144
|
};
|
|
132
145
|
permissionGate: {
|
|
133
146
|
patterns: DangerousPattern[];
|
package/src/shared/index.ts
CHANGED
package/src/shared/matching.ts
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
|
|
11
11
|
import { matchesGlob } from "node:path";
|
|
12
12
|
import type { PatternConfig } from "./config";
|
|
13
|
-
import { addPendingWarning } from "./warnings";
|
|
14
13
|
|
|
15
14
|
export interface CompiledPattern {
|
|
16
15
|
test: (input: string) => boolean;
|
|
@@ -47,9 +46,10 @@ export function compileFilePattern(config: PatternConfig): CompiledPattern {
|
|
|
47
46
|
source: config,
|
|
48
47
|
};
|
|
49
48
|
} catch {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
)
|
|
49
|
+
// TODO: surface invalid regex to the user via ctx.ui.notify once pattern
|
|
50
|
+
// compilation is pre-cached at extension setup (so it has ctx access and
|
|
51
|
+
// runs once, instead of re-firing on every tool call). For now the
|
|
52
|
+
// pattern silently matches nothing.
|
|
53
53
|
return { test: () => false, source: config };
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -80,9 +80,10 @@ export function compileCommandPattern(config: PatternConfig): CompiledPattern {
|
|
|
80
80
|
const re = new RegExp(config.pattern);
|
|
81
81
|
return { test: (input) => re.test(input), source: config };
|
|
82
82
|
} catch {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
)
|
|
83
|
+
// TODO: surface invalid regex to the user via ctx.ui.notify once pattern
|
|
84
|
+
// compilation is pre-cached at extension setup (so it has ctx access and
|
|
85
|
+
// runs once, instead of re-firing on every tool call). For now the
|
|
86
|
+
// pattern silently matches nothing.
|
|
86
87
|
return { test: () => false, source: config };
|
|
87
88
|
}
|
|
88
89
|
}
|
package/src/shared/warnings.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Module-level warnings queue for messages that arise before any session
|
|
3
|
-
* context is available (config loading, migration, pattern compilation).
|
|
4
|
-
*/
|
|
5
|
-
const pendingWarnings: string[] = [];
|
|
6
|
-
|
|
7
|
-
export function addPendingWarning(message: string): void {
|
|
8
|
-
pendingWarnings.push(message);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function getPendingWarnings(): readonly string[] {
|
|
12
|
-
return pendingWarnings;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function drainPendingWarnings(): string[] {
|
|
16
|
-
return pendingWarnings.splice(0);
|
|
17
|
-
}
|