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/src/config.ts ADDED
@@ -0,0 +1,151 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { DEFAULT_PRESETS, PRESETS } from "./presets.ts";
5
+ import { i18n } from "./i18n.ts";
6
+ import type { Detector, RuleAction, RuleMatch, RuleMessage, SafetyConfig, SafetyRule } from "./types.ts";
7
+
8
+ const EXTENSIONS_DIR = "extensions";
9
+ const PACKAGE_NAME = "pi-safety-guards";
10
+ const CONFIG_FILENAME = "config.json";
11
+ const FILE_NOT_FOUND_CODE = "ENOENT";
12
+ const ACTIONS = new Set<unknown>(["warn", "confirm", "block"]);
13
+ const DETECTORS = new Set<unknown>(["disk-format", "fork-bomb", "in-place-edit", "home-root", "root-search"]);
14
+
15
+ /** 返回 agent 目录下的显式用户配置位置。 */
16
+ export function configPath(): string {
17
+ return join(getAgentDir(), EXTENSIONS_DIR, PACKAGE_NAME, CONFIG_FILENAME);
18
+ }
19
+
20
+ export interface SafetyConfigDocument {
21
+ presets: string[];
22
+ rules: unknown[];
23
+ }
24
+
25
+ /** 读取配置文件的用户文档,保留预设选择和自定义规则原文。 */
26
+ export function loadConfigDocument(path = configPath()): SafetyConfigDocument {
27
+ try {
28
+ const raw = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
29
+ parseConfig(raw);
30
+ return {
31
+ presets: raw.presets === undefined ? [...DEFAULT_PRESETS] : [...(raw.presets as string[])],
32
+ rules: raw.rules === undefined ? [] : [...(raw.rules as unknown[])],
33
+ };
34
+ } catch (error) {
35
+ if ((error as NodeJS.ErrnoException).code === FILE_NOT_FOUND_CODE) {
36
+ return { presets: [...DEFAULT_PRESETS], rules: [] };
37
+ }
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ /** 将完整的用户配置文档校验后写入文件。 */
43
+ export function saveConfigDocument(document: SafetyConfigDocument, path = configPath()): string {
44
+ parseConfig(document);
45
+ mkdirSync(dirname(path), { recursive: true });
46
+ writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`, "utf8");
47
+ return path;
48
+ }
49
+
50
+ /** 将经过校验的安全规则配置写入配置文件。 */
51
+ export function saveConfig(config: SafetyConfig, path = configPath()): string {
52
+ mkdirSync(dirname(path), { recursive: true });
53
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
54
+ return path;
55
+ }
56
+
57
+ /** 将配置错误转换成双语诊断,不悄悄忽略未知配置。 */
58
+ function invalid(field: string): never {
59
+ throw new Error(i18n.t("configInvalidField", { field }));
60
+ }
61
+
62
+ /** 收窄普通 JSON 对象。 */
63
+ function object(value: unknown, field: string): Record<string, unknown> {
64
+ if (!value || typeof value !== "object" || Array.isArray(value)) return invalid(field);
65
+ return value as Record<string, unknown>;
66
+ }
67
+
68
+ /** 拒绝字段拼写错误和旧技术专属配置,避免误启用默认策略。 */
69
+ function checkKeys(raw: Record<string, unknown>, keys: readonly string[], field: string): void {
70
+ for (const key of Object.keys(raw)) {
71
+ if (!keys.includes(key)) invalid(`${field}.${key}`);
72
+ }
73
+ }
74
+
75
+ /** 验证非空文本数组,目录允许空列表以表达不信任任何根。 */
76
+ function strings(value: unknown, field: string, allowEmpty = false): string[] {
77
+ if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) return invalid(field);
78
+ if (value.some((item) => typeof item !== "string" || !item.trim())) return invalid(field);
79
+ return value.map((item: string) => item.trim());
80
+ }
81
+
82
+ /** 匹配器只允许一种类型,不组合成规则表达式语言。 */
83
+ function parseMatch(value: unknown, field: string): RuleMatch {
84
+ const raw = object(value, field);
85
+ if (Object.keys(raw).length !== 1) return invalid(field);
86
+ if (Object.hasOwn(raw, "commands")) return { commands: strings(raw.commands, field) };
87
+ if (Object.hasOwn(raw, "outsideRoots")) return { outsideRoots: strings(raw.outsideRoots, field, true) };
88
+ if (Object.hasOwn(raw, "detector") && DETECTORS.has(raw.detector)) return { detector: raw.detector as Detector };
89
+ if (typeof raw.module === "string" && raw.module.trim()) return { module: raw.module.trim() };
90
+ return invalid(field);
91
+ }
92
+
93
+ /** 用户本地文案可用单一语言,公共示例提供中英文。 */
94
+ function parseMessage(value: unknown, field: string): RuleMessage {
95
+ if (typeof value === "string" && value.trim()) return value;
96
+ const raw = object(value, field);
97
+ checkKeys(raw, ["zh-CN", "en-US"], field);
98
+ if (typeof raw["zh-CN"] !== "string" || !raw["zh-CN"].trim() ||
99
+ typeof raw["en-US"] !== "string" || !raw["en-US"].trim()) return invalid(field);
100
+ return { "zh-CN": raw["zh-CN"], "en-US": raw["en-US"] };
101
+ }
102
+
103
+ /** 选择预设后按稳定 ID 覆盖,不继承任何维护者的技术栈策略。 */
104
+ export function parseConfig(value: unknown): SafetyConfig {
105
+ const raw = object(value, "config");
106
+ checkKeys(raw, ["presets", "rules"], "config");
107
+ const presets = raw.presets === undefined ? DEFAULT_PRESETS : strings(raw.presets, "presets", true);
108
+ const rules = new Map<string, SafetyRule>();
109
+ for (const preset of presets) {
110
+ if (!Object.hasOwn(PRESETS, preset)) invalid(`presets.${preset}`);
111
+ for (const rule of PRESETS[preset]) {
112
+ // 不共享可变预设对象,用户模块也不会收到规则配置引用。
113
+ rules.set(rule.id, structuredClone(rule));
114
+ }
115
+ }
116
+ const overrides = raw.rules === undefined ? [] : raw.rules;
117
+ if (!Array.isArray(overrides)) return invalid("rules");
118
+ const seen = new Set<string>();
119
+ for (const value of overrides) {
120
+ const override = object(value, "rules");
121
+ checkKeys(override, ["id", "enabled", "action", "match", "message"], "rules");
122
+ if (typeof override.id !== "string" || !override.id.trim()) invalid("rules.id");
123
+ const id = (override.id as string).trim();
124
+ if (seen.has(id)) invalid(`rules.${id}`);
125
+ seen.add(id);
126
+ if (override.enabled !== undefined && typeof override.enabled !== "boolean") invalid(`${id}.enabled`);
127
+ const previous = rules.get(id);
128
+ const action = override.action ?? previous?.action;
129
+ if (override.action !== undefined && !ACTIONS.has(override.action)) invalid(`${id}.action`);
130
+ const match = override.match === undefined ? previous?.match : parseMatch(override.match, `${id}.match`);
131
+ const message = override.message === undefined ? previous?.message : parseMessage(override.message, `${id}.message`);
132
+ if (override.enabled === false) {
133
+ if (!previous && !match) invalid(`${id}.match`);
134
+ rules.delete(id);
135
+ continue;
136
+ }
137
+ if (!ACTIONS.has(action) || !match) invalid(id);
138
+ rules.set(id, { id, action: action as RuleAction, match: match!, ...(message === undefined ? {} : { message }) });
139
+ }
140
+ return { rules: [...rules.values()] };
141
+ }
142
+
143
+ /** 仅文件不存在时使用默认预设;配置损坏会阻止受保护工具执行。 */
144
+ export function loadConfig(path = configPath()): SafetyConfig {
145
+ try {
146
+ return parseConfig(JSON.parse(readFileSync(path, "utf8")));
147
+ } catch (error) {
148
+ if ((error as NodeJS.ErrnoException).code === FILE_NOT_FOUND_CODE) return parseConfig({});
149
+ throw error;
150
+ }
151
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,168 @@
1
+ import { statSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { analyzeShellCommand, type ShellCommandAnalysis } from "./shell-command-utils.ts";
5
+ import { findOutOfScopeBashPaths } from "./bash-directory-scope-utils.ts";
6
+ import { i18n } from "./i18n.ts";
7
+ import type { Detector, RuleAction, RuleContext, RuleMatcher, RuleMatch, SafetyConfig, SafetyRule } from "./types.ts";
8
+
9
+ const MODULE_TIMEOUT_MS = 5_000;
10
+ const ACTION_ORDER: readonly RuleAction[] = ["block", "confirm", "warn"];
11
+ const FORK_BOMB_NAME = ":";
12
+ const FORMAT_COMMAND = "mkfs";
13
+ const FORMAT_PREFIX = "mkfs.";
14
+ const FUNCTION_NODE = "Function";
15
+ const EDIT_COMMAND = "sed";
16
+ const SEARCH_COMMAND = "find";
17
+ const IN_PLACE_OPTION = "--in-place";
18
+ const HOME_ROOT = "~";
19
+ const FILESYSTEM_ROOT = "/";
20
+
21
+ export interface CompiledRule {
22
+ readonly rule: SafetyRule;
23
+ readonly matcher?: RuleMatcher;
24
+ }
25
+ export interface PolicyDecision {
26
+ readonly action: RuleAction;
27
+ readonly matches: readonly SafetyRule[];
28
+ }
29
+ export type ModuleLoader = (path: string) => Promise<unknown>;
30
+
31
+ /** 只加载配置指定的本地模块;mtime 使 reload 可以读取规则文件的新版本。 */
32
+ async function loadModule(path: string): Promise<unknown> {
33
+ const url = pathToFileURL(path);
34
+ url.searchParams.set("mtime", String(statSync(path).mtimeMs));
35
+ return import(url.href);
36
+ }
37
+
38
+ /** 在注册任何工具 hook 之前加载启用的规则,避免部分注册后才发现配置错误。 */
39
+ export async function compileRules(
40
+ config: SafetyConfig,
41
+ configDirectory: string,
42
+ loader: ModuleLoader = loadModule,
43
+ ): Promise<CompiledRule[]> {
44
+ const compiled: CompiledRule[] = [];
45
+ for (const rule of config.rules) {
46
+ if (!("module" in rule.match)) {
47
+ compiled.push({ rule });
48
+ continue;
49
+ }
50
+ try {
51
+ const modulePath = resolve(configDirectory, rule.match.module);
52
+ const module = await withDeadline(() => loader(modulePath));
53
+ const matcher = module && typeof module === "object" ? (module as { default?: unknown }).default : undefined;
54
+ if (typeof matcher !== "function") throw new Error(i18n.t("moduleMustExportMatcher"));
55
+ compiled.push({ rule, matcher: matcher as RuleMatcher });
56
+ } catch (error) {
57
+ throw ruleFailure(rule.id, error);
58
+ }
59
+ }
60
+ return compiled;
61
+ }
62
+
63
+ /** 检测器只返回匹配事实,动作和建议不属于检测器。 */
64
+ function detect(detector: Detector, analysis: ShellCommandAnalysis, command: string): boolean {
65
+ switch (detector) {
66
+ case "disk-format":
67
+ return analysis.commands.some(({ name }) => name === FORMAT_COMMAND || name.startsWith(FORMAT_PREFIX));
68
+ case "fork-bomb":
69
+ return /:\(\)\s*\{/.test(command) && analysis.nodes.some(isForkBombFunction);
70
+ case "in-place-edit":
71
+ return analysis.commands.some(({ name, args }) => name === EDIT_COMMAND && args.some(({ value }) => isInPlaceOption(value)));
72
+ case "home-root":
73
+ return analysis.commands.some(({ args }) => args.some(({ text }) => text === HOME_ROOT));
74
+ case "root-search":
75
+ return analysis.commands.some(({ name, args }) => name === SEARCH_COMMAND && args.some(({ value }) => value === FILESYSTEM_ROOT));
76
+ }
77
+ }
78
+
79
+ /** 匹配已有 fork bomb 检测范围中的冒号函数节点。 */
80
+ function isForkBombFunction(node: Record<string, unknown>): boolean {
81
+ if (node.type !== FUNCTION_NODE || !node.name || typeof node.name !== "object") return false;
82
+ return (node.name as { value?: unknown }).value === FORK_BOMB_NAME;
83
+ }
84
+
85
+ /** 识别 sed 组合短选项和带备份后缀的原地编辑选项。 */
86
+ function isInPlaceOption(value: string): boolean {
87
+ if (value === IN_PLACE_OPTION || value.startsWith(`${IN_PLACE_OPTION}=`)) return true;
88
+ return /^-[^-]*i/.test(value);
89
+ }
90
+
91
+ /** 用户模块只拿到深度冻结的命令摘要,不能改写后续内置规则的分析结果。 */
92
+ function moduleContext(command: string, cwd: string, analysis: ShellCommandAnalysis): RuleContext {
93
+ return Object.freeze({
94
+ command,
95
+ cwd,
96
+ commands: Object.freeze(analysis.commands.map(({ name, args }) => Object.freeze({
97
+ name,
98
+ args: Object.freeze(args.map(({ value }) => value)),
99
+ }))),
100
+ });
101
+ }
102
+
103
+ /** 有界等待异步规则;同进程同步死循环不能被抢占,模块必须可信。 */
104
+ function withDeadline<T>(operation: () => Promise<T> | T): Promise<T> {
105
+ return new Promise<T>((resolve, reject) => {
106
+ const timer = setTimeout(() => reject(new Error(i18n.t("moduleTimeout"))), MODULE_TIMEOUT_MS);
107
+ Promise.resolve().then(operation).then(
108
+ (value) => { clearTimeout(timer); resolve(value); },
109
+ (error) => { clearTimeout(timer); reject(error); },
110
+ );
111
+ });
112
+ }
113
+
114
+ /** 命令名只建立一次索引;内置检测保持明确的分支。 */
115
+ function matchesBuiltin(
116
+ match: RuleMatch,
117
+ context: RuleContext,
118
+ analysis: ShellCommandAnalysis,
119
+ commandNames: ReadonlySet<string>,
120
+ additionalRoots: readonly string[],
121
+ ): boolean {
122
+ if ("commands" in match) return match.commands.some((name) => commandNames.has(name));
123
+ if ("detector" in match) return detect(match.detector, analysis, context.command);
124
+ if ("outsideRoots" in match) {
125
+ return findOutOfScopeBashPaths(
126
+ context.command,
127
+ context.cwd,
128
+ [...match.outsideRoots, ...additionalRoots],
129
+ ).length > 0;
130
+ }
131
+ throw new Error(i18n.t("moduleMustExportMatcher"));
132
+ }
133
+
134
+ /** 错误保留规则 ID,绝不按未命中继续执行。 */
135
+ function ruleFailure(id: string, error: unknown): Error {
136
+ return new Error(i18n.t("ruleFailed", {
137
+ id,
138
+ error: error instanceof Error ? error.message : String(error),
139
+ }), { cause: error });
140
+ }
141
+
142
+ /** 评估所有规则并按 block > confirm > warn 合并,不能用前面的低风险动作绕过阻断。 */
143
+ export async function evaluateRules(
144
+ rules: readonly CompiledRule[],
145
+ command: string,
146
+ cwd: string,
147
+ additionalRoots: readonly string[] = [],
148
+ ): Promise<PolicyDecision | undefined> {
149
+ if (rules.length === 0) return undefined;
150
+ const analysis = analyzeShellCommand(command);
151
+ if (analysis.errors.length) throw new Error(i18n.t("shellParseBlocked"));
152
+ const context = moduleContext(command, cwd, analysis);
153
+ const commandNames = new Set(analysis.commands.map(({ name }) => name));
154
+ const matches: SafetyRule[] = [];
155
+ for (const { rule, matcher } of rules) {
156
+ try {
157
+ const matched = matcher
158
+ ? await withDeadline(() => matcher(context))
159
+ : matchesBuiltin(rule.match, context, analysis, commandNames, additionalRoots);
160
+ if (typeof matched !== "boolean") throw new Error(i18n.t("matcherMustReturnBoolean"));
161
+ if (matched) matches.push(rule);
162
+ } catch (error) {
163
+ throw ruleFailure(rule.id, error);
164
+ }
165
+ }
166
+ const action = ACTION_ORDER.find((action) => matches.some((rule) => rule.action === action));
167
+ return action ? { action, matches } : undefined;
168
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { createTranslator, loadCatalog } from "pi-extensions-i18n";
2
+
3
+ export const i18n = createTranslator(loadCatalog(new URL("../locales/i18n.json", import.meta.url)));
package/src/presets.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { SafetyRule } from "./types.ts";
2
+
3
+ /** 预设只保存规则数据,检测器不决定动作或替代工具。 */
4
+ export const PRESETS: Readonly<Record<string, readonly SafetyRule[]>> = {
5
+ "destructive-operations": [
6
+ { id: "filesystem.delete", action: "confirm", match: { commands: ["rm", "rmdir"] } },
7
+ { id: "filesystem.format", action: "confirm", match: { detector: "disk-format" } },
8
+ { id: "filesystem.ownership", action: "confirm", match: { commands: ["chown"] } },
9
+ { id: "shell.fork-bomb", action: "confirm", match: { detector: "fork-bomb" } },
10
+ ],
11
+ "workspace-boundary": [
12
+ { id: "paths.workspace", action: "block", match: { outsideRoots: ["."] } },
13
+ ],
14
+ };
15
+
16
+ export const DEFAULT_PRESETS = ["destructive-operations"] as const;