pi-information-protecter 0.2.0 → 0.3.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/CHANGELOG.md +20 -1
- package/README.md +161 -68
- package/SECURITY.md +6 -2
- package/dist/index.mjs +9 -0
- package/dist/scan-worker.mjs +2 -0
- package/dist/validate-worker.mjs +2 -0
- package/package.json +8 -4
- package/src/config.ts +0 -124
- package/src/engine.ts +0 -130
- package/src/guard.ts +0 -73
- package/src/index.ts +0 -151
- package/src/machine.ts +0 -28
- package/src/migration.ts +0 -127
- package/src/scan-worker.mjs +0 -160
- package/src/validate-worker.mjs +0 -13
package/src/engine.ts
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { Worker } from "node:worker_threads";
|
|
2
|
-
import { fileURLToPath } from "node:url";
|
|
3
|
-
import { migrateConfig, type Snapshot } from "./migration.ts";
|
|
4
|
-
import { getMachineHash } from "./machine.ts";
|
|
5
|
-
|
|
6
|
-
export const REQUEST_ERROR =
|
|
7
|
-
"protecter: 请求已清空。配置无效、扫描超时或包含不支持的附件;修复后重试。";
|
|
8
|
-
export class Protecter {
|
|
9
|
-
private readonly tokens = new Map<string, string>();
|
|
10
|
-
private queue: Promise<unknown> = Promise.resolve();
|
|
11
|
-
private readonly workers = new Set<Worker>();
|
|
12
|
-
private closed = false;
|
|
13
|
-
private snapshot?: Snapshot;
|
|
14
|
-
constructor(
|
|
15
|
-
private readonly configDir: string,
|
|
16
|
-
private readonly timeoutMs = 2000,
|
|
17
|
-
private readonly machineId: () => string = getMachineHash,
|
|
18
|
-
) {}
|
|
19
|
-
|
|
20
|
-
async initialize(): Promise<void> {
|
|
21
|
-
if (this.closed) throw new Error(REQUEST_ERROR);
|
|
22
|
-
this.snapshot = undefined;
|
|
23
|
-
const snapshot = await migrateConfig(this.configDir, this.machineId());
|
|
24
|
-
if (this.closed) throw new Error(REQUEST_ERROR);
|
|
25
|
-
this.snapshot = snapshot;
|
|
26
|
-
}
|
|
27
|
-
get configPath(): string { return this.snapshot?.path ?? ""; }
|
|
28
|
-
get ruleCount(): number { return this.snapshot?.config.sensitiveWords.length ?? 0; }
|
|
29
|
-
get ready(): boolean { return !!this.snapshot && !this.closed; }
|
|
30
|
-
get mappingCount(): number {
|
|
31
|
-
return this.tokens.size;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Serialise scans so two requests cannot assign different tokens to the same value.
|
|
36
|
-
* 串行扫描,避免两个请求为同一原文分配不同占位符。
|
|
37
|
-
*/
|
|
38
|
-
redact(payload: unknown): Promise<unknown> {
|
|
39
|
-
const run = this.queue.then(() => this.scan(payload));
|
|
40
|
-
this.queue = run.catch(() => undefined);
|
|
41
|
-
return run;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
private async scan(payload: unknown): Promise<unknown> {
|
|
45
|
-
if (!this.ready) throw new Error(REQUEST_ERROR);
|
|
46
|
-
const { config, sourceRaws } = this.snapshot!;
|
|
47
|
-
const serialized = JSON.stringify(payload);
|
|
48
|
-
if (!serialized || Buffer.byteLength(serialized) > 8 * 1024 * 1024)
|
|
49
|
-
throw new Error(REQUEST_ERROR);
|
|
50
|
-
let snapshot: unknown;
|
|
51
|
-
try {
|
|
52
|
-
snapshot = JSON.parse(serialized);
|
|
53
|
-
} catch {
|
|
54
|
-
throw new Error(REQUEST_ERROR);
|
|
55
|
-
}
|
|
56
|
-
return new Promise((resolve, reject) => {
|
|
57
|
-
let settled = false;
|
|
58
|
-
const worker = new Worker(
|
|
59
|
-
fileURLToPath(new URL("./scan-worker.mjs", import.meta.url)),
|
|
60
|
-
{
|
|
61
|
-
workerData: {
|
|
62
|
-
payload: snapshot,
|
|
63
|
-
rules: config.sensitiveWords,
|
|
64
|
-
entries: [...this.tokens],
|
|
65
|
-
configRaws: sourceRaws,
|
|
66
|
-
},
|
|
67
|
-
execArgv: [],
|
|
68
|
-
resourceLimits: { maxOldGenerationSizeMb: 128 },
|
|
69
|
-
},
|
|
70
|
-
);
|
|
71
|
-
this.workers.add(worker);
|
|
72
|
-
const finish = (data?: {
|
|
73
|
-
payload: unknown;
|
|
74
|
-
additions: [string, string][];
|
|
75
|
-
}) => {
|
|
76
|
-
if (settled) return;
|
|
77
|
-
settled = true;
|
|
78
|
-
clearTimeout(timer);
|
|
79
|
-
this.workers.delete(worker);
|
|
80
|
-
void worker.terminate();
|
|
81
|
-
if (!data || this.closed) {
|
|
82
|
-
reject(new Error(REQUEST_ERROR));
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
for (const [token, original] of data.additions)
|
|
86
|
-
this.tokens.set(token, original);
|
|
87
|
-
resolve(data.payload);
|
|
88
|
-
};
|
|
89
|
-
const timer = setTimeout(() => finish(), this.timeoutMs);
|
|
90
|
-
worker.once("message", (data) => finish(data.failed ? undefined : data));
|
|
91
|
-
worker.once("error", () => finish());
|
|
92
|
-
worker.once("exit", () => finish());
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
restoreText(text: string): string {
|
|
97
|
-
// One pass, never recursively expand text introduced by a replacement.
|
|
98
|
-
// 仅替换一遍,不递归展开替换后引入的文本。
|
|
99
|
-
return text.replace(
|
|
100
|
-
/__PIP_[a-f0-9]{48}__/g,
|
|
101
|
-
(token) => this.tokens.get(token) ?? token,
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
restore<T>(value: T, depth = 0): T {
|
|
106
|
-
if (depth > 80) throw new Error("protecter: 响应层级过深。");
|
|
107
|
-
if (typeof value === "string") return this.restoreText(value) as T;
|
|
108
|
-
if (Array.isArray(value))
|
|
109
|
-
return value.map((item) => this.restore(item, depth + 1)) as T;
|
|
110
|
-
if (value && typeof value === "object") {
|
|
111
|
-
const result: Record<string, unknown> = Object.create(null);
|
|
112
|
-
for (const [key, item] of Object.entries(value)) {
|
|
113
|
-
const nextKey = this.restoreText(key);
|
|
114
|
-
if (Object.hasOwn(result, nextKey))
|
|
115
|
-
throw new Error("protecter: 响应键冲突。");
|
|
116
|
-
result[nextKey] = this.restore(item, depth + 1);
|
|
117
|
-
}
|
|
118
|
-
return result as T;
|
|
119
|
-
}
|
|
120
|
-
return value;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
close(): void {
|
|
124
|
-
this.closed = true;
|
|
125
|
-
this.snapshot = undefined;
|
|
126
|
-
this.tokens.clear();
|
|
127
|
-
for (const worker of this.workers) void worker.terminate();
|
|
128
|
-
this.workers.clear();
|
|
129
|
-
}
|
|
130
|
-
}
|
package/src/guard.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { realpathSync, statSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
-
import { CONFIG_NAME } from "./migration.ts";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
|
|
7
|
-
function normalized(path: string, cwd: string): string {
|
|
8
|
-
let value = path.replace(/^@/, "");
|
|
9
|
-
if (value.startsWith("file://")) value = fileURLToPath(value);
|
|
10
|
-
if (value === "~" || value.startsWith("~/"))
|
|
11
|
-
value = homedir() + value.slice(1);
|
|
12
|
-
return resolve(cwd, value);
|
|
13
|
-
}
|
|
14
|
-
function canonical(path: string): string {
|
|
15
|
-
try {
|
|
16
|
-
return realpathSync(path);
|
|
17
|
-
} catch {
|
|
18
|
-
const parent = dirname(path);
|
|
19
|
-
return parent === path
|
|
20
|
-
? path
|
|
21
|
-
: resolve(canonical(parent), relative(parent, path));
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
function contains(parent: string, child: string): boolean {
|
|
25
|
-
const rel = relative(parent, child);
|
|
26
|
-
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
27
|
-
}
|
|
28
|
-
function sameFile(a: string, b: string): boolean {
|
|
29
|
-
try {
|
|
30
|
-
const x = statSync(a),
|
|
31
|
-
y = statSync(b);
|
|
32
|
-
return x.dev === y.dev && x.ino === y.ino;
|
|
33
|
-
} catch {
|
|
34
|
-
return false;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Best-effort access guard. Arbitrary shell/third-party tools are NOT sandboxed.
|
|
40
|
-
* 尽力而为的访问防护,不对任意 shell 或第三方工具提供沙箱隔离。
|
|
41
|
-
*/
|
|
42
|
-
export function blocksConfigAccess(
|
|
43
|
-
tool: string,
|
|
44
|
-
input: Record<string, unknown>,
|
|
45
|
-
cwd: string,
|
|
46
|
-
configPath: string,
|
|
47
|
-
): boolean {
|
|
48
|
-
if (!configPath) return true;
|
|
49
|
-
const target = canonical(resolve(configPath));
|
|
50
|
-
// Inspect executable fields, not document bodies. / 检查可执行字段,不检查文档正文。
|
|
51
|
-
if (["bash", "powershell"].includes(tool)) {
|
|
52
|
-
const command = typeof input.command === "string" ? input.command : "";
|
|
53
|
-
if (/protecter(?:\.[a-f0-9]{32})?\.json|\.protecter-migration/i.test(command) || command.includes(configPath)) return true;
|
|
54
|
-
}
|
|
55
|
-
const candidate = input.path ?? input.file_path ?? input.filePath;
|
|
56
|
-
const recursive = ["grep", "find", "ls"].includes(tool);
|
|
57
|
-
if (typeof candidate !== "string" && !recursive) return false;
|
|
58
|
-
try {
|
|
59
|
-
const path = normalized(
|
|
60
|
-
typeof candidate === "string" ? candidate : ".",
|
|
61
|
-
cwd,
|
|
62
|
-
);
|
|
63
|
-
const real = canonical(path);
|
|
64
|
-
return (
|
|
65
|
-
(dirname(real) === dirname(target) && (CONFIG_NAME.test(basename(real)) || basename(real).startsWith(".protecter-migration"))) ||
|
|
66
|
-
real === target ||
|
|
67
|
-
sameFile(path, configPath) ||
|
|
68
|
-
(recursive && contains(real, target))
|
|
69
|
-
);
|
|
70
|
-
} catch {
|
|
71
|
-
return true;
|
|
72
|
-
}
|
|
73
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
getAgentDir,
|
|
3
|
-
VERSION,
|
|
4
|
-
type ExtensionAPI,
|
|
5
|
-
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { Protecter, REQUEST_ERROR } from "./engine.ts";
|
|
7
|
-
import { blocksConfigAccess } from "./guard.ts";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Explicitly install this after other extensions that rewrite outgoing payloads.
|
|
11
|
-
* 请将本扩展放在其他改写出站请求体的扩展之后加载。
|
|
12
|
-
*/
|
|
13
|
-
export default function informationProtecter(pi: ExtensionAPI): void {
|
|
14
|
-
const engine = new Protecter(getAgentDir());
|
|
15
|
-
const compatible =
|
|
16
|
-
/^0\.85\./.test(VERSION) && Number(VERSION.split(".")[2]) >= 1;
|
|
17
|
-
let healthy = false;
|
|
18
|
-
|
|
19
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
20
|
-
try {
|
|
21
|
-
if (!compatible) throw new Error();
|
|
22
|
-
await engine.initialize();
|
|
23
|
-
healthy = true;
|
|
24
|
-
const count = engine.ruleCount;
|
|
25
|
-
ctx.ui.setStatus("protecter", `SPI Protecter · ${count} rules`);
|
|
26
|
-
if (count === 0)
|
|
27
|
-
ctx.ui.notify(
|
|
28
|
-
"SPI Protecter: empty rules; edit local machine config and /reload / 规则为空,请编辑本地机器配置后重载。",
|
|
29
|
-
"warning",
|
|
30
|
-
);
|
|
31
|
-
} catch {
|
|
32
|
-
healthy = false;
|
|
33
|
-
ctx.ui.notify(REQUEST_ERROR, "error");
|
|
34
|
-
}
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
pi.on("before_provider_request", async (event, ctx) => {
|
|
38
|
-
try {
|
|
39
|
-
if (!compatible) throw new Error();
|
|
40
|
-
const payload = await engine.redact(event.payload);
|
|
41
|
-
healthy = true;
|
|
42
|
-
return payload;
|
|
43
|
-
} catch {
|
|
44
|
-
healthy = false;
|
|
45
|
-
// Pi catches hook errors and continues with the ORIGINAL payload.
|
|
46
|
-
// Pi 会捕获钩子异常并继续使用原始请求体。
|
|
47
|
-
// Always return an empty replacement; abort/notifications are best-effort.
|
|
48
|
-
// 必须返回空替代请求体;终止请求和通知仅作尽力处理。
|
|
49
|
-
try {
|
|
50
|
-
await ctx.abort();
|
|
51
|
-
} catch {
|
|
52
|
-
/* Replacement below remains mandatory. / 仍须返回下方的替代请求体。 */
|
|
53
|
-
}
|
|
54
|
-
try {
|
|
55
|
-
ctx.ui.notify(REQUEST_ERROR, "error");
|
|
56
|
-
} catch {
|
|
57
|
-
/* Never fail open. / 不因异常放行原文。 */
|
|
58
|
-
}
|
|
59
|
-
// May produce a provider validation error, but contains NO original data.
|
|
60
|
-
// 可能触发提供商校验错误,但不包含任何原始数据。
|
|
61
|
-
return {};
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
pi.on("message_end", (event) => {
|
|
66
|
-
if (event.message.role !== "assistant") return;
|
|
67
|
-
// Preserve IDs, signatures, usage and provider metadata; only restore user-facing content.
|
|
68
|
-
// 保留 ID、签名、用量与提供商元数据,仅还原面向用户的内容。
|
|
69
|
-
const message = event.message;
|
|
70
|
-
return {
|
|
71
|
-
message: {
|
|
72
|
-
...message,
|
|
73
|
-
content: message.content.map((block) => {
|
|
74
|
-
if (block.type === "text")
|
|
75
|
-
return { ...block, text: engine.restoreText(block.text) };
|
|
76
|
-
if (block.type === "thinking")
|
|
77
|
-
return { ...block, thinking: engine.restoreText(block.thinking) };
|
|
78
|
-
if (block.type === "toolCall")
|
|
79
|
-
return { ...block, arguments: engine.restore(block.arguments) };
|
|
80
|
-
return block;
|
|
81
|
-
}),
|
|
82
|
-
...(message.errorMessage
|
|
83
|
-
? { errorMessage: engine.restoreText(message.errorMessage) }
|
|
84
|
-
: {}),
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
pi.registerMarkdownTransformer((text, context) =>
|
|
90
|
-
context.messageType === "user" ? text : engine.restoreText(text),
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
pi.on("tool_call", (event, ctx) => {
|
|
94
|
-
if (!healthy) return { block: true, reason: "SPI Protecter 尚未就绪。" };
|
|
95
|
-
const restored = engine.restore(event.input);
|
|
96
|
-
for (const key of Object.keys(event.input))
|
|
97
|
-
delete (event.input as Record<string, unknown>)[key];
|
|
98
|
-
Object.assign(event.input, restored);
|
|
99
|
-
if (
|
|
100
|
-
blocksConfigAccess(
|
|
101
|
-
event.toolName,
|
|
102
|
-
event.input,
|
|
103
|
-
ctx.cwd,
|
|
104
|
-
engine.configPath,
|
|
105
|
-
)
|
|
106
|
-
) {
|
|
107
|
-
return {
|
|
108
|
-
block: true,
|
|
109
|
-
reason: "SPI Protecter:禁止工具访问保护配置,请由用户在本地编辑。",
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// These auxiliary requests have different lifecycle/response restoration semantics.
|
|
115
|
-
// 这些辅助请求具有不同的生命周期与响应还原语义。
|
|
116
|
-
// Until separately verified, cancel instead of silently sending plaintext summaries.
|
|
117
|
-
// 在单独验证前取消这些请求,避免静默发送明文摘要。
|
|
118
|
-
pi.on("session_before_compact", (_event, ctx) => {
|
|
119
|
-
ctx.ui.notify(
|
|
120
|
-
"SPI Protecter:暂不支持远程上下文压缩,请使用 /new 开始新会话。",
|
|
121
|
-
"warning",
|
|
122
|
-
);
|
|
123
|
-
return { cancel: true };
|
|
124
|
-
});
|
|
125
|
-
pi.on("session_before_tree", (event) =>
|
|
126
|
-
event.preparation.userWantsSummary ? { cancel: true } : undefined,
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
pi.registerCommand("protecter", {
|
|
130
|
-
description: "SPI Protecter memory status / 内存状态(不显示敏感值)",
|
|
131
|
-
handler: async (args, ctx) => {
|
|
132
|
-
if (args.trim() && !["status", "reload"].includes(args.trim())) {
|
|
133
|
-
ctx.ui.notify(
|
|
134
|
-
"用法:/protecter [status|reload]。不要在命令参数中填写敏感值。",
|
|
135
|
-
"info",
|
|
136
|
-
);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
if (args.trim() === "reload") {
|
|
140
|
-
await ctx.waitForIdle();
|
|
141
|
-
await ctx.reload();
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
ctx.ui.notify(
|
|
145
|
-
`SPI Protecter: ${engine.ready ? "ready / 就绪" : "not ready / 未就绪"}; ${engine.ruleCount} rules / 规则; ${engine.mappingCount} mappings / 映射. Memory snapshot; /reload to refresh / 内存快照,重载后更新。`,
|
|
146
|
-
engine.ready ? "info" : "error",
|
|
147
|
-
);
|
|
148
|
-
},
|
|
149
|
-
});
|
|
150
|
-
pi.on("session_shutdown", () => engine.close());
|
|
151
|
-
}
|
package/src/machine.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { execFileSync } from "node:child_process";
|
|
4
|
-
|
|
5
|
-
export const MACHINE_ERROR = "SPI Protecter: machine ID unavailable / 无法获取机器标识。";
|
|
6
|
-
|
|
7
|
-
/** Normalize OS identifiers without exposing them. / 规范化系统标识,不向外暴露。 */
|
|
8
|
-
export function machineHash(id: string): string {
|
|
9
|
-
const normalized = id.trim().toLowerCase().replace(/-/g, "");
|
|
10
|
-
if (!/^[a-f0-9]{32}$/.test(normalized) || /^0+$/.test(normalized)) throw new Error(MACHINE_ERROR);
|
|
11
|
-
return createHash("sha256").update(`pi-information-protecter:v1:${normalized}`).digest("hex").slice(0, 32);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** No random fallback: unavailable IDs must fail closed. / 不随机回退,标识不可用时拒绝初始化。 */
|
|
15
|
-
export function getMachineHash(): string {
|
|
16
|
-
try {
|
|
17
|
-
if (process.platform === "linux") return machineHash(readFileSync("/etc/machine-id", "utf8"));
|
|
18
|
-
if (process.platform === "darwin") {
|
|
19
|
-
const text = execFileSync("/usr/sbin/ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], { encoding: "utf8", timeout: 5000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
20
|
-
return machineHash(text.match(/"IOPlatformUUID"\s*=\s*"([a-fA-F0-9-]+)"/)?.[1] ?? "");
|
|
21
|
-
}
|
|
22
|
-
if (process.platform === "win32") {
|
|
23
|
-
const text = execFileSync("reg.exe", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid", "/reg:64"], { encoding: "utf8", timeout: 5000, maxBuffer: 65536, stdio: ["ignore", "pipe", "pipe"] });
|
|
24
|
-
return machineHash(text.match(/MachineGuid\s+REG_SZ\s+([a-fA-F0-9-]+)/i)?.[1] ?? "");
|
|
25
|
-
}
|
|
26
|
-
} catch { /* Do not expose command output. / 不暴露命令输出。 */ }
|
|
27
|
-
throw new Error(MACHINE_ERROR);
|
|
28
|
-
}
|
package/src/migration.ts
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
import { join, resolve } from "node:path";
|
|
3
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
import { Worker } from "node:worker_threads";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { CONFIG_ERROR, DEFAULT_CONFIG, loadConfig, parseConfig, type Config, type Rule } from "./config.ts";
|
|
7
|
-
|
|
8
|
-
export const CONFIG_NAME = /^protecter(?:\.[a-f0-9]{32})?\.json$/;
|
|
9
|
-
export interface Snapshot { path: string; config: Config; raw: string; sourceRaws: string[]; migrated: number }
|
|
10
|
-
interface Source { name: string; raw: string; config: Config; identity: string; digest: string }
|
|
11
|
-
export interface MigrationOptions {
|
|
12
|
-
lockTimeoutMs?: number;
|
|
13
|
-
// Local fault injection for regression tests only. / 仅供本地回归测试注入故障。
|
|
14
|
-
checkpoint?: (stage: "beforeCommit" | "afterCommit" | "beforeDelete", path: string) => void;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function validatePatterns(configs: Config[]): Promise<void> {
|
|
18
|
-
return new Promise((resolvePromise, reject) => {
|
|
19
|
-
const worker = new Worker(fileURLToPath(new URL("./validate-worker.mjs", import.meta.url)), { workerData: configs, execArgv: [], resourceLimits: { maxOldGenerationSizeMb: 64 } });
|
|
20
|
-
let done = false;
|
|
21
|
-
const finish = (ok: boolean) => {
|
|
22
|
-
if (done) return;
|
|
23
|
-
done = true;
|
|
24
|
-
clearTimeout(timer);
|
|
25
|
-
void worker.terminate();
|
|
26
|
-
if (ok) resolvePromise(); else reject(new Error(CONFIG_ERROR));
|
|
27
|
-
};
|
|
28
|
-
const timer = setTimeout(() => finish(false), 2000);
|
|
29
|
-
worker.once("message", value => finish(value === true));
|
|
30
|
-
worker.once("error", () => finish(false));
|
|
31
|
-
worker.once("exit", () => finish(false));
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function mergeConfigs(configs: Config[]): Config {
|
|
36
|
-
const seen = new Set<string>();
|
|
37
|
-
const rules: Rule[] = [];
|
|
38
|
-
for (const config of configs) for (const rule of config.sensitiveWords) {
|
|
39
|
-
const key = typeof rule === "string" ? JSON.stringify(["literal", rule]) : rule.type === "literal" ? JSON.stringify(["literal", rule.value]) : JSON.stringify(["regex", rule.pattern, [...new Set((rule.flags ?? "") + "g")].sort().join("")]);
|
|
40
|
-
if (!seen.has(key)) { seen.add(key); rules.push(rule); }
|
|
41
|
-
}
|
|
42
|
-
const raw = JSON.stringify({ version: 1, sensitiveWords: rules });
|
|
43
|
-
if (Buffer.byteLength(raw) > 1024 * 1024) throw new Error(CONFIG_ERROR);
|
|
44
|
-
return parseConfig(raw);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function identity(path: string): string {
|
|
48
|
-
const s = fs.lstatSync(path, { bigint: true });
|
|
49
|
-
if (!s.isFile() || s.nlink !== 1n || (process.getuid && s.uid !== BigInt(process.getuid()))) throw new Error(CONFIG_ERROR);
|
|
50
|
-
return `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}`;
|
|
51
|
-
}
|
|
52
|
-
function source(dir: string, name: string): Source {
|
|
53
|
-
const path = join(dir, name), before = identity(path);
|
|
54
|
-
const loaded = loadConfig(path);
|
|
55
|
-
if (identity(path) !== before) throw new Error(CONFIG_ERROR);
|
|
56
|
-
return { name, ...loaded, identity: before, digest: createHash("sha256").update(loaded.raw).digest("hex") };
|
|
57
|
-
}
|
|
58
|
-
function unchanged(dir: string, previous: Source): void {
|
|
59
|
-
const now = source(dir, previous.name);
|
|
60
|
-
if (now.identity !== previous.identity || now.digest !== previous.digest) throw new Error(CONFIG_ERROR);
|
|
61
|
-
}
|
|
62
|
-
function names(dir: string): string[] { return fs.readdirSync(dir).filter(name => CONFIG_NAME.test(name)).sort(); }
|
|
63
|
-
function syncDir(dir: string): void {
|
|
64
|
-
// Windows does not support POSIX directory fsync. / Windows 不支持 POSIX 目录同步。
|
|
65
|
-
if (process.platform === "win32") return;
|
|
66
|
-
const fd = fs.openSync(dir, fs.constants.O_RDONLY);
|
|
67
|
-
try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Commit before deleting; repeat safely after partial cleanup. / 先提交再删除,部分清理后可安全重试。 */
|
|
71
|
-
export async function migrateConfig(directory: string, hash: string, options: MigrationOptions = {}): Promise<Snapshot> {
|
|
72
|
-
if (!/^[a-f0-9]{32}$/.test(hash)) throw new Error(CONFIG_ERROR);
|
|
73
|
-
const dir = resolve(directory), targetName = `protecter.${hash}.json`, path = join(dir, targetName);
|
|
74
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
75
|
-
const lock = join(dir, ".protecter-migration.lock"), deadline = Date.now() + (options.lockTimeoutMs ?? 5000);
|
|
76
|
-
for (;;) {
|
|
77
|
-
try { fs.mkdirSync(lock, { mode: 0o700 }); break; }
|
|
78
|
-
catch (error) {
|
|
79
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST" || Date.now() >= deadline) throw new Error("SPI Protecter: migration locked; check active processes / 迁移锁被占用,请检查活动进程。");
|
|
80
|
-
await new Promise(resolvePromise => setTimeout(resolvePromise, 50));
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
let temp: string | undefined;
|
|
84
|
-
try {
|
|
85
|
-
const discovered = names(dir);
|
|
86
|
-
if (discovered.length > 64) throw new Error(CONFIG_ERROR);
|
|
87
|
-
const ordered = [...discovered].sort((a, b) => a === targetName ? -1 : b === targetName ? 1 : a.localeCompare(b));
|
|
88
|
-
const sources = ordered.map(name => source(dir, name));
|
|
89
|
-
const config = mergeConfigs(sources.length ? sources.map(s => s.config) : [DEFAULT_CONFIG]);
|
|
90
|
-
await validatePatterns(sources.length ? sources.map(s => s.config) : [config]);
|
|
91
|
-
if (sources.length === 1 && sources[0].name === targetName) {
|
|
92
|
-
unchanged(dir, sources[0]);
|
|
93
|
-
if (JSON.stringify(names(dir)) !== JSON.stringify(discovered)) throw new Error(CONFIG_ERROR);
|
|
94
|
-
return { path, config: sources[0].config, raw: sources[0].raw, sourceRaws: [sources[0].raw], migrated: 0 };
|
|
95
|
-
}
|
|
96
|
-
const raw = JSON.stringify(config, null, 2) + "\n";
|
|
97
|
-
if (Buffer.byteLength(raw) > 1024 * 1024) throw new Error(CONFIG_ERROR);
|
|
98
|
-
temp = join(dir, `.protecter-migration.${randomUUID()}.tmp`);
|
|
99
|
-
const fd = fs.openSync(temp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
100
|
-
try { fs.writeFileSync(fd, raw); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
101
|
-
options.checkpoint?.("beforeCommit", path);
|
|
102
|
-
if (JSON.stringify(names(dir)) !== JSON.stringify(discovered)) throw new Error(CONFIG_ERROR);
|
|
103
|
-
for (const s of sources) unchanged(dir, s);
|
|
104
|
-
fs.renameSync(temp, path);
|
|
105
|
-
temp = undefined;
|
|
106
|
-
syncDir(dir);
|
|
107
|
-
const committed = source(dir, targetName);
|
|
108
|
-
if (committed.raw !== raw) throw new Error(CONFIG_ERROR);
|
|
109
|
-
options.checkpoint?.("afterCommit", path);
|
|
110
|
-
for (const s of sources) {
|
|
111
|
-
if (s.name === targetName) continue;
|
|
112
|
-
options.checkpoint?.("beforeDelete", join(dir, s.name));
|
|
113
|
-
unchanged(dir, committed);
|
|
114
|
-
unchanged(dir, s);
|
|
115
|
-
fs.unlinkSync(join(dir, s.name));
|
|
116
|
-
}
|
|
117
|
-
syncDir(dir);
|
|
118
|
-
const expected = [targetName];
|
|
119
|
-
if (JSON.stringify(names(dir)) !== JSON.stringify(expected)) throw new Error(CONFIG_ERROR);
|
|
120
|
-
unchanged(dir, committed);
|
|
121
|
-
return { path, config, raw, sourceRaws: [...new Set([...sources.map(s => s.raw), raw])], migrated: sources.filter(s => s.name !== targetName).length };
|
|
122
|
-
} catch { throw new Error(CONFIG_ERROR); }
|
|
123
|
-
finally {
|
|
124
|
-
if (temp) { try { fs.unlinkSync(temp); } catch { /* Preserve on failure. / 失败时保留待人工检查。 */ } }
|
|
125
|
-
fs.rmdirSync(lock);
|
|
126
|
-
}
|
|
127
|
-
}
|
package/src/scan-worker.mjs
DELETED
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
// A separate worker bounds user-supplied JavaScript regex execution (including ReDoS).
|
|
2
|
-
// 独立 worker 限制用户正则的执行时间,包括正则拒绝服务风险。
|
|
3
|
-
import { parentPort, workerData } from "node:worker_threads";
|
|
4
|
-
import { randomBytes } from "node:crypto";
|
|
5
|
-
|
|
6
|
-
try {
|
|
7
|
-
const { payload, rules, entries, configRaws } = workerData;
|
|
8
|
-
const originalToToken = new Map(
|
|
9
|
-
entries.map(([token, original]) => [original, token]),
|
|
10
|
-
);
|
|
11
|
-
const tokenToOriginal = new Map(entries);
|
|
12
|
-
const additions = [];
|
|
13
|
-
const matchers = rules.map((rule) => {
|
|
14
|
-
if (typeof rule === "string") return { literal: rule };
|
|
15
|
-
if (rule.type === "literal") return { literal: rule.value };
|
|
16
|
-
return {
|
|
17
|
-
regex: new RegExp(
|
|
18
|
-
rule.pattern,
|
|
19
|
-
[...new Set((rule.flags ?? "") + "g")].join(""),
|
|
20
|
-
),
|
|
21
|
-
};
|
|
22
|
-
});
|
|
23
|
-
// A restored value may lose its original regex context (e.g. a Bearer prefix).
|
|
24
|
-
// 还原后的值可能失去原始正则上下文,例如 Bearer 前缀。
|
|
25
|
-
// Keep learned originals protected for the lifetime of this extension instance.
|
|
26
|
-
// 在当前扩展实例的整个生命周期内继续保护已识别的原文。
|
|
27
|
-
for (const original of originalToToken.keys())
|
|
28
|
-
matchers.push({ literal: original });
|
|
29
|
-
// Defense in depth for an accidentally pasted full configuration (not a sandbox).
|
|
30
|
-
// 对意外粘贴的完整配置提供纵深防护,但这不是沙箱。
|
|
31
|
-
const protectedTexts = [...new Set(configRaws.flatMap(raw => [raw, JSON.stringify(JSON.parse(raw))]))];
|
|
32
|
-
for (const literal of protectedTexts) matchers.push({ literal });
|
|
33
|
-
const payloadText = JSON.stringify(payload);
|
|
34
|
-
let matches = 0;
|
|
35
|
-
let nodes = 0;
|
|
36
|
-
function tokenFor(original) {
|
|
37
|
-
let token = originalToToken.get(original);
|
|
38
|
-
if (token) return token;
|
|
39
|
-
if (tokenToOriginal.size >= 50000) throw new Error();
|
|
40
|
-
do {
|
|
41
|
-
token = `__PIP_${randomBytes(24).toString("hex")}__`;
|
|
42
|
-
} while (tokenToOriginal.has(token) || payloadText.includes(token));
|
|
43
|
-
originalToToken.set(original, token);
|
|
44
|
-
tokenToOriginal.set(token, original);
|
|
45
|
-
additions.push([token, original]);
|
|
46
|
-
return token;
|
|
47
|
-
}
|
|
48
|
-
function redactPlain(text) {
|
|
49
|
-
const spans = [];
|
|
50
|
-
for (const matcher of matchers) {
|
|
51
|
-
if (matcher.literal === undefined) {
|
|
52
|
-
matcher.regex.lastIndex = 0;
|
|
53
|
-
for (const match of text.matchAll(matcher.regex)) {
|
|
54
|
-
if (!match[0].length) throw new Error();
|
|
55
|
-
spans.push([match.index, match.index + match[0].length]);
|
|
56
|
-
if (++matches > 100000) throw new Error();
|
|
57
|
-
}
|
|
58
|
-
} else {
|
|
59
|
-
if (!matcher.literal) continue;
|
|
60
|
-
let start = 0;
|
|
61
|
-
while ((start = text.indexOf(matcher.literal, start)) !== -1) {
|
|
62
|
-
spans.push([start, start + matcher.literal.length]);
|
|
63
|
-
if (++matches > 100000) throw new Error();
|
|
64
|
-
start++; // Include overlapping occurrences. / 包含重叠匹配。
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
spans.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
|
|
69
|
-
const merged = [];
|
|
70
|
-
for (const span of spans) {
|
|
71
|
-
const prev = merged.at(-1);
|
|
72
|
-
if (prev && span[0] < prev[1]) prev[1] = Math.max(prev[1], span[1]);
|
|
73
|
-
else merged.push([...span]);
|
|
74
|
-
}
|
|
75
|
-
let result = "",
|
|
76
|
-
cursor = 0;
|
|
77
|
-
for (const [start, end] of merged) {
|
|
78
|
-
result += text.slice(cursor, start) + tokenFor(text.slice(start, end));
|
|
79
|
-
cursor = end;
|
|
80
|
-
}
|
|
81
|
-
return result + text.slice(cursor);
|
|
82
|
-
}
|
|
83
|
-
function redact(text) {
|
|
84
|
-
// Only OUR tokens are exempt, not arbitrary strings with a similar prefix.
|
|
85
|
-
// 仅豁免本实例生成的占位符,不豁免具有相似前缀的任意字符串。
|
|
86
|
-
let result = "",
|
|
87
|
-
cursor = 0;
|
|
88
|
-
for (const match of text.matchAll(/__PIP_[a-f0-9]{48}__/g)) {
|
|
89
|
-
if (!tokenToOriginal.has(match[0])) continue;
|
|
90
|
-
result += redactPlain(text.slice(cursor, match.index)) + match[0];
|
|
91
|
-
cursor = match.index + match[0].length;
|
|
92
|
-
}
|
|
93
|
-
return result + redactPlain(text.slice(cursor));
|
|
94
|
-
}
|
|
95
|
-
function walk(value, depth = 0) {
|
|
96
|
-
if (++nodes > 200000 || depth > 80) throw new Error();
|
|
97
|
-
if (typeof value === "string") {
|
|
98
|
-
if (protectedTexts.some(text => value.includes(text)))
|
|
99
|
-
return redact(value);
|
|
100
|
-
// OpenAI serializes tool arguments as JSON strings: decode escapes BEFORE matching.
|
|
101
|
-
// OpenAI 将工具参数序列化为 JSON 字符串,匹配前必须先解码转义。
|
|
102
|
-
if (/^\s*[[{]/.test(value)) {
|
|
103
|
-
let parsed;
|
|
104
|
-
try {
|
|
105
|
-
parsed = JSON.parse(value);
|
|
106
|
-
} catch {
|
|
107
|
-
/* Ordinary prose. / 普通文本。 */
|
|
108
|
-
}
|
|
109
|
-
if (parsed && typeof parsed === "object") {
|
|
110
|
-
const inner = JSON.stringify(walk(parsed, depth + 1));
|
|
111
|
-
const outer = redact(inner);
|
|
112
|
-
if (outer === inner) return inner;
|
|
113
|
-
// A rule may span JSON syntax or match a whole JSON-shaped secret.
|
|
114
|
-
// 规则可能跨越 JSON 语法,或匹配整个 JSON 形式的敏感值。
|
|
115
|
-
// Return altered JSON only if it still parses; otherwise reject the request.
|
|
116
|
-
// 仅返回仍可解析的修改后 JSON,否则拒绝请求。
|
|
117
|
-
JSON.parse(outer);
|
|
118
|
-
return outer;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return redact(value);
|
|
122
|
-
}
|
|
123
|
-
if (typeof value === "number") {
|
|
124
|
-
const transformed = redact(String(value));
|
|
125
|
-
return transformed === String(value) ? value : transformed;
|
|
126
|
-
}
|
|
127
|
-
if (Array.isArray(value)) return value.map((item) => walk(item, depth + 1));
|
|
128
|
-
if (value && typeof value === "object") {
|
|
129
|
-
// No claim to perform OCR or redact opaque attachments. Reject rather than leak.
|
|
130
|
-
// 不提供 OCR 或不透明附件脱敏,宁可拒绝请求也不放行泄漏。
|
|
131
|
-
if (
|
|
132
|
-
typeof value.type === "string" &&
|
|
133
|
-
/image|audio|video|document|(^|_)file($|_)/i.test(value.type)
|
|
134
|
-
)
|
|
135
|
-
throw new Error();
|
|
136
|
-
if (
|
|
137
|
-
Object.keys(value).some((key) =>
|
|
138
|
-
/^(inlineData|inline_data|fileData|file_data|image_url|input_audio)$/.test(
|
|
139
|
-
key,
|
|
140
|
-
),
|
|
141
|
-
)
|
|
142
|
-
)
|
|
143
|
-
throw new Error();
|
|
144
|
-
const result = Object.create(null);
|
|
145
|
-
for (const [key, item] of Object.entries(value)) {
|
|
146
|
-
const nextKey = redact(key);
|
|
147
|
-
if (Object.hasOwn(result, nextKey)) throw new Error();
|
|
148
|
-
result[nextKey] = walk(item, depth + 1);
|
|
149
|
-
}
|
|
150
|
-
return result;
|
|
151
|
-
}
|
|
152
|
-
if (value === null || typeof value === "boolean") return value;
|
|
153
|
-
throw new Error();
|
|
154
|
-
}
|
|
155
|
-
parentPort.postMessage({ payload: walk(payload), additions });
|
|
156
|
-
} catch {
|
|
157
|
-
// Never serialize an exception, original payload or configuration back into diagnostics.
|
|
158
|
-
// 不将异常、原始请求体或配置序列化到诊断信息中。
|
|
159
|
-
parentPort.postMessage({ failed: true });
|
|
160
|
-
}
|
package/src/validate-worker.mjs
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { parentPort, workerData } from "node:worker_threads";
|
|
2
|
-
|
|
3
|
-
try {
|
|
4
|
-
for (const config of workerData) {
|
|
5
|
-
for (const rule of config.sensitiveWords) {
|
|
6
|
-
if (typeof rule === "object" && rule.type === "regex" && new RegExp(rule.pattern, rule.flags ?? "").test("")) throw new Error();
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
|
-
parentPort.postMessage(true);
|
|
10
|
-
} catch {
|
|
11
|
-
// Return no rules or parser errors. / 不返回规则或解析错误。
|
|
12
|
-
parentPort.postMessage(false);
|
|
13
|
-
}
|