@aliou/pi-guardrails 0.11.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -167
- package/extensions/guardrails/commands/examples/index.ts +520 -0
- package/extensions/guardrails/commands/onboarding/config.ts +54 -0
- package/{src/commands/onboarding-command.ts → extensions/guardrails/commands/onboarding/index.ts} +5 -31
- package/extensions/guardrails/commands/settings/add-rule-wizard.ts +267 -0
- package/extensions/guardrails/commands/settings/examples.ts +399 -0
- package/extensions/guardrails/commands/settings/index.ts +596 -0
- package/extensions/guardrails/commands/settings/path-list-editor.ts +158 -0
- package/extensions/guardrails/commands/settings/scope-picker-submenu.ts +69 -0
- package/extensions/guardrails/commands/settings/utils.ts +108 -0
- package/extensions/guardrails/components/onboarding-choice-step.ts +140 -0
- package/extensions/guardrails/components/onboarding-finish-step.ts +50 -0
- package/extensions/guardrails/components/onboarding-intro-step.ts +30 -0
- package/extensions/guardrails/components/onboarding-types.ts +10 -0
- package/extensions/guardrails/components/onboarding-wizard.ts +116 -0
- package/{src → extensions/guardrails}/components/pattern-editor.ts +11 -10
- package/extensions/guardrails/index.ts +106 -0
- package/extensions/guardrails/rules.test.ts +107 -0
- package/extensions/guardrails/rules.ts +119 -0
- package/extensions/guardrails/targets.test.ts +44 -0
- package/extensions/guardrails/targets.ts +66 -0
- package/extensions/path-access/grants.test.ts +47 -0
- package/extensions/path-access/grants.ts +68 -0
- package/extensions/path-access/index.ts +143 -0
- package/extensions/path-access/prompt.ts +196 -0
- package/extensions/path-access/rules.test.ts +46 -0
- package/extensions/path-access/rules.ts +37 -0
- package/extensions/path-access/targets.test.ts +40 -0
- package/extensions/path-access/targets.ts +19 -0
- package/extensions/permission-gate/grants.ts +21 -0
- package/extensions/permission-gate/index.ts +122 -0
- package/extensions/permission-gate/prompt.ts +222 -0
- package/extensions/permission-gate/rules.test.ts +132 -0
- package/extensions/permission-gate/rules.ts +72 -0
- package/package.json +18 -20
- package/schema.json +286 -0
- package/src/core/check.test.ts +169 -0
- package/src/core/check.ts +38 -0
- package/src/{hooks/permission-gate/dangerous-commands.test.ts → core/commands/dangerous.test.ts} +134 -2
- package/src/{hooks/permission-gate/dangerous-commands.ts → core/commands/dangerous.ts} +119 -1
- package/src/core/commands/index.ts +15 -0
- package/src/core/index.ts +13 -0
- package/src/{utils/path-access.test.ts → core/paths/access.test.ts} +1 -5
- package/src/core/paths/index.ts +14 -0
- package/src/{utils → core/shell}/command-args.test.ts +31 -20
- package/src/core/shell/index.ts +2 -0
- package/src/core/types.ts +55 -0
- package/src/shared/config/defaults.ts +118 -0
- package/src/shared/config/index.ts +17 -0
- package/src/shared/config/loader.ts +64 -0
- package/src/shared/config/migration/001-v0-format-upgrade.ts +107 -0
- package/src/shared/config/migration/002-strip-toolchain-fields.ts +39 -0
- package/src/shared/config/migration/003-strip-command-explainer-fields.ts +42 -0
- package/src/shared/config/migration/004-env-files-to-policies.ts +87 -0
- package/src/shared/config/migration/005-normalize-allowed-paths.ts +43 -0
- package/src/shared/config/migration/006-apply-builtin-defaults.ts +19 -0
- package/src/shared/config/migration/007-mark-onboarding-done.ts +25 -0
- package/src/shared/config/migration/index.ts +44 -0
- package/src/shared/config/migration/version.ts +7 -0
- package/src/shared/config/types.ts +141 -0
- package/src/shared/events.ts +100 -0
- package/src/shared/index.ts +6 -0
- package/src/shared/matching.test.ts +86 -0
- package/src/{utils → shared}/matching.ts +4 -4
- package/src/{utils → shared/paths}/bash-paths.test.ts +11 -2
- package/src/{utils → shared/paths}/bash-paths.ts +4 -4
- package/src/shared/paths/index.ts +1 -0
- package/src/shared/warnings.ts +17 -0
- package/docs/defaults.md +0 -140
- package/docs/examples.md +0 -170
- package/src/commands/onboarding.ts +0 -390
- package/src/commands/settings-command.ts +0 -1616
- package/src/config.ts +0 -392
- package/src/hooks/index.ts +0 -11
- package/src/hooks/path-access.ts +0 -395
- package/src/hooks/permission-gate/index.test.ts +0 -332
- package/src/hooks/permission-gate/index.ts +0 -595
- package/src/hooks/policies.ts +0 -322
- package/src/index.ts +0 -96
- package/src/lib/executor.ts +0 -280
- package/src/lib/index.ts +0 -16
- package/src/lib/model-resolver.ts +0 -47
- package/src/lib/timing.ts +0 -42
- package/src/lib/types.ts +0 -115
- package/src/utils/events.ts +0 -32
- package/src/utils/migration.test.ts +0 -58
- package/src/utils/migration.ts +0 -340
- package/src/utils/warnings.ts +0 -7
- /package/src/{utils/path-access.ts → core/paths/access.ts} +0 -0
- /package/src/{utils → core/paths}/path.test.ts +0 -0
- /package/src/{utils → core/paths}/path.ts +0 -0
- /package/src/{utils/shell-utils.ts → core/shell/ast.ts} +0 -0
- /package/src/{utils → core/shell}/command-args.ts +0 -0
- /package/src/{utils/glob-expander.ts → shared/glob.ts} +0 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { checkAction } from "../../src/core";
|
|
4
|
+
import {
|
|
5
|
+
normalizeForDisplay,
|
|
6
|
+
type PathAccessState,
|
|
7
|
+
} from "../../src/core/paths";
|
|
8
|
+
import { configLoader } from "../../src/shared/config";
|
|
9
|
+
import {
|
|
10
|
+
createFeatureRegisterPayload,
|
|
11
|
+
emitActionBlocked,
|
|
12
|
+
GUARDRAILS_FEATURE_REGISTER_EVENT,
|
|
13
|
+
GUARDRAILS_FEATURE_REQUEST_EVENT,
|
|
14
|
+
} from "../../src/shared/events";
|
|
15
|
+
import {
|
|
16
|
+
createPendingGrant,
|
|
17
|
+
isGrantTooBroad,
|
|
18
|
+
type PendingPathGrant,
|
|
19
|
+
pendingAllowedPaths,
|
|
20
|
+
persistGrant,
|
|
21
|
+
resolveAllowedPaths,
|
|
22
|
+
} from "./grants";
|
|
23
|
+
import { createPathAccessPromptComponent, type PromptResult } from "./prompt";
|
|
24
|
+
import { createPathAccessRule } from "./rules";
|
|
25
|
+
import { targetsForTool } from "./targets";
|
|
26
|
+
|
|
27
|
+
export default async function pathAccess(pi: ExtensionAPI) {
|
|
28
|
+
await configLoader.load();
|
|
29
|
+
|
|
30
|
+
pi.events.on(GUARDRAILS_FEATURE_REQUEST_EVENT, () => {
|
|
31
|
+
pi.events.emit(
|
|
32
|
+
GUARDRAILS_FEATURE_REGISTER_EVENT,
|
|
33
|
+
createFeatureRegisterPayload("pathAccess"),
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
38
|
+
const config = configLoader.getConfig();
|
|
39
|
+
if (
|
|
40
|
+
!config.enabled ||
|
|
41
|
+
!config.features.pathAccess ||
|
|
42
|
+
config.pathAccess.mode === "allow"
|
|
43
|
+
) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const input = event.input as Record<string, unknown>;
|
|
48
|
+
const targets = [
|
|
49
|
+
...new Set(await targetsForTool(event.toolName, input, ctx.cwd)),
|
|
50
|
+
];
|
|
51
|
+
const acceptedGrants: PendingPathGrant[] = [];
|
|
52
|
+
|
|
53
|
+
for (const absolutePath of targets) {
|
|
54
|
+
const action = {
|
|
55
|
+
kind: "file" as const,
|
|
56
|
+
path: absolutePath,
|
|
57
|
+
origin: event.toolName,
|
|
58
|
+
};
|
|
59
|
+
const state: PathAccessState = {
|
|
60
|
+
cwd: ctx.cwd,
|
|
61
|
+
mode: config.pathAccess.mode,
|
|
62
|
+
allowedPaths: [
|
|
63
|
+
...resolveAllowedPaths(config.pathAccess.allowedPaths, ctx.cwd),
|
|
64
|
+
...pendingAllowedPaths(acceptedGrants),
|
|
65
|
+
],
|
|
66
|
+
hasUI: ctx.hasUI,
|
|
67
|
+
};
|
|
68
|
+
const safety = await checkAction(action, [createPathAccessRule(state)]);
|
|
69
|
+
if (safety.kind === "safe") continue;
|
|
70
|
+
|
|
71
|
+
if (config.pathAccess.mode === "block" || !ctx.hasUI) {
|
|
72
|
+
emitActionBlocked(pi, {
|
|
73
|
+
feature: "pathAccess",
|
|
74
|
+
action: safety.action,
|
|
75
|
+
reason: safety.reason,
|
|
76
|
+
block: {
|
|
77
|
+
source: ctx.hasUI ? "policy" : "nonInteractive",
|
|
78
|
+
metadata: safety.metadata,
|
|
79
|
+
},
|
|
80
|
+
context: { toolName: event.toolName, input },
|
|
81
|
+
});
|
|
82
|
+
return { block: true, reason: safety.reason };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const parentDir = dirname(absolutePath);
|
|
86
|
+
const showFileOptions =
|
|
87
|
+
event.toolName !== "ls" && event.toolName !== "find";
|
|
88
|
+
const result = await ctx.ui.custom<PromptResult>(
|
|
89
|
+
createPathAccessPromptComponent(
|
|
90
|
+
event.toolName,
|
|
91
|
+
safety.metadata.displayPath,
|
|
92
|
+
normalizeForDisplay(parentDir, ctx.cwd),
|
|
93
|
+
ctx.cwd,
|
|
94
|
+
showFileOptions,
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (result === "allow-file-once" || result === "allow-dir-once") {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (result === "allow-file-session" || result === "allow-file-always") {
|
|
103
|
+
const grant = createPendingGrant(
|
|
104
|
+
absolutePath,
|
|
105
|
+
false,
|
|
106
|
+
result === "allow-file-session" ? "memory" : "local",
|
|
107
|
+
);
|
|
108
|
+
acceptedGrants.push(grant);
|
|
109
|
+
await persistGrant(grant);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (result === "allow-dir-session" || result === "allow-dir-always") {
|
|
114
|
+
const dirPath = showFileOptions ? parentDir : absolutePath;
|
|
115
|
+
if (isGrantTooBroad(dirPath)) {
|
|
116
|
+
ctx.ui.notify(
|
|
117
|
+
`Cannot grant access to ${normalizeForDisplay(dirPath, ctx.cwd)}/ — too broad. Treating as allow once.`,
|
|
118
|
+
"warning",
|
|
119
|
+
);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const grant = createPendingGrant(
|
|
123
|
+
dirPath,
|
|
124
|
+
true,
|
|
125
|
+
result === "allow-dir-session" ? "memory" : "local",
|
|
126
|
+
);
|
|
127
|
+
acceptedGrants.push(grant);
|
|
128
|
+
await persistGrant(grant);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const reason = "User denied access outside working directory";
|
|
133
|
+
emitActionBlocked(pi, {
|
|
134
|
+
feature: "pathAccess",
|
|
135
|
+
action: safety.action,
|
|
136
|
+
reason,
|
|
137
|
+
block: { source: "user", metadata: safety.metadata },
|
|
138
|
+
context: { toolName: event.toolName, input },
|
|
139
|
+
});
|
|
140
|
+
return { block: true, reason };
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import {
|
|
3
|
+
Container,
|
|
4
|
+
Key,
|
|
5
|
+
matchesKey,
|
|
6
|
+
Spacer,
|
|
7
|
+
Text,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
// Grant result type from the UI prompt
|
|
12
|
+
export type PromptResult =
|
|
13
|
+
| "allow-file-once"
|
|
14
|
+
| "allow-dir-once"
|
|
15
|
+
| "allow-file-session"
|
|
16
|
+
| "allow-dir-session"
|
|
17
|
+
| "allow-file-always"
|
|
18
|
+
| "allow-dir-always"
|
|
19
|
+
| "deny";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Collapse home directory to ~ for display.
|
|
23
|
+
*/
|
|
24
|
+
function displayCwd(cwd: string): string {
|
|
25
|
+
const home = homedir();
|
|
26
|
+
if (cwd === home) return "~";
|
|
27
|
+
if (cwd.startsWith(`${home}/`) || cwd.startsWith(`${home}\\`)) {
|
|
28
|
+
return `~${cwd.slice(home.length)}`;
|
|
29
|
+
}
|
|
30
|
+
return cwd;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface PromptOption {
|
|
34
|
+
label: string;
|
|
35
|
+
result: PromptResult;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const FILE_OPTIONS: PromptOption[] = [
|
|
39
|
+
{ label: "Allow once", result: "allow-file-once" },
|
|
40
|
+
{ label: "Allow file this session", result: "allow-file-session" },
|
|
41
|
+
{ label: "Allow file always", result: "allow-file-always" },
|
|
42
|
+
{ label: "Allow directory this session", result: "allow-dir-session" },
|
|
43
|
+
{ label: "Allow directory always", result: "allow-dir-always" },
|
|
44
|
+
{ label: "Deny", result: "deny" },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const DIR_OPTIONS: PromptOption[] = [
|
|
48
|
+
{ label: "Allow once", result: "allow-dir-once" },
|
|
49
|
+
{ label: "Allow directory this session", result: "allow-dir-session" },
|
|
50
|
+
{ label: "Allow directory always", result: "allow-dir-always" },
|
|
51
|
+
{ label: "Deny", result: "deny" },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the confirmation UI component.
|
|
56
|
+
* For directory-oriented tools (ls, find): only directory grant options.
|
|
57
|
+
* For file tools and bash: both file and directory options.
|
|
58
|
+
* Options rendered as highlighted tabs (selected = accent bg, unselected = dim),
|
|
59
|
+
* navigable with ←/→/Tab/Shift+Tab.
|
|
60
|
+
*/
|
|
61
|
+
export function createPathAccessPromptComponent(
|
|
62
|
+
toolName: string,
|
|
63
|
+
displayPath: string,
|
|
64
|
+
displayDir: string,
|
|
65
|
+
cwd: string,
|
|
66
|
+
showFileOptions: boolean,
|
|
67
|
+
) {
|
|
68
|
+
return (
|
|
69
|
+
tui: { terminal: { columns: number }; requestRender(): void },
|
|
70
|
+
theme: {
|
|
71
|
+
fg(color: string, text: string): string;
|
|
72
|
+
bg(color: string, text: string): string;
|
|
73
|
+
bold(text: string): string;
|
|
74
|
+
},
|
|
75
|
+
_kb: unknown,
|
|
76
|
+
done: (result: PromptResult) => void,
|
|
77
|
+
) => {
|
|
78
|
+
const options = showFileOptions ? FILE_OPTIONS : DIR_OPTIONS;
|
|
79
|
+
let selectedIndex = 0;
|
|
80
|
+
|
|
81
|
+
const container = new Container();
|
|
82
|
+
const border = (s: string) => theme.fg("warning", s);
|
|
83
|
+
const cwdDisplay = displayCwd(cwd);
|
|
84
|
+
|
|
85
|
+
container.addChild(
|
|
86
|
+
new Text(
|
|
87
|
+
theme.fg("warning", theme.bold("Outside Workspace Access")),
|
|
88
|
+
1,
|
|
89
|
+
0,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
container.addChild(new Spacer(1));
|
|
93
|
+
container.addChild(
|
|
94
|
+
new Text(
|
|
95
|
+
theme.fg(
|
|
96
|
+
"text",
|
|
97
|
+
`\`${toolName}\` targets a path outside the working directory.`,
|
|
98
|
+
),
|
|
99
|
+
1,
|
|
100
|
+
0,
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
container.addChild(new Spacer(1));
|
|
104
|
+
container.addChild(
|
|
105
|
+
new Text(theme.fg("dim", ` Cwd: ${cwdDisplay}`), 1, 0),
|
|
106
|
+
);
|
|
107
|
+
container.addChild(
|
|
108
|
+
new Text(theme.fg("dim", ` Path: ${displayPath}`), 1, 0),
|
|
109
|
+
);
|
|
110
|
+
container.addChild(
|
|
111
|
+
new Text(theme.fg("dim", ` Dir: ${displayDir}`), 1, 0),
|
|
112
|
+
);
|
|
113
|
+
container.addChild(new Spacer(1));
|
|
114
|
+
|
|
115
|
+
// Dynamically rendered option lines
|
|
116
|
+
const optionLines: Text[] = options.map(() => new Text("", 1, 0));
|
|
117
|
+
for (const line of optionLines) {
|
|
118
|
+
container.addChild(line);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
container.addChild(new Spacer(1));
|
|
122
|
+
container.addChild(
|
|
123
|
+
new Text(
|
|
124
|
+
theme.fg("dim", "↑/↓/Tab select · Enter select · Esc deny"),
|
|
125
|
+
1,
|
|
126
|
+
0,
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const renderOptions = () => {
|
|
131
|
+
for (let i = 0; i < options.length; i++) {
|
|
132
|
+
const label = options[i].label;
|
|
133
|
+
if (i === selectedIndex) {
|
|
134
|
+
optionLines[i].setText(
|
|
135
|
+
theme.bg("selectedBg", theme.fg("accent", ` ${label} `)),
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
optionLines[i].setText(theme.fg("dim", ` ${label} `));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
renderOptions();
|
|
144
|
+
|
|
145
|
+
const moveSelection = (direction: number) => {
|
|
146
|
+
selectedIndex =
|
|
147
|
+
(selectedIndex + direction + options.length) % options.length;
|
|
148
|
+
renderOptions();
|
|
149
|
+
tui.requestRender();
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
render: (width: number) => {
|
|
154
|
+
const innerWidth = Math.max(1, width - 2);
|
|
155
|
+
const contentWidth = Math.max(1, width - 4);
|
|
156
|
+
const raw = container.render(contentWidth);
|
|
157
|
+
const top = border(`╭${"─".repeat(innerWidth)}╮`);
|
|
158
|
+
const bottom = border(`╰${"─".repeat(innerWidth)}╯`);
|
|
159
|
+
const left = border("│");
|
|
160
|
+
const right = border("│");
|
|
161
|
+
const lines = raw.map((line) => {
|
|
162
|
+
const visible = visibleWidth(line);
|
|
163
|
+
const pad = Math.max(0, contentWidth - visible);
|
|
164
|
+
return `${left} ${line}${" ".repeat(pad)} ${right}`;
|
|
165
|
+
});
|
|
166
|
+
return [top, ...lines, bottom];
|
|
167
|
+
},
|
|
168
|
+
invalidate: () => container.invalidate(),
|
|
169
|
+
handleInput: (data: string) => {
|
|
170
|
+
if (
|
|
171
|
+
matchesKey(data, Key.up) ||
|
|
172
|
+
data === "k" ||
|
|
173
|
+
matchesKey(data, Key.shift("tab"))
|
|
174
|
+
) {
|
|
175
|
+
moveSelection(-1);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (
|
|
179
|
+
matchesKey(data, Key.down) ||
|
|
180
|
+
data === "j" ||
|
|
181
|
+
matchesKey(data, Key.tab)
|
|
182
|
+
) {
|
|
183
|
+
moveSelection(1);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (matchesKey(data, Key.enter)) {
|
|
187
|
+
done(options[selectedIndex].result);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (matchesKey(data, Key.escape)) {
|
|
191
|
+
done("deny");
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createPathAccessRule } from "./rules";
|
|
3
|
+
|
|
4
|
+
const cwd = "/repo";
|
|
5
|
+
const state = (allowedPaths: string[] = []) => ({
|
|
6
|
+
cwd,
|
|
7
|
+
mode: "block" as const,
|
|
8
|
+
allowedPaths,
|
|
9
|
+
hasUI: true,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("createPathAccessRule", () => {
|
|
13
|
+
it("passes command actions", () => {
|
|
14
|
+
const rule = createPathAccessRule(state());
|
|
15
|
+
expect(rule.check({ kind: "command", command: "cat /tmp/a" })).toEqual({
|
|
16
|
+
kind: "pass",
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("passes files inside cwd", () => {
|
|
21
|
+
const rule = createPathAccessRule(state());
|
|
22
|
+
expect(rule.check({ kind: "file", path: "/repo/src/index.ts" })).toEqual({
|
|
23
|
+
kind: "pass",
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("matches outside files in block mode", () => {
|
|
28
|
+
const rule = createPathAccessRule(state());
|
|
29
|
+
expect(rule.check({ kind: "file", path: "/tmp/secret.txt" })).toMatchObject(
|
|
30
|
+
{
|
|
31
|
+
kind: "match",
|
|
32
|
+
metadata: {
|
|
33
|
+
absolutePath: "/tmp/secret.txt",
|
|
34
|
+
displayPath: "/tmp/secret.txt",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("passes explicitly allowed outside paths", () => {
|
|
41
|
+
const rule = createPathAccessRule(state(["/tmp/"]));
|
|
42
|
+
expect(rule.check({ kind: "file", path: "/tmp/secret.txt" })).toEqual({
|
|
43
|
+
kind: "pass",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Action, Rule } from "../../src/core";
|
|
2
|
+
import {
|
|
3
|
+
checkPathAccess,
|
|
4
|
+
normalizeForDisplay,
|
|
5
|
+
type PathAccessState,
|
|
6
|
+
} from "../../src/core/paths";
|
|
7
|
+
|
|
8
|
+
export type PathAccessMeta = {
|
|
9
|
+
absolutePath: string;
|
|
10
|
+
displayPath: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function createPathAccessRule(
|
|
14
|
+
state: PathAccessState,
|
|
15
|
+
): Rule<PathAccessMeta> {
|
|
16
|
+
return {
|
|
17
|
+
key: "path-access.outside-workspace",
|
|
18
|
+
check(action: Action) {
|
|
19
|
+
if (action.kind !== "file") return { kind: "pass" };
|
|
20
|
+
const displayPath = normalizeForDisplay(action.path, state.cwd);
|
|
21
|
+
const decision = checkPathAccess(action.path, displayPath, state);
|
|
22
|
+
if (decision.kind === "allow") return { kind: "pass" };
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
kind: "match",
|
|
26
|
+
reason:
|
|
27
|
+
decision.kind === "deny"
|
|
28
|
+
? decision.reason
|
|
29
|
+
: `Access to ${displayPath} requires confirmation.`,
|
|
30
|
+
metadata: {
|
|
31
|
+
absolutePath: action.path,
|
|
32
|
+
displayPath,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { vol } from "memfs";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { targetsForTool } from "./targets";
|
|
5
|
+
|
|
6
|
+
describe("targetsForTool", () => {
|
|
7
|
+
it("resolves direct file tool targets from cwd", async () => {
|
|
8
|
+
await expect(
|
|
9
|
+
targetsForTool("read", { path: "README.md" }, "/repo"),
|
|
10
|
+
).resolves.toEqual(["/repo/README.md"]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("extracts bash path candidates", async () => {
|
|
14
|
+
const cwd = "/repo";
|
|
15
|
+
vol.fromJSON({ "/repo/README.md": "hello" });
|
|
16
|
+
|
|
17
|
+
await expect(
|
|
18
|
+
targetsForTool("bash", { command: "cat ./README.md" }, cwd),
|
|
19
|
+
).resolves.toEqual([join(cwd, "README.md")]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("does not treat awk regexes as paths", async () => {
|
|
23
|
+
const cwd = "/repo";
|
|
24
|
+
vol.fromJSON({ "/repo/test.txt": "aaa" });
|
|
25
|
+
|
|
26
|
+
await expect(
|
|
27
|
+
targetsForTool(
|
|
28
|
+
"bash",
|
|
29
|
+
{ command: "awk '/aaa/{flag=1} flag{print}' ./test.txt" },
|
|
30
|
+
cwd,
|
|
31
|
+
),
|
|
32
|
+
).resolves.toEqual([join(cwd, "test.txt")]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("ignores unrelated tools", async () => {
|
|
36
|
+
await expect(
|
|
37
|
+
targetsForTool("custom", { path: "README.md" }, "/repo"),
|
|
38
|
+
).resolves.toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { resolveFromCwd } from "../../src/core/paths";
|
|
2
|
+
import { extractBashPathCandidates } from "../../src/shared/paths";
|
|
3
|
+
|
|
4
|
+
export async function targetsForTool(
|
|
5
|
+
toolName: string,
|
|
6
|
+
input: Record<string, unknown>,
|
|
7
|
+
cwd: string,
|
|
8
|
+
): Promise<string[]> {
|
|
9
|
+
if (["read", "write", "edit", "grep", "find", "ls"].includes(toolName)) {
|
|
10
|
+
const raw = String(input.file_path ?? input.path ?? "").trim();
|
|
11
|
+
return raw ? [resolveFromCwd(raw, cwd)] : [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (toolName === "bash") {
|
|
15
|
+
return extractBashPathCandidates(String(input.command ?? ""), cwd);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { configLoader } from "../../src/shared/config";
|
|
2
|
+
import { compileCommandPatterns } from "../../src/shared/matching";
|
|
3
|
+
|
|
4
|
+
export function isCommandAllowed(command: string): boolean {
|
|
5
|
+
const config = configLoader.getConfig();
|
|
6
|
+
return compileCommandPatterns(config.permissionGate.allowedPatterns).some(
|
|
7
|
+
(pattern) => pattern.test(command),
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function saveCommandSessionGrant(command: string): Promise<void> {
|
|
12
|
+
const resolved = configLoader.getConfig();
|
|
13
|
+
await configLoader.save("memory", {
|
|
14
|
+
permissionGate: {
|
|
15
|
+
allowedPatterns: [
|
|
16
|
+
...resolved.permissionGate.allowedPatterns,
|
|
17
|
+
{ pattern: command },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
isToolCallEventType,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { checkAction } from "../../src/core";
|
|
6
|
+
import { configLoader } from "../../src/shared/config";
|
|
7
|
+
import {
|
|
8
|
+
createFeatureRegisterPayload,
|
|
9
|
+
emitActionBlocked,
|
|
10
|
+
emitRiskDetected,
|
|
11
|
+
GUARDRAILS_FEATURE_REGISTER_EVENT,
|
|
12
|
+
GUARDRAILS_FEATURE_REQUEST_EVENT,
|
|
13
|
+
} from "../../src/shared/events";
|
|
14
|
+
import { isCommandAllowed, saveCommandSessionGrant } from "./grants";
|
|
15
|
+
import { createPermissionGateConfirmComponent } from "./prompt";
|
|
16
|
+
import {
|
|
17
|
+
createPermissionGateRule,
|
|
18
|
+
formatAutoDenyReason,
|
|
19
|
+
matchCommandPattern,
|
|
20
|
+
} from "./rules";
|
|
21
|
+
|
|
22
|
+
export default async function permissionGate(pi: ExtensionAPI) {
|
|
23
|
+
await configLoader.load();
|
|
24
|
+
|
|
25
|
+
pi.events.on(GUARDRAILS_FEATURE_REQUEST_EVENT, () => {
|
|
26
|
+
pi.events.emit(
|
|
27
|
+
GUARDRAILS_FEATURE_REGISTER_EVENT,
|
|
28
|
+
createFeatureRegisterPayload("permissionGate"),
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
33
|
+
const config = configLoader.getConfig();
|
|
34
|
+
if (!config.enabled || !config.features.permissionGate) return;
|
|
35
|
+
if (!isToolCallEventType("bash", event)) return;
|
|
36
|
+
|
|
37
|
+
const command = event.input.command;
|
|
38
|
+
const action = { kind: "command" as const, command, origin: "bash" };
|
|
39
|
+
if (isCommandAllowed(command)) return;
|
|
40
|
+
|
|
41
|
+
const autoDenyMatch = matchCommandPattern(
|
|
42
|
+
command,
|
|
43
|
+
config.permissionGate.autoDenyPatterns,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (autoDenyMatch) {
|
|
47
|
+
const reason = formatAutoDenyReason(autoDenyMatch);
|
|
48
|
+
|
|
49
|
+
emitActionBlocked(pi, {
|
|
50
|
+
feature: "permissionGate",
|
|
51
|
+
action,
|
|
52
|
+
reason,
|
|
53
|
+
block: { source: "permission", metadata: autoDenyMatch },
|
|
54
|
+
context: { toolName: "bash", input: event.input },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return { block: true, reason };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const safety = await checkAction(action, [
|
|
61
|
+
createPermissionGateRule({
|
|
62
|
+
patterns: config.permissionGate.patterns,
|
|
63
|
+
useBuiltinMatchers: config.permissionGate.useBuiltinMatchers,
|
|
64
|
+
}),
|
|
65
|
+
]);
|
|
66
|
+
if (safety.kind === "safe") return;
|
|
67
|
+
|
|
68
|
+
emitRiskDetected(pi, {
|
|
69
|
+
feature: "permissionGate",
|
|
70
|
+
risk: safety,
|
|
71
|
+
context: { toolName: "bash", input: event.input },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!config.permissionGate.requireConfirmation) {
|
|
75
|
+
ctx.ui.notify(`Dangerous command detected: ${safety.reason}`, "warning");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!ctx.hasUI) {
|
|
80
|
+
const reason = `Dangerous command blocked (no UI to confirm): ${safety.reason}`;
|
|
81
|
+
emitActionBlocked(pi, {
|
|
82
|
+
feature: "permissionGate",
|
|
83
|
+
action: safety.action,
|
|
84
|
+
reason,
|
|
85
|
+
block: { source: "nonInteractive", metadata: safety.metadata },
|
|
86
|
+
context: { toolName: "bash", input: event.input },
|
|
87
|
+
});
|
|
88
|
+
return { block: true, reason };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type ConfirmResult = "allow" | "allow-session" | "deny";
|
|
92
|
+
let result = await ctx.ui.custom<ConfirmResult>(
|
|
93
|
+
createPermissionGateConfirmComponent(command, safety.reason),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (result === undefined) {
|
|
97
|
+
const selection = await ctx.ui.select(
|
|
98
|
+
`Dangerous command: ${safety.reason}`,
|
|
99
|
+
["Allow once", "Allow for session", "Deny"],
|
|
100
|
+
);
|
|
101
|
+
if (selection === "Allow once") result = "allow";
|
|
102
|
+
else if (selection === "Allow for session") result = "allow-session";
|
|
103
|
+
else result = "deny";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (result === "allow") return;
|
|
107
|
+
if (result === "allow-session") {
|
|
108
|
+
await saveCommandSessionGrant(command);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const reason = "User denied dangerous command";
|
|
113
|
+
emitActionBlocked(pi, {
|
|
114
|
+
feature: "permissionGate",
|
|
115
|
+
action: safety.action,
|
|
116
|
+
reason,
|
|
117
|
+
block: { source: "user", metadata: safety.metadata },
|
|
118
|
+
context: { toolName: "bash", input: event.input },
|
|
119
|
+
});
|
|
120
|
+
return { block: true, reason };
|
|
121
|
+
});
|
|
122
|
+
}
|