pi-safety-guards 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -0
- package/README.zh-CN.md +114 -0
- package/SKILL.md +41 -0
- package/config.example.json +6 -0
- package/examples/custom-rules.json +18 -0
- package/index.ts +167 -0
- package/locales/i18n.json +110 -0
- package/package.json +75 -0
- package/src/bash-directory-scope-utils.ts +590 -0
- package/src/config.ts +151 -0
- package/src/engine.ts +168 -0
- package/src/i18n.ts +3 -0
- package/src/presets.ts +16 -0
- package/src/shell-command-utils.ts +369 -0
- package/src/types.ts +32 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { parse, type Command, type Word } from "unbash";
|
|
2
|
+
|
|
3
|
+
export type ShellWord = Word;
|
|
4
|
+
|
|
5
|
+
const MAX_NESTED_SHELL_DEPTH = 8;
|
|
6
|
+
const OPTION_AND_VALUE_WIDTH = 2;
|
|
7
|
+
const COMMAND_WRAPPER_NAME = "command";
|
|
8
|
+
const ENV_WRAPPER_NAME = "env";
|
|
9
|
+
const EVAL_COMMAND_NAME = "eval";
|
|
10
|
+
const OPTION_TERMINATOR = "--";
|
|
11
|
+
const STANDARD_INPUT_SCRIPT = "-";
|
|
12
|
+
const SHORT_OPTION_PREFIX = "-";
|
|
13
|
+
const ENABLE_OPTION_PREFIX = "+";
|
|
14
|
+
const SHELL_COMMAND_OPTION = "-c";
|
|
15
|
+
const SHELL_COMMAND_FLAG = "c";
|
|
16
|
+
const SHELL_LAUNCHERS = new Set(["bash", "dash", "ksh", "sh", "zsh"]);
|
|
17
|
+
const COMMAND_QUERY_OPTIONS = new Set(["-v", "-V", "--help"]);
|
|
18
|
+
const COMMAND_QUERY_SHORT_OPTIONS = /^-[pvV]*[vV][pvV]*$/;
|
|
19
|
+
const ENV_SPLIT_OPTIONS = new Set(["-S", "--split-string"]);
|
|
20
|
+
const SHELL_OPTIONS_WITH_VALUE = new Set([
|
|
21
|
+
"-O", "+O", "-o", "+o", "--init-file", "--rcfile",
|
|
22
|
+
]);
|
|
23
|
+
const EMPTY_OPTIONS = new Set<string>();
|
|
24
|
+
|
|
25
|
+
interface WrapperConfig {
|
|
26
|
+
optionsWithValue: ReadonlySet<string>;
|
|
27
|
+
skipAssignments: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const WRAPPER_CONFIGS: Readonly<Record<string, WrapperConfig>> = {
|
|
31
|
+
sudo: {
|
|
32
|
+
optionsWithValue: new Set([
|
|
33
|
+
"-C", "--close-from", "-D", "--chdir", "-g", "--group", "-h", "--host",
|
|
34
|
+
"-p", "--prompt", "-R", "--chroot", "-T", "--command-timeout", "-u", "--user",
|
|
35
|
+
]),
|
|
36
|
+
skipAssignments: false,
|
|
37
|
+
},
|
|
38
|
+
env: {
|
|
39
|
+
optionsWithValue: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
|
|
40
|
+
skipAssignments: true,
|
|
41
|
+
},
|
|
42
|
+
command: { optionsWithValue: EMPTY_OPTIONS, skipAssignments: false },
|
|
43
|
+
builtin: { optionsWithValue: EMPTY_OPTIONS, skipAssignments: false },
|
|
44
|
+
nohup: { optionsWithValue: EMPTY_OPTIONS, skipAssignments: false },
|
|
45
|
+
exec: { optionsWithValue: new Set(["-a"]), skipAssignments: false },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export interface ShellWrapperOption {
|
|
49
|
+
name: string;
|
|
50
|
+
value?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ShellWrapperInvocation {
|
|
54
|
+
executable: Word;
|
|
55
|
+
name: string;
|
|
56
|
+
args: readonly Word[];
|
|
57
|
+
options: readonly ShellWrapperOption[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ShellCommandInvocation {
|
|
61
|
+
executable: Word;
|
|
62
|
+
name: string;
|
|
63
|
+
args: readonly Word[];
|
|
64
|
+
wrappers: readonly ShellWrapperInvocation[];
|
|
65
|
+
/** shell -c 与 eval 会把参数继续解释为 Shell 源码,而不是普通文件参数。 */
|
|
66
|
+
nestedSource?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ShellParseIssue {
|
|
70
|
+
message: string;
|
|
71
|
+
pos: number;
|
|
72
|
+
source: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ShellCommandAnalysis {
|
|
76
|
+
commands: readonly ShellCommandInvocation[];
|
|
77
|
+
wrappers: readonly ShellWrapperInvocation[];
|
|
78
|
+
/** 保留 AST 节点供路径、重定向和文件测试等策略检查,各业务策略不重复解析。 */
|
|
79
|
+
nodes: readonly Record<string, unknown>[];
|
|
80
|
+
errors: readonly ShellParseIssue[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface MutableAnalysis {
|
|
84
|
+
commands: ShellCommandInvocation[];
|
|
85
|
+
wrappers: ShellWrapperInvocation[];
|
|
86
|
+
nodes: Record<string, unknown>[];
|
|
87
|
+
errors: ShellParseIssue[];
|
|
88
|
+
visited: WeakSet<object>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface CommandResolution {
|
|
92
|
+
invocation?: ShellCommandInvocation;
|
|
93
|
+
wrappers: ShellWrapperInvocation[];
|
|
94
|
+
nestedSource?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface ConsumedWrapper {
|
|
98
|
+
wrapper: ShellWrapperInvocation;
|
|
99
|
+
nextIndex: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface VisitShellValueOptions {
|
|
103
|
+
value: unknown;
|
|
104
|
+
source: string;
|
|
105
|
+
state: MutableAnalysis;
|
|
106
|
+
depth: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface ConsumeWrapperOptions {
|
|
110
|
+
words: Word[];
|
|
111
|
+
wrapperIndex: number;
|
|
112
|
+
name: string;
|
|
113
|
+
config: WrapperConfig;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let cachedSource: string | undefined;
|
|
117
|
+
let cachedAnalysis: ShellCommandAnalysis | undefined;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 将 Bash 源码解析为真实命令调用、wrapper 和完整 AST 节点。
|
|
121
|
+
* 单条 tool_call 会被多个安全策略检查,单项缓存避免对同一命令重复解析。
|
|
122
|
+
*/
|
|
123
|
+
export function analyzeShellCommand(source: string): ShellCommandAnalysis {
|
|
124
|
+
if (source === cachedSource && cachedAnalysis) return cachedAnalysis;
|
|
125
|
+
|
|
126
|
+
const state: MutableAnalysis = {
|
|
127
|
+
commands: [],
|
|
128
|
+
wrappers: [],
|
|
129
|
+
nodes: [],
|
|
130
|
+
errors: [],
|
|
131
|
+
visited: new WeakSet<object>(),
|
|
132
|
+
};
|
|
133
|
+
analyzeSource(source, state, 0);
|
|
134
|
+
|
|
135
|
+
const analysis: ShellCommandAnalysis = {
|
|
136
|
+
commands: state.commands,
|
|
137
|
+
wrappers: state.wrappers,
|
|
138
|
+
nodes: state.nodes,
|
|
139
|
+
errors: state.errors,
|
|
140
|
+
};
|
|
141
|
+
cachedSource = source;
|
|
142
|
+
cachedAnalysis = analysis;
|
|
143
|
+
return analysis;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 递归解析 shell -c、eval 与 env -S 中的字面量子命令。 */
|
|
147
|
+
function analyzeSource(source: string, state: MutableAnalysis, depth: number): void {
|
|
148
|
+
if (depth >= MAX_NESTED_SHELL_DEPTH) {
|
|
149
|
+
state.errors.push({
|
|
150
|
+
message: `Shell nesting exceeds ${MAX_NESTED_SHELL_DEPTH} levels`,
|
|
151
|
+
pos: 0,
|
|
152
|
+
source,
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let script: unknown;
|
|
158
|
+
try {
|
|
159
|
+
script = parse(source);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
state.errors.push({
|
|
162
|
+
message: error instanceof Error ? error.message : String(error),
|
|
163
|
+
pos: 0,
|
|
164
|
+
source,
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
visitShellValue({ value: script, source, state, depth });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 遍历惰性 word parts、命令替换、进程替换及所有普通 AST 字段。 */
|
|
173
|
+
function visitShellValue(options: VisitShellValueOptions): void {
|
|
174
|
+
const { value, source, state, depth } = options;
|
|
175
|
+
if (Array.isArray(value)) {
|
|
176
|
+
for (const item of value) visitShellValue({ value: item, source, state, depth });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!value || typeof value !== "object" || state.visited.has(value)) return;
|
|
180
|
+
state.visited.add(value);
|
|
181
|
+
|
|
182
|
+
const node = value as Record<string, unknown>;
|
|
183
|
+
const nodeSource = node.type === "Script" && typeof node.source === "string"
|
|
184
|
+
? node.source
|
|
185
|
+
: source;
|
|
186
|
+
state.nodes.push(node);
|
|
187
|
+
|
|
188
|
+
if (node.type === "Script" && Array.isArray(node.errors)) {
|
|
189
|
+
for (const error of node.errors) {
|
|
190
|
+
if (!isRecord(error) || typeof error.message !== "string") continue;
|
|
191
|
+
state.errors.push({
|
|
192
|
+
message: error.message,
|
|
193
|
+
pos: typeof error.pos === "number" ? error.pos : 0,
|
|
194
|
+
source: nodeSource,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (node.type === "Command") {
|
|
200
|
+
const resolution = resolveCommand(node as unknown as Command);
|
|
201
|
+
state.wrappers.push(...resolution.wrappers);
|
|
202
|
+
if (resolution.invocation) state.commands.push(resolution.invocation);
|
|
203
|
+
if (resolution.nestedSource) analyzeSource(resolution.nestedSource, state, depth + 1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Word.parts 与 indexParts 是惰性属性,不会出现在 Object.values 中。
|
|
207
|
+
if ("parts" in node) {
|
|
208
|
+
visitShellValue({ value: node.parts, source: nodeSource, state, depth });
|
|
209
|
+
}
|
|
210
|
+
if ("indexParts" in node) {
|
|
211
|
+
visitShellValue({ value: node.indexParts, source: nodeSource, state, depth });
|
|
212
|
+
}
|
|
213
|
+
for (const child of Object.values(node)) {
|
|
214
|
+
visitShellValue({ value: child, source: nodeSource, state, depth });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 跳过 sudo/env/command 等 wrapper,返回真正执行的程序和需递归解析的源码。 */
|
|
219
|
+
function resolveCommand(command: Command): CommandResolution {
|
|
220
|
+
if (!command.name) return { wrappers: [] };
|
|
221
|
+
|
|
222
|
+
const words = [command.name, ...command.suffix];
|
|
223
|
+
const wrappers: ShellWrapperInvocation[] = [];
|
|
224
|
+
let index = 0;
|
|
225
|
+
|
|
226
|
+
while (index < words.length) {
|
|
227
|
+
const executable = words[index];
|
|
228
|
+
const name = commandBasename(executable.value);
|
|
229
|
+
const config = WRAPPER_CONFIGS[name];
|
|
230
|
+
if (!config) {
|
|
231
|
+
const args = words.slice(index + 1);
|
|
232
|
+
const nestedSource = nestedCommandSource(name, args);
|
|
233
|
+
return {
|
|
234
|
+
wrappers,
|
|
235
|
+
invocation: { executable, name, args, wrappers: [...wrappers], nestedSource },
|
|
236
|
+
nestedSource,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const consumed = consumeWrapper({ words, wrapperIndex: index, name, config });
|
|
241
|
+
wrappers.push(consumed.wrapper);
|
|
242
|
+
if (name === COMMAND_WRAPPER_NAME && isCommandQuery(consumed.wrapper.options)) {
|
|
243
|
+
return { wrappers };
|
|
244
|
+
}
|
|
245
|
+
if (name === ENV_WRAPPER_NAME) {
|
|
246
|
+
const splitSource = envSplitSource(consumed.wrapper.options);
|
|
247
|
+
if (splitSource !== undefined) return { wrappers, nestedSource: splitSource };
|
|
248
|
+
}
|
|
249
|
+
index = consumed.nextIndex;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { wrappers };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 消费单个 wrapper 的选项、选项值和 env 赋值,保留规范化后的选项。 */
|
|
256
|
+
function consumeWrapper(input: ConsumeWrapperOptions): ConsumedWrapper {
|
|
257
|
+
const { words, wrapperIndex, name, config } = input;
|
|
258
|
+
const args: Word[] = [];
|
|
259
|
+
const wrapperOptions: ShellWrapperOption[] = [];
|
|
260
|
+
let index = wrapperIndex + 1;
|
|
261
|
+
|
|
262
|
+
while (index < words.length) {
|
|
263
|
+
const word = words[index];
|
|
264
|
+
const value = word.value;
|
|
265
|
+
if (config.skipAssignments && isShellAssignment(value)) {
|
|
266
|
+
args.push(word);
|
|
267
|
+
index++;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (value === "--") {
|
|
271
|
+
args.push(word);
|
|
272
|
+
index++;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
if (!value.startsWith("-") || value === "-") break;
|
|
276
|
+
|
|
277
|
+
const [optionName, inlineValue] = splitRecognizedShellOption(value, config.optionsWithValue);
|
|
278
|
+
args.push(word);
|
|
279
|
+
if (config.optionsWithValue.has(optionName) && inlineValue === undefined) {
|
|
280
|
+
const optionValue = words[index + 1];
|
|
281
|
+
if (optionValue) args.push(optionValue);
|
|
282
|
+
wrapperOptions.push({ name: optionName, value: optionValue?.value });
|
|
283
|
+
index += OPTION_AND_VALUE_WIDTH;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
wrapperOptions.push({ name: optionName, value: inlineValue });
|
|
288
|
+
index++;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
wrapper: {
|
|
293
|
+
executable: words[wrapperIndex],
|
|
294
|
+
name,
|
|
295
|
+
args,
|
|
296
|
+
options: wrapperOptions,
|
|
297
|
+
},
|
|
298
|
+
nextIndex: index,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** command -v/-V/--help 仅查询命令信息,不会执行后续参数。 */
|
|
303
|
+
function isCommandQuery(options: readonly ShellWrapperOption[]): boolean {
|
|
304
|
+
return options.some(({ name }) =>
|
|
305
|
+
COMMAND_QUERY_OPTIONS.has(name) || COMMAND_QUERY_SHORT_OPTIONS.test(name),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** env -S/--split-string 的值会被 env 拆成新的命令及参数。 */
|
|
310
|
+
function envSplitSource(options: readonly ShellWrapperOption[]): string | undefined {
|
|
311
|
+
return options.find(({ name }) => ENV_SPLIT_OPTIONS.has(name))?.value;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 提取 shell -c 或 eval 需要继续解释的字面量源码。 */
|
|
315
|
+
function nestedCommandSource(name: string, args: readonly Word[]): string | undefined {
|
|
316
|
+
if (SHELL_LAUNCHERS.has(name)) return shellOptionCommandSource(args);
|
|
317
|
+
return name === EVAL_COMMAND_NAME ? args.map((word) => word.value).join(" ") : undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 只在脚本文件参数之前解析 shell 选项,避免把 `bash script.sh -c arg` 当作嵌套命令。 */
|
|
321
|
+
function shellOptionCommandSource(args: readonly Word[]): string | undefined {
|
|
322
|
+
for (let index = 0; index < args.length; index++) {
|
|
323
|
+
const option = args[index].value;
|
|
324
|
+
if (option === OPTION_TERMINATOR || option === STANDARD_INPUT_SCRIPT ||
|
|
325
|
+
(!option.startsWith(SHORT_OPTION_PREFIX) && !option.startsWith(ENABLE_OPTION_PREFIX))) {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
if (option === SHELL_COMMAND_OPTION ||
|
|
329
|
+
(/^-[^-]+$/.test(option) && option.slice(SHORT_OPTION_PREFIX.length).includes(SHELL_COMMAND_FLAG))) {
|
|
330
|
+
return args[index + 1]?.value;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const [optionName, inlineValue] = splitRecognizedShellOption(option, SHELL_OPTIONS_WITH_VALUE);
|
|
334
|
+
if (SHELL_OPTIONS_WITH_VALUE.has(optionName) && inlineValue === undefined) index++;
|
|
335
|
+
}
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** 识别 --option=value 与 -fVALUE 形式的已知短选项。 */
|
|
340
|
+
export function splitRecognizedShellOption(
|
|
341
|
+
value: string,
|
|
342
|
+
recognizedOptions: ReadonlySet<string>,
|
|
343
|
+
): [string, string | undefined] {
|
|
344
|
+
const equalsIndex = value.indexOf("=");
|
|
345
|
+
if (equalsIndex !== -1) return [value.slice(0, equalsIndex), value.slice(equalsIndex + 1)];
|
|
346
|
+
if (recognizedOptions.has(value)) return [value, undefined];
|
|
347
|
+
|
|
348
|
+
for (const option of recognizedOptions) {
|
|
349
|
+
if (/^-[^-]$/.test(option) && value.startsWith(option) && value.length > option.length) {
|
|
350
|
+
return [option, value.slice(option.length)];
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return [value, undefined];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Shell 的 NAME=value 赋值不是命令或位置参数。 */
|
|
357
|
+
export function isShellAssignment(value: string): boolean {
|
|
358
|
+
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(value);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** 统一绝对路径、相对路径和普通命令名。 */
|
|
362
|
+
function commandBasename(value: string): string {
|
|
363
|
+
return value.slice(Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")) + 1);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** 将未知 AST 值安全收窄为普通对象。 */
|
|
367
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
368
|
+
return typeof value === "object" && value !== null;
|
|
369
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type RuleAction = "warn" | "confirm" | "block";
|
|
2
|
+
export type Detector = "disk-format" | "fork-bomb" | "in-place-edit" | "home-root" | "root-search";
|
|
3
|
+
export type RuleMessage = string | { "zh-CN": string; "en-US": string };
|
|
4
|
+
|
|
5
|
+
export type RuleMatch =
|
|
6
|
+
| { commands: readonly string[] }
|
|
7
|
+
| { detector: Detector }
|
|
8
|
+
| { outsideRoots: readonly string[] }
|
|
9
|
+
| { module: string };
|
|
10
|
+
|
|
11
|
+
export interface SafetyRule {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly action: RuleAction;
|
|
14
|
+
readonly match: RuleMatch;
|
|
15
|
+
readonly message?: RuleMessage;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SafetyConfig {
|
|
19
|
+
readonly rules: readonly SafetyRule[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 用户规则模块只接收匹配数据,不接收 Pi 的注册或执行接口。 */
|
|
23
|
+
export interface RuleContext {
|
|
24
|
+
readonly command: string;
|
|
25
|
+
readonly cwd: string;
|
|
26
|
+
readonly commands: readonly {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly args: readonly string[];
|
|
29
|
+
}[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type RuleMatcher = (context: RuleContext) => boolean | Promise<boolean>;
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"strict": false,
|
|
10
|
+
"types": ["node"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["index.ts", "src/**/*.ts"]
|
|
13
|
+
}
|