@aliou/pi-guardrails 0.9.5 → 0.10.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 +5 -0
- package/docs/defaults.md +115 -0
- package/docs/examples.md +170 -0
- package/package.json +3 -2
- package/src/commands/onboarding-command.ts +59 -0
- package/src/commands/onboarding.ts +274 -0
- package/src/commands/settings-command.ts +129 -3
- package/src/config.ts +58 -2
- package/src/hooks/permission-gate.ts +248 -105
- package/src/hooks/policies.ts +20 -4
- package/src/index.ts +62 -3
- package/src/utils/migration.ts +55 -1
- package/src/utils/path.ts +18 -0
|
@@ -258,6 +258,118 @@ const COMMAND_EXAMPLES: Array<{
|
|
|
258
258
|
description: "Require confirmation for table drops",
|
|
259
259
|
pattern: { pattern: "DROP TABLE", description: "SQL table drop" },
|
|
260
260
|
},
|
|
261
|
+
{
|
|
262
|
+
label: "dbt run",
|
|
263
|
+
description: "Require confirmation for dbt model runs",
|
|
264
|
+
pattern: {
|
|
265
|
+
pattern: "dbt run",
|
|
266
|
+
description: "dbt model execution",
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
label: "dbt seed",
|
|
271
|
+
description: "Require confirmation for dbt seed data loading",
|
|
272
|
+
pattern: {
|
|
273
|
+
pattern: "dbt seed",
|
|
274
|
+
description: "dbt seed data loading",
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
label: "aws s3 rm",
|
|
279
|
+
description: "Require confirmation for AWS S3 deletions",
|
|
280
|
+
pattern: {
|
|
281
|
+
pattern: "aws s3 rm",
|
|
282
|
+
description: "AWS S3 object deletion",
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
label: "aws iam",
|
|
287
|
+
description: "Require confirmation for AWS IAM changes",
|
|
288
|
+
pattern: {
|
|
289
|
+
pattern: "aws iam",
|
|
290
|
+
description: "AWS IAM permission changes",
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
label: "aws ec2 terminate",
|
|
295
|
+
description: "Require confirmation for EC2 instance termination",
|
|
296
|
+
pattern: {
|
|
297
|
+
pattern: "aws ec2 terminate-instances",
|
|
298
|
+
description: "AWS EC2 instance termination",
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
label: "kubectl apply",
|
|
303
|
+
description: "Require confirmation for k8s resource application",
|
|
304
|
+
pattern: {
|
|
305
|
+
pattern: "kubectl apply",
|
|
306
|
+
description: "Kubernetes resource application",
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
label: "kubectl scale",
|
|
311
|
+
description: "Require confirmation for k8s scaling operations",
|
|
312
|
+
pattern: {
|
|
313
|
+
pattern: "kubectl scale",
|
|
314
|
+
description: "Kubernetes scaling operation",
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
label: "docker rm",
|
|
319
|
+
description: "Require confirmation for Docker container removal",
|
|
320
|
+
pattern: {
|
|
321
|
+
pattern: "docker rm",
|
|
322
|
+
description: "Docker container removal",
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
label: "docker rmi",
|
|
327
|
+
description: "Require confirmation for Docker image removal",
|
|
328
|
+
pattern: {
|
|
329
|
+
pattern: "docker rmi",
|
|
330
|
+
description: "Docker image removal",
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
label: "docker compose down",
|
|
335
|
+
description: "Require confirmation for Docker Compose teardown",
|
|
336
|
+
pattern: {
|
|
337
|
+
pattern: "docker compose down",
|
|
338
|
+
description: "Docker Compose service teardown",
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
label: "terraform import",
|
|
343
|
+
description: "Require confirmation for Terraform resource import",
|
|
344
|
+
pattern: {
|
|
345
|
+
pattern: "terraform import",
|
|
346
|
+
description: "Terraform resource import",
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
label: "gcloud compute delete",
|
|
351
|
+
description: "Require confirmation for GCP compute instance deletion",
|
|
352
|
+
pattern: {
|
|
353
|
+
pattern: "gcloud compute instances delete",
|
|
354
|
+
description: "GCP compute instance deletion",
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
label: "gcloud iam",
|
|
359
|
+
description: "Require confirmation for GCP IAM changes",
|
|
360
|
+
pattern: {
|
|
361
|
+
pattern: "gcloud iam",
|
|
362
|
+
description: "GCP IAM permission changes",
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
label: "gcloud sql delete",
|
|
367
|
+
description: "Require confirmation for GCP SQL instance deletion",
|
|
368
|
+
pattern: {
|
|
369
|
+
pattern: "gcloud sql instances delete",
|
|
370
|
+
description: "GCP Cloud SQL instance deletion",
|
|
371
|
+
},
|
|
372
|
+
},
|
|
261
373
|
];
|
|
262
374
|
|
|
263
375
|
function toKebabCase(input: string): string {
|
|
@@ -845,7 +957,7 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
|
|
|
845
957
|
buildSections: (
|
|
846
958
|
tabConfig: GuardrailsConfig | null,
|
|
847
959
|
_resolved: ResolvedConfig,
|
|
848
|
-
{ setDraft, theme },
|
|
960
|
+
{ setDraft, theme, scope },
|
|
849
961
|
): SettingsSection[] => {
|
|
850
962
|
const settingsTheme = theme;
|
|
851
963
|
let scopedConfig = structuredClone(tabConfig ?? {}) as GuardrailsConfig;
|
|
@@ -1005,9 +1117,11 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
|
|
|
1005
1117
|
return scopedConfig.permissionGate?.explainTimeout ?? null;
|
|
1006
1118
|
}
|
|
1007
1119
|
|
|
1008
|
-
const featureItems = (
|
|
1120
|
+
const featureItems: SettingItem[] = (
|
|
1121
|
+
Object.keys(FEATURE_UI) as FeatureKey[]
|
|
1122
|
+
)
|
|
1009
1123
|
.filter((key) => key !== "policies")
|
|
1010
|
-
.map((key) => {
|
|
1124
|
+
.map((key): SettingItem => {
|
|
1011
1125
|
const scopedValue = scopedConfig.features?.[key];
|
|
1012
1126
|
return {
|
|
1013
1127
|
id: `features.${key}`,
|
|
@@ -1023,6 +1137,18 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
|
|
|
1023
1137
|
};
|
|
1024
1138
|
});
|
|
1025
1139
|
|
|
1140
|
+
if (scope === "global") {
|
|
1141
|
+
featureItems.push({
|
|
1142
|
+
id: "onboarding.run",
|
|
1143
|
+
label: "Onboarding status",
|
|
1144
|
+
description: "Use /guardrails:onboarding to run onboarding",
|
|
1145
|
+
currentValue:
|
|
1146
|
+
scopedConfig.onboarding?.completed === true
|
|
1147
|
+
? "completed"
|
|
1148
|
+
: "pending",
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1026
1152
|
const policyRules = getPolicyRules();
|
|
1027
1153
|
|
|
1028
1154
|
const openPolicyEditor = (
|
package/src/config.ts
CHANGED
|
@@ -56,6 +56,13 @@ export interface PolicyRule {
|
|
|
56
56
|
export interface GuardrailsConfig {
|
|
57
57
|
version?: string;
|
|
58
58
|
enabled?: boolean;
|
|
59
|
+
/** Deprecated-defaults bridge: when true, applies built-in policy defaults. */
|
|
60
|
+
applyBuiltinDefaults?: boolean;
|
|
61
|
+
onboarding?: {
|
|
62
|
+
completed?: boolean;
|
|
63
|
+
completedAt?: string;
|
|
64
|
+
version?: string;
|
|
65
|
+
};
|
|
59
66
|
features?: {
|
|
60
67
|
policies?: boolean;
|
|
61
68
|
permissionGate?: boolean;
|
|
@@ -90,6 +97,7 @@ export interface GuardrailsConfig {
|
|
|
90
97
|
export interface ResolvedConfig {
|
|
91
98
|
version: string;
|
|
92
99
|
enabled: boolean;
|
|
100
|
+
applyBuiltinDefaults: boolean;
|
|
93
101
|
features: {
|
|
94
102
|
policies: boolean;
|
|
95
103
|
permissionGate: boolean;
|
|
@@ -187,6 +195,7 @@ const migrations: Migration<GuardrailsConfig>[] = [
|
|
|
187
195
|
const DEFAULT_CONFIG: ResolvedConfig = {
|
|
188
196
|
version: CURRENT_VERSION,
|
|
189
197
|
enabled: true,
|
|
198
|
+
applyBuiltinDefaults: true,
|
|
190
199
|
features: {
|
|
191
200
|
policies: true,
|
|
192
201
|
permissionGate: true,
|
|
@@ -217,6 +226,51 @@ const DEFAULT_CONFIG: ResolvedConfig = {
|
|
|
217
226
|
"Accessing {file} is not allowed. This file contains secrets. " +
|
|
218
227
|
"Explain to the user why you want to access this file, and if changes are needed ask the user to make them.",
|
|
219
228
|
},
|
|
229
|
+
{
|
|
230
|
+
id: "home-ssh",
|
|
231
|
+
description: "SSH directory and keys",
|
|
232
|
+
enabled: false,
|
|
233
|
+
patterns: [
|
|
234
|
+
{ pattern: "~/.ssh/**" },
|
|
235
|
+
{ pattern: "~/.ssh/*_rsa" },
|
|
236
|
+
{ pattern: "~/.ssh/*_ed25519" },
|
|
237
|
+
{ pattern: "~/.ssh/*.pem" },
|
|
238
|
+
],
|
|
239
|
+
allowedPatterns: [{ pattern: "~/.ssh/*.pub" }],
|
|
240
|
+
protection: "noAccess",
|
|
241
|
+
onlyIfExists: true,
|
|
242
|
+
blockMessage:
|
|
243
|
+
"Accessing {file} is not allowed. This file is part of your SSH configuration and may contain private keys or sensitive host information.",
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
id: "home-config",
|
|
247
|
+
description: "Sensitive user configuration directories",
|
|
248
|
+
enabled: false,
|
|
249
|
+
patterns: [
|
|
250
|
+
{ pattern: "~/.config/gh/**" },
|
|
251
|
+
{ pattern: "~/.config/gcloud/**" },
|
|
252
|
+
{ pattern: "~/.config/op/**" },
|
|
253
|
+
{ pattern: "~/.config/sops/**" },
|
|
254
|
+
],
|
|
255
|
+
protection: "noAccess",
|
|
256
|
+
onlyIfExists: true,
|
|
257
|
+
blockMessage:
|
|
258
|
+
"Accessing {file} is not allowed. This file is in a sensitive user configuration directory and may contain credentials or tokens.",
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
id: "home-gpg",
|
|
262
|
+
description: "GPG keys and configuration",
|
|
263
|
+
enabled: false,
|
|
264
|
+
patterns: [
|
|
265
|
+
{ pattern: "~/.gnupg/**" },
|
|
266
|
+
{ pattern: "~/*.gpg" },
|
|
267
|
+
{ pattern: "~/.gpg-agent.conf" },
|
|
268
|
+
],
|
|
269
|
+
protection: "noAccess",
|
|
270
|
+
onlyIfExists: true,
|
|
271
|
+
blockMessage:
|
|
272
|
+
"Accessing {file} is not allowed. This file is part of your GPG configuration and may contain private keys or trust settings.",
|
|
273
|
+
},
|
|
220
274
|
],
|
|
221
275
|
},
|
|
222
276
|
permissionGate: {
|
|
@@ -250,8 +304,10 @@ export const configLoader = new ConfigLoader<GuardrailsConfig, ResolvedConfig>(
|
|
|
250
304
|
afterMerge: (resolved, global, local, memory) => {
|
|
251
305
|
const ruleMap = new Map<string, PolicyRule>();
|
|
252
306
|
|
|
253
|
-
|
|
254
|
-
|
|
307
|
+
if (resolved.applyBuiltinDefaults) {
|
|
308
|
+
for (const rule of DEFAULT_CONFIG.policies.rules) {
|
|
309
|
+
ruleMap.set(rule.id, rule);
|
|
310
|
+
}
|
|
255
311
|
}
|
|
256
312
|
if (global?.policies?.rules) {
|
|
257
313
|
for (const rule of global.policies.rules) {
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
matchesKey,
|
|
15
15
|
Spacer,
|
|
16
16
|
Text,
|
|
17
|
+
truncateToWidth,
|
|
18
|
+
visibleWidth,
|
|
17
19
|
wrapTextWithAnsi,
|
|
18
20
|
} from "@mariozechner/pi-tui";
|
|
19
21
|
import type { DangerousPattern, ResolvedConfig } from "../config";
|
|
@@ -96,6 +98,250 @@ interface CommandExplanation {
|
|
|
96
98
|
provider: string;
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
interface MinimalTheme {
|
|
102
|
+
fg(color: string, text: string): string;
|
|
103
|
+
bg(color: string, text: string): string;
|
|
104
|
+
bold(text: string): string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface NumberedWrappedRow {
|
|
108
|
+
logicalLineNumber: number;
|
|
109
|
+
rendered: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface CommandViewportState {
|
|
113
|
+
maxScrollOffset: number;
|
|
114
|
+
pinnedRows: NumberedWrappedRow[];
|
|
115
|
+
scrollWindowLines: number;
|
|
116
|
+
scrollableRows: NumberedWrappedRow[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const COMMAND_VIEWPORT_LINES = 12;
|
|
120
|
+
const BUILTIN_KEYWORD_PATTERNS = new Set([
|
|
121
|
+
"rm -rf",
|
|
122
|
+
"sudo",
|
|
123
|
+
"dd if=",
|
|
124
|
+
"mkfs.",
|
|
125
|
+
"chmod -R 777",
|
|
126
|
+
"chown -R",
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
function buildNumberedWrappedLines(
|
|
130
|
+
command: string,
|
|
131
|
+
contentWidth: number,
|
|
132
|
+
theme: Pick<MinimalTheme, "fg">,
|
|
133
|
+
): NumberedWrappedRow[] {
|
|
134
|
+
const logicalLines = command.split("\n");
|
|
135
|
+
const lineNumberWidth = Math.max(2, String(logicalLines.length).length);
|
|
136
|
+
const prefixSpacing = 1;
|
|
137
|
+
const textWidth = Math.max(1, contentWidth - lineNumberWidth - prefixSpacing);
|
|
138
|
+
const rows: Array<{ logicalLineNumber: number; rendered: string }> = [];
|
|
139
|
+
|
|
140
|
+
for (const [index, logicalLine] of logicalLines.entries()) {
|
|
141
|
+
const lineNumber = index + 1;
|
|
142
|
+
const wrapped = wrapTextWithAnsi(theme.fg("text", logicalLine), textWidth);
|
|
143
|
+
const wrappedLines = wrapped.length > 0 ? wrapped : [""];
|
|
144
|
+
const prefix = theme.fg(
|
|
145
|
+
"dim",
|
|
146
|
+
String(lineNumber).padStart(lineNumberWidth),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
for (const line of wrappedLines) {
|
|
150
|
+
rows.push({
|
|
151
|
+
logicalLineNumber: lineNumber,
|
|
152
|
+
rendered: `${prefix} ${line}`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return rows;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getCommandViewportState(
|
|
161
|
+
command: string,
|
|
162
|
+
contentWidth: number,
|
|
163
|
+
theme: Pick<MinimalTheme, "fg">,
|
|
164
|
+
): CommandViewportState {
|
|
165
|
+
const numberedRows = buildNumberedWrappedLines(command, contentWidth, theme);
|
|
166
|
+
const pinnedRows = numberedRows.filter((row) => row.logicalLineNumber === 1);
|
|
167
|
+
const scrollableRows = numberedRows.filter(
|
|
168
|
+
(row) => row.logicalLineNumber !== 1,
|
|
169
|
+
);
|
|
170
|
+
const scrollWindowLines = Math.max(
|
|
171
|
+
0,
|
|
172
|
+
COMMAND_VIEWPORT_LINES - pinnedRows.length,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
maxScrollOffset: Math.max(0, scrollableRows.length - scrollWindowLines),
|
|
177
|
+
pinnedRows,
|
|
178
|
+
scrollWindowLines,
|
|
179
|
+
scrollableRows,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildRightAlignedBorder(
|
|
184
|
+
width: number,
|
|
185
|
+
themeLine: (s: string) => string,
|
|
186
|
+
label: string,
|
|
187
|
+
): string {
|
|
188
|
+
const safeWidth = Math.max(1, width);
|
|
189
|
+
const truncatedLabel = truncateToWidth(label, safeWidth);
|
|
190
|
+
const remaining = safeWidth - visibleWidth(truncatedLabel);
|
|
191
|
+
return themeLine("─".repeat(Math.max(0, remaining)) + truncatedLabel);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function createPermissionGateConfirmComponent(
|
|
195
|
+
command: string,
|
|
196
|
+
description: string,
|
|
197
|
+
explanation: CommandExplanation | null,
|
|
198
|
+
) {
|
|
199
|
+
return (
|
|
200
|
+
tui: { terminal: { rows: number; columns: number }; requestRender(): void },
|
|
201
|
+
theme: MinimalTheme,
|
|
202
|
+
_kb: unknown,
|
|
203
|
+
done: (result: "allow" | "allow-session" | "deny") => void,
|
|
204
|
+
) => {
|
|
205
|
+
const container = new Container();
|
|
206
|
+
const redBorder = (s: string) => theme.fg("error", s);
|
|
207
|
+
const dimBorder = (s: string) => theme.fg("dim", s);
|
|
208
|
+
let scrollOffset = 0;
|
|
209
|
+
|
|
210
|
+
if (explanation) {
|
|
211
|
+
const explanationBox = new Box(1, 1, (s: string) =>
|
|
212
|
+
theme.bg("customMessageBg", s),
|
|
213
|
+
);
|
|
214
|
+
explanationBox.addChild(
|
|
215
|
+
new Text(
|
|
216
|
+
theme.fg(
|
|
217
|
+
"accent",
|
|
218
|
+
theme.bold(
|
|
219
|
+
`Model explanation (${explanation.modelName} / ${explanation.modelId} / ${explanation.provider})`,
|
|
220
|
+
),
|
|
221
|
+
),
|
|
222
|
+
0,
|
|
223
|
+
0,
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
explanationBox.addChild(new Spacer(1));
|
|
227
|
+
explanationBox.addChild(
|
|
228
|
+
new Markdown(explanation.text, 0, 0, getMarkdownTheme(), {
|
|
229
|
+
color: (s: string) => theme.fg("text", s),
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
container.addChild(explanationBox);
|
|
233
|
+
}
|
|
234
|
+
container.addChild(new DynamicBorder(redBorder));
|
|
235
|
+
container.addChild(
|
|
236
|
+
new Text(
|
|
237
|
+
theme.fg("error", theme.bold("Dangerous Command Detected")),
|
|
238
|
+
1,
|
|
239
|
+
0,
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
container.addChild(new Spacer(1));
|
|
243
|
+
container.addChild(
|
|
244
|
+
new Text(
|
|
245
|
+
theme.fg("warning", `This command contains ${description}:`),
|
|
246
|
+
1,
|
|
247
|
+
0,
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
container.addChild(new Spacer(1));
|
|
251
|
+
const commandTopBorder = new Text("", 0, 0);
|
|
252
|
+
container.addChild(commandTopBorder);
|
|
253
|
+
const commandText = new Text("", 1, 0);
|
|
254
|
+
container.addChild(commandText);
|
|
255
|
+
const commandBottomBorder = new Text("", 0, 0);
|
|
256
|
+
container.addChild(commandBottomBorder);
|
|
257
|
+
container.addChild(new Spacer(1));
|
|
258
|
+
container.addChild(new Text(theme.fg("text", "Allow execution?"), 1, 0));
|
|
259
|
+
container.addChild(new Spacer(1));
|
|
260
|
+
container.addChild(
|
|
261
|
+
new Text(
|
|
262
|
+
theme.fg(
|
|
263
|
+
"dim",
|
|
264
|
+
"↑/↓ or j/k: scroll • y/enter: allow • a: session • n/esc: deny",
|
|
265
|
+
),
|
|
266
|
+
1,
|
|
267
|
+
0,
|
|
268
|
+
),
|
|
269
|
+
);
|
|
270
|
+
container.addChild(new DynamicBorder(redBorder));
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
render: (width: number) => {
|
|
274
|
+
const contentWidth = Math.max(1, width - 4);
|
|
275
|
+
const {
|
|
276
|
+
maxScrollOffset,
|
|
277
|
+
pinnedRows,
|
|
278
|
+
scrollWindowLines,
|
|
279
|
+
scrollableRows,
|
|
280
|
+
} = getCommandViewportState(command, contentWidth, theme);
|
|
281
|
+
scrollOffset = Math.max(0, Math.min(scrollOffset, maxScrollOffset));
|
|
282
|
+
|
|
283
|
+
const visibleScrollableRows = scrollableRows.slice(
|
|
284
|
+
scrollOffset,
|
|
285
|
+
scrollOffset + scrollWindowLines,
|
|
286
|
+
);
|
|
287
|
+
const visibleRows = [...pinnedRows, ...visibleScrollableRows];
|
|
288
|
+
const linesBelow = Math.max(
|
|
289
|
+
0,
|
|
290
|
+
scrollableRows.length - (scrollOffset + visibleScrollableRows.length),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
commandTopBorder.setText(
|
|
294
|
+
buildRightAlignedBorder(
|
|
295
|
+
width,
|
|
296
|
+
dimBorder,
|
|
297
|
+
scrollOffset > 0 ? `↑ ${scrollOffset} more` : "",
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
commandText.setText(visibleRows.map((row) => row.rendered).join("\n"));
|
|
301
|
+
commandBottomBorder.setText(
|
|
302
|
+
buildRightAlignedBorder(
|
|
303
|
+
width,
|
|
304
|
+
dimBorder,
|
|
305
|
+
linesBelow > 0 ? `↓ ${linesBelow} more` : "",
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
return container.render(width);
|
|
309
|
+
},
|
|
310
|
+
invalidate: () => container.invalidate(),
|
|
311
|
+
handleInput: (data: string) => {
|
|
312
|
+
const contentWidth = Math.max(1, tui.terminal.columns - 4);
|
|
313
|
+
const { maxScrollOffset } = getCommandViewportState(
|
|
314
|
+
command,
|
|
315
|
+
contentWidth,
|
|
316
|
+
theme,
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
320
|
+
scrollOffset = Math.max(0, scrollOffset - 1);
|
|
321
|
+
tui.requestRender();
|
|
322
|
+
} else if (matchesKey(data, Key.down) || data === "j") {
|
|
323
|
+
scrollOffset = Math.min(maxScrollOffset, scrollOffset + 1);
|
|
324
|
+
tui.requestRender();
|
|
325
|
+
} else if (
|
|
326
|
+
matchesKey(data, Key.enter) ||
|
|
327
|
+
data === "y" ||
|
|
328
|
+
data === "Y"
|
|
329
|
+
) {
|
|
330
|
+
done("allow");
|
|
331
|
+
} else if (data === "a" || data === "A") {
|
|
332
|
+
done("allow-session");
|
|
333
|
+
} else if (
|
|
334
|
+
matchesKey(data, Key.escape) ||
|
|
335
|
+
data === "n" ||
|
|
336
|
+
data === "N"
|
|
337
|
+
) {
|
|
338
|
+
done("deny");
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
99
345
|
async function explainCommand(
|
|
100
346
|
command: string,
|
|
101
347
|
modelSpec: string,
|
|
@@ -217,22 +463,13 @@ function findDangerousMatch(
|
|
|
217
463
|
|
|
218
464
|
// When structural parsing succeeds, skip raw substring fallback for built-in
|
|
219
465
|
// keyword patterns to avoid false positives in quoted args/messages.
|
|
220
|
-
const builtInKeywordPatterns = new Set([
|
|
221
|
-
"rm -rf",
|
|
222
|
-
"sudo",
|
|
223
|
-
"dd if=",
|
|
224
|
-
"mkfs.",
|
|
225
|
-
"chmod -R 777",
|
|
226
|
-
"chown -R",
|
|
227
|
-
]);
|
|
228
|
-
|
|
229
466
|
for (const cp of compiledPatterns) {
|
|
230
467
|
const src = cp.source as DangerousPattern;
|
|
231
468
|
if (
|
|
232
469
|
useBuiltinMatchers &&
|
|
233
470
|
parsedSuccessfully &&
|
|
234
471
|
!src.regex &&
|
|
235
|
-
|
|
472
|
+
BUILTIN_KEYWORD_PATTERNS.has(src.pattern)
|
|
236
473
|
) {
|
|
237
474
|
continue;
|
|
238
475
|
}
|
|
@@ -344,101 +581,7 @@ export function setupPermissionGateHook(
|
|
|
344
581
|
type ConfirmResult = "allow" | "allow-session" | "deny";
|
|
345
582
|
|
|
346
583
|
const result = await ctx.ui.custom<ConfirmResult>(
|
|
347
|
-
(
|
|
348
|
-
const container = new Container();
|
|
349
|
-
const redBorder = (s: string) => theme.fg("error", s);
|
|
350
|
-
|
|
351
|
-
if (explanation) {
|
|
352
|
-
const explanationBox = new Box(1, 1, (s: string) =>
|
|
353
|
-
theme.bg("customMessageBg", s),
|
|
354
|
-
);
|
|
355
|
-
explanationBox.addChild(
|
|
356
|
-
new Text(
|
|
357
|
-
theme.fg(
|
|
358
|
-
"accent",
|
|
359
|
-
theme.bold(
|
|
360
|
-
`Model explanation (${explanation.modelName} / ${explanation.modelId} / ${explanation.provider})`,
|
|
361
|
-
),
|
|
362
|
-
),
|
|
363
|
-
0,
|
|
364
|
-
0,
|
|
365
|
-
),
|
|
366
|
-
);
|
|
367
|
-
explanationBox.addChild(new Spacer(1));
|
|
368
|
-
explanationBox.addChild(
|
|
369
|
-
new Markdown(explanation.text, 0, 0, getMarkdownTheme(), {
|
|
370
|
-
color: (s: string) => theme.fg("text", s),
|
|
371
|
-
}),
|
|
372
|
-
);
|
|
373
|
-
container.addChild(explanationBox);
|
|
374
|
-
}
|
|
375
|
-
container.addChild(new DynamicBorder(redBorder));
|
|
376
|
-
container.addChild(
|
|
377
|
-
new Text(
|
|
378
|
-
theme.fg("error", theme.bold("Dangerous Command Detected")),
|
|
379
|
-
1,
|
|
380
|
-
0,
|
|
381
|
-
),
|
|
382
|
-
);
|
|
383
|
-
container.addChild(new Spacer(1));
|
|
384
|
-
container.addChild(
|
|
385
|
-
new Text(
|
|
386
|
-
theme.fg("warning", `This command contains ${description}:`),
|
|
387
|
-
1,
|
|
388
|
-
0,
|
|
389
|
-
),
|
|
390
|
-
);
|
|
391
|
-
container.addChild(new Spacer(1));
|
|
392
|
-
container.addChild(
|
|
393
|
-
new DynamicBorder((s: string) => theme.fg("muted", s)),
|
|
394
|
-
);
|
|
395
|
-
const commandText = new Text("", 1, 0);
|
|
396
|
-
container.addChild(commandText);
|
|
397
|
-
container.addChild(
|
|
398
|
-
new DynamicBorder((s: string) => theme.fg("muted", s)),
|
|
399
|
-
);
|
|
400
|
-
container.addChild(new Spacer(1));
|
|
401
|
-
container.addChild(
|
|
402
|
-
new Text(theme.fg("text", "Allow execution?"), 1, 0),
|
|
403
|
-
);
|
|
404
|
-
container.addChild(new Spacer(1));
|
|
405
|
-
container.addChild(
|
|
406
|
-
new Text(
|
|
407
|
-
theme.fg(
|
|
408
|
-
"dim",
|
|
409
|
-
"y/enter: allow • a: allow for session • n/esc: deny",
|
|
410
|
-
),
|
|
411
|
-
1,
|
|
412
|
-
0,
|
|
413
|
-
),
|
|
414
|
-
);
|
|
415
|
-
container.addChild(new DynamicBorder(redBorder));
|
|
416
|
-
|
|
417
|
-
return {
|
|
418
|
-
render: (width: number) => {
|
|
419
|
-
const wrappedCommand = wrapTextWithAnsi(
|
|
420
|
-
theme.fg("text", command),
|
|
421
|
-
width - 4,
|
|
422
|
-
).join("\n");
|
|
423
|
-
commandText.setText(wrappedCommand);
|
|
424
|
-
return container.render(width);
|
|
425
|
-
},
|
|
426
|
-
invalidate: () => container.invalidate(),
|
|
427
|
-
handleInput: (data: string) => {
|
|
428
|
-
if (matchesKey(data, Key.enter) || data === "y" || data === "Y") {
|
|
429
|
-
done("allow");
|
|
430
|
-
} else if (data === "a" || data === "A") {
|
|
431
|
-
done("allow-session");
|
|
432
|
-
} else if (
|
|
433
|
-
matchesKey(data, Key.escape) ||
|
|
434
|
-
data === "n" ||
|
|
435
|
-
data === "N"
|
|
436
|
-
) {
|
|
437
|
-
done("deny");
|
|
438
|
-
}
|
|
439
|
-
},
|
|
440
|
-
};
|
|
441
|
-
},
|
|
584
|
+
createPermissionGateConfirmComponent(command, description, explanation),
|
|
442
585
|
);
|
|
443
586
|
|
|
444
587
|
if (result === "allow-session") {
|
package/src/hooks/policies.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
compileFilePatterns,
|
|
11
11
|
normalizeFilePath,
|
|
12
12
|
} from "../utils/matching";
|
|
13
|
+
import { expandHomePath } from "../utils/path";
|
|
13
14
|
import { walkCommands, wordToString } from "../utils/shell-utils";
|
|
14
15
|
import { pendingWarnings } from "../utils/warnings";
|
|
15
16
|
|
|
@@ -37,9 +38,9 @@ interface CompiledRule {
|
|
|
37
38
|
enabled: boolean;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
async function fileExists(
|
|
41
|
+
async function fileExists(filePath: string, cwd: string): Promise<boolean> {
|
|
41
42
|
try {
|
|
42
|
-
await stat(
|
|
43
|
+
await stat(resolvePolicyPath(filePath, cwd));
|
|
43
44
|
return true;
|
|
44
45
|
} catch {
|
|
45
46
|
return false;
|
|
@@ -118,8 +119,19 @@ function maybePathLike(token: string): boolean {
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
function normalizeTargetForPolicy(filePath: string, cwd: string): string {
|
|
121
|
-
|
|
122
|
+
if (filePath === "~" || filePath.startsWith("~/")) {
|
|
123
|
+
return normalizeFilePath(filePath);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const expanded = expandHomePath(filePath);
|
|
127
|
+
const absolute = resolve(cwd, expanded);
|
|
122
128
|
const rel = relative(cwd, absolute);
|
|
129
|
+
const normalizedHome = normalizeFilePath(expandHomePath("~"));
|
|
130
|
+
const normalizedAbsolute = normalizeFilePath(absolute);
|
|
131
|
+
|
|
132
|
+
if (normalizedAbsolute.startsWith(`${normalizedHome}/`)) {
|
|
133
|
+
return normalizeFilePath(`~/${relative(expandHomePath("~"), absolute)}`);
|
|
134
|
+
}
|
|
123
135
|
|
|
124
136
|
const candidate =
|
|
125
137
|
rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : absolute;
|
|
@@ -127,6 +139,10 @@ function normalizeTargetForPolicy(filePath: string, cwd: string): string {
|
|
|
127
139
|
return normalizeFilePath(candidate);
|
|
128
140
|
}
|
|
129
141
|
|
|
142
|
+
function resolvePolicyPath(filePath: string, cwd: string): string {
|
|
143
|
+
return resolve(cwd, expandHomePath(filePath));
|
|
144
|
+
}
|
|
145
|
+
|
|
130
146
|
function matchesAnyPolicyPattern(
|
|
131
147
|
filePath: string,
|
|
132
148
|
rules: CompiledRule[],
|
|
@@ -236,7 +252,7 @@ async function getEffectiveProtection(
|
|
|
236
252
|
);
|
|
237
253
|
if (allowed) continue;
|
|
238
254
|
|
|
239
|
-
if (rule.onlyIfExists && !(await fileExists(
|
|
255
|
+
if (rule.onlyIfExists && !(await fileExists(filePath, cwd))) continue;
|
|
240
256
|
|
|
241
257
|
const rank = protectionRank(rule.protection);
|
|
242
258
|
if (!bestMatch || rank > bestMatch.rank) {
|