@yep-ai/cli 1.0.2 → 1.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 +50 -30
- package/bin/market.js +25 -3
- package/bin/yep.js +2 -0
- package/lib/device/client.js +99 -13
- package/lib/device/command.js +5 -3
- package/lib/device/tools.js +56 -5
- package/lib/privacy/command.js +56 -0
- package/lib/privacy/manager.js +139 -0
- package/lib/privacy/permissions.js +34 -0
- package/lib/privacy/worker.js +63 -0
- package/native/darwin-arm64/yep-node +0 -0
- package/native/darwin-x64/yep-node +0 -0
- package/native/linux-arm64/yep-node +0 -0
- package/native/linux-x64/yep-node +0 -0
- package/native/manifest.json +7 -7
- package/native/win32-arm64/yep-node.exe +0 -0
- package/native/win32-x64/yep-node.exe +0 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
## 安装与使用
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
npx --yes
|
|
8
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep --help
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
以下远程命令适用于 1.1.0 发布之后;本次实现不代表 npm 或网站已更新。用户可通过 npx 运行设备节点安装包,无需全局安装:
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
|
-
npx --yes
|
|
15
|
-
npx --yes
|
|
14
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep --help
|
|
15
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep device start
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
本包包含完整实现。`yep-cli` 是计划中的同版本入口包,当前尚未发布本项目版本,请使用 `@yep-ai/cli`。
|
|
@@ -26,31 +26,31 @@ npm install
|
|
|
26
26
|
npm run yep -- --help
|
|
27
27
|
npm run build:yep-node
|
|
28
28
|
npm run pack:cli
|
|
29
|
-
npm install --prefix ./tmp/yep-install ./dist/cli/scoped/yep-ai-cli-1.0.
|
|
29
|
+
npm install --prefix ./tmp/yep-install ./dist/cli/scoped/yep-ai-cli-1.1.0.tgz ./dist/cli/unscoped/yep-cli-1.1.0.tgz
|
|
30
30
|
./tmp/yep-install/node_modules/.bin/yep --version
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
完整实现包为 `scoped/yep-ai-cli-1.0.
|
|
33
|
+
完整实现包为 `scoped/yep-ai-cli-1.1.0.tgz`,入口包为 `unscoped/yep-cli-1.1.0.tgz`。本地验证时可一起指定两份文件,无需从 npm 下载实现包。输出的 `manifest.json` 记录包名、版本、依赖和 SHA-256。
|
|
34
34
|
|
|
35
35
|
## 市场与 Git 内容版本
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
|
-
npx --yes
|
|
39
|
-
npx --yes
|
|
40
|
-
npx --yes
|
|
41
|
-
npx --yes
|
|
42
|
-
npx --yes
|
|
43
|
-
npx --yes
|
|
44
|
-
npx --yes
|
|
45
|
-
npx --yes
|
|
46
|
-
npx --yes
|
|
47
|
-
npx --yes
|
|
48
|
-
npx --yes
|
|
49
|
-
npx --yes
|
|
50
|
-
npx --yes
|
|
51
|
-
npx --yes
|
|
52
|
-
npx --yes
|
|
53
|
-
npx --yes
|
|
38
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep login --registry https://yep.nihao.cool
|
|
39
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep whoami
|
|
40
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep search assistant
|
|
41
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep info @creator/demo
|
|
42
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep client set codex,claude
|
|
43
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep install @creator/demo --client codex
|
|
44
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep list
|
|
45
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep list --remote
|
|
46
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep list --mine
|
|
47
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep uninstall @creator/demo --client codex
|
|
48
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep publish ./my-skill
|
|
49
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep history @creator/demo
|
|
50
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep diff @creator/demo --base published
|
|
51
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep publish ./my-agent --kind agent
|
|
52
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep submit @creator/demo --kind agent
|
|
53
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep withdraw @creator/demo --kind agent
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
默认资源类型为 `skill`,也支持 `--kind agent|plugin`。Skill 私有保存后从网页申请共享审核;Agent/Plugin 的发布会提交审核。审核通过才进入公开市场。`history` 和 `diff` 访问服务端保留的真实 Git 版本。
|
|
@@ -60,20 +60,22 @@ npx --yes @yep-ai/cli@latest withdraw @creator/demo --kind agent
|
|
|
60
60
|
## 设备节点
|
|
61
61
|
|
|
62
62
|
```sh
|
|
63
|
-
npx --yes
|
|
64
|
-
npx --yes
|
|
65
|
-
npx --yes
|
|
66
|
-
npx --yes
|
|
63
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep device login
|
|
64
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep device start "/path/to/documents"
|
|
65
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep device status
|
|
66
|
+
npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep device logout
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
设备通过 HTTPS 配对和 WSS 出站连接;网页批准设备后,还需在某场咨询中选择目录和权限。目录不会整体上传。最多指定 5
|
|
69
|
+
设备通过 HTTPS 配对和 WSS 出站连接;网页批准设备后,还需在某场咨询中选择目录和权限。目录不会整体上传。最多指定 5 个目录;指定目录时仅提供受限内置命令。不指定目录时仅授权当前目录;必须显式使用 `--privacy system` 才启用完整系统终端,该终端拥有当前系统用户权限,可访问其他文件和网络,输入输出不经过文件脱敏。进程退出后节点离线,不安装后台服务。设备授权最长 24 小时,可以从网页或 CLI 撤销。
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
节点上线后,可在「设备节点」点击「打开终端」。显式使用 `--privacy system` 且不指定启动目录时,npm 包内的 Go `yep-node` 为每个标签启动独立的真实 Shell:Windows 使用 ConPTY + cmd,macOS / Linux 使用 PTY + 当前用户的 Shell。输入直接进入终端,工作目录、变量和交互程序状态保留;支持 Ctrl+C、窗口尺寸变化和最多 4 个独立网页会话。AI 操作与网页终端分别执行,不共用工作目录或进程。关闭标签、撤销授权、节点退出或网络连接断开会终止对应 Shell;重连创建新会话,不重放命令。
|
|
72
|
+
|
|
73
|
+
终端中的「+」可选择文件浏览器,Windows 还可选择 CMD 或 PowerShell。标签显示实际 Shell 名称,同名时才编号;仅当前标签显示关闭按钮。文件浏览器显示设备上的完整路径,选中文件可在底部展开大小、修改时间等概要,通过自定义右键菜单下载文件或复制完整路径。受限模式只能访问已授权目录,并继续排除 CLI 私有凭据及符号链接;下载期间文件变化、设备断线或授权失效会中断下载。
|
|
72
74
|
|
|
73
75
|
原生程序随 npm 包一起分发,无需用户安装 Go。当前构建目标为 Windows x64 / ARM64、macOS Intel / Apple Silicon、Linux x64 / ARM64;Windows 需要支持 ConPTY 的 Windows 10 1809 / Server 2019 或更新版本。鸿蒙 / OpenHarmony 原生 ABI 尚未适配,不能将 Linux ARM64 包视为鸿蒙包。缺少原生程序时会明确提示并提供兼容的逐条命令模式;受限目录始终使用内置命令模式,不启动系统 Shell。
|
|
74
76
|
|
|
75
77
|
设备配对和网络通道使用 HTTPS / WSS;服务端保存令牌摘要,客户端每小时轮换设备令牌并保留短暂交接窗口,轮换不会延长原有 24 小时授权。客户端先持久化待交接凭据,断线后继续完成交接。TLS 在站点入口终止,集群内部连接不属于端到端加密。
|
|
76
|
-
指定启动目录时仅支持 `pwd`、`ls` / `dir`、`echo`、`cat` / `type`、`head`、`tail`、`grep` / `findstr`、`wc -l` 与终端内的 `cd`,不能使用管道、重定向或外部程序。已有旧版节点若提示不支持终端,请停止旧进程,将原启动命令中的 `yep` 替换为 `npx --yes
|
|
78
|
+
指定启动目录时仅支持 `pwd`、`ls` / `dir`、`echo`、`cat` / `type`、`head`、`tail`、`grep` / `findstr`、`wc -l` 与终端内的 `cd`,不能使用管道、重定向或外部程序。已有旧版节点若提示不支持终端,请停止旧进程,将原启动命令中的 `yep` 替换为 `npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep`,保留原目录参数。
|
|
77
79
|
|
|
78
80
|
设备默认连接 `https://yep.nihao.cool`,使用本站时无需指定服务器。连接自建站点时可使用 `--server https://your-site.example`;只接受 HTTPS 源站(回环开发地址允许 HTTP),显式指定后记住该源站,令牌按源站隔离。市场登录不等于设备授权。
|
|
79
81
|
|
|
@@ -93,4 +95,22 @@ npx --yes @yep-ai/cli@latest device logout
|
|
|
93
95
|
|
|
94
96
|
市场凭据保存为 `~/.config/yep/config.json`;设备身份和各源站的授权保存在 `~/.config/yep/devices/`。目录权限 `0700`、凭据文件 `0600`;Windows 使用当前用户专属 ACL。市场和设备令牌分别管理。域名改变后需要在新源站重新登录、配对,不会把旧源站令牌发送到新地址。
|
|
95
97
|
|
|
96
|
-
执行 `npx --yes
|
|
98
|
+
执行 `npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep --help` 或 `npx --yes --package=https://yep.nihao.cool/downloads/yep-cli-1.1.0.tgz yep device --help` 查看全部选项。
|
|
99
|
+
|
|
100
|
+
## 本地脱敏(1.1.0)
|
|
101
|
+
|
|
102
|
+
默认 `device start` 为严格模式,无目录参数时仅授权当前目录;完整系统终端需显式 `--privacy system`,其输入输出不经过文件脱敏。读取、搜索、下载及发布前使用本机 Go 核心,规则修改不需要用户编译。
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
yep privacy init
|
|
106
|
+
yep privacy validate --offline
|
|
107
|
+
yep privacy test --offline
|
|
108
|
+
yep privacy preview ./report.txt --offline
|
|
109
|
+
yep privacy export ./report.txt --out ./report.safe.txt --offline
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
私有配置位于 `~/.config/yep/privacy/policy.yaml` 或 `policy.json`,二选一;目录 0700、文件 0600,Windows 使用 ACL。网页“设备节点 → 脱敏规则”保存通用规则并下发,真实私有词典留在本机。
|
|
113
|
+
|
|
114
|
+
支持 10 MiB 以内的 UTF-8 文本、源码、JSON/YAML/CSV;PDF、Office、图片、压缩包与其他无法解析为文本的文件默认允许原样传输。可在「设备节点 → 脱敏规则」选择阻断,或设置本地 `unsupportedFiles: block`;任一适用策略要求阻断时以阻断为准。原件不覆盖。任意 JS 脚本不在默认引擎中执行。检测不保证穷尽自然语言秘密,发送前仍需本机复核。详见仓库 `docs/privacy.md`。
|
|
115
|
+
|
|
116
|
+
本仓库已准备 1.1.0;npm 发布与网站部署完成后,对应远程版本命令才可使用。
|
package/bin/market.js
CHANGED
|
@@ -17,6 +17,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:pat
|
|
|
17
17
|
import { createInterface } from "node:readline/promises";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { installClientDefinitions, normalizeClients, promptClients, resolveClients } from "./clients.js";
|
|
20
|
+
import { PrivacyManager, privateWrite } from "../lib/privacy/manager.js";
|
|
20
21
|
|
|
21
22
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
23
|
const rootDir = resolve(__dirname, "../../..");
|
|
@@ -202,7 +203,7 @@ async function publish(args, options) {
|
|
|
202
203
|
options.token = storedAuth(registry)?.token;
|
|
203
204
|
}
|
|
204
205
|
const token = requireRegistryToken(options.token ?? storedAuth(registry)?.token, registry, "publish Skills");
|
|
205
|
-
const packageFiles =
|
|
206
|
+
const packageFiles = await privatePackageFiles(packageDir, registry, token);
|
|
206
207
|
let ownedSkill = await apiFindOwnedSkillIfSupported(registry, manifest.name, token);
|
|
207
208
|
if (ownedSkill?.version === manifest.version) {
|
|
208
209
|
throw new Error(`Version ${manifest.name}@${manifest.version} already exists. Bump the version before publishing another package.`);
|
|
@@ -316,7 +317,7 @@ async function publishResource(args, options) {
|
|
|
316
317
|
options.token = storedAuth(registry)?.token;
|
|
317
318
|
}
|
|
318
319
|
const token = requireRegistryToken(options.token, registry, "publish resources");
|
|
319
|
-
const files = Object.entries(
|
|
320
|
+
const files = Object.entries(await privatePackageFiles(packageDir, registry, token)).map(([path, content]) => ({ path, contentBase64: Buffer.from(content).toString("base64") }));
|
|
320
321
|
if (!files.some((file) => file.path === manifest.entry)) throw new Error(`Package entry is excluded from publication: ${manifest.entry}`);
|
|
321
322
|
const existing = await findResource(registry, { ...options, token }, manifest.name, true);
|
|
322
323
|
if (existing?.status === "pending") throw new Error(`This resource is awaiting review; run "${cliName} withdraw ${manifest.name} --kind ${options.kind}" before editing it`);
|
|
@@ -1307,6 +1308,27 @@ function shouldRecordDownload(skill) {
|
|
|
1307
1308
|
return skill?.visibility !== "private" && !["draft", "pending", "rejected"].includes(skill?.status);
|
|
1308
1309
|
}
|
|
1309
1310
|
|
|
1311
|
+
async function privatePackageFiles(packageDir, registry, token) {
|
|
1312
|
+
const manager = await PrivacyManager.create();
|
|
1313
|
+
try {
|
|
1314
|
+
try { await manager.apply(await apiFetch(registry, "/api/privacy/attachment-policy", {redirect:"error"}, token)); }
|
|
1315
|
+
catch { throw new Error("发布已停止:无法校验当前账号的脱敏规则,原件未上传。"); }
|
|
1316
|
+
const files = collectPackageFiles(packageDir), sanitized = Object.create(null);
|
|
1317
|
+
let changed = false;
|
|
1318
|
+
for (const [name, content] of Object.entries(files)) {
|
|
1319
|
+
await manager.checkPath(name); await manager.metadata(name);
|
|
1320
|
+
const result = await manager.scan(Buffer.from(content), name, "marketplace-publication");
|
|
1321
|
+
sanitized[name] = result.data; changed ||= !Buffer.from(content).equals(result.data);
|
|
1322
|
+
}
|
|
1323
|
+
if (changed) {
|
|
1324
|
+
const review = join(manager.directory, "review", String(Date.now()));
|
|
1325
|
+
for (const [name, content] of Object.entries(sanitized)) await privateWrite(join(review, name), content);
|
|
1326
|
+
throw new Error(`发布已停止,原件未上传。请在本机检查脱敏副本后,从该目录重新发布:${review}`);
|
|
1327
|
+
}
|
|
1328
|
+
return files;
|
|
1329
|
+
} finally { await manager.close(); }
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1310
1332
|
function collectPackageFiles(packageDir) {
|
|
1311
1333
|
const files = Object.create(null);
|
|
1312
1334
|
const seenPaths = new Map();
|
|
@@ -1349,7 +1371,7 @@ function collectPackageFiles(packageDir) {
|
|
|
1349
1371
|
function shouldIgnorePackagePath(packagePath) {
|
|
1350
1372
|
const segments = packagePath.split("/");
|
|
1351
1373
|
const name = basename(packagePath);
|
|
1352
|
-
return segments.includes(".git") || segments.includes("node_modules") || name === ".DS_Store" || name === "yep-installed.json" || name === ".yep.json" || name === "cpm-installed.json" || name === ".skills-center.json" || name === ".env" || name.startsWith(".env.");
|
|
1374
|
+
return segments.includes(".git") || segments.includes(".yep") || segments.includes("node_modules") || name === ".DS_Store" || name === "yep-installed.json" || name === ".yep.json" || name === "cpm-installed.json" || name === ".skills-center.json" || name === ".env" || name.startsWith(".env.");
|
|
1353
1375
|
}
|
|
1354
1376
|
|
|
1355
1377
|
function safePackageOutput(target, packagePath) {
|
package/bin/yep.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runMarketplace } from "./market.js";
|
|
3
3
|
import { runDevice } from "../lib/device/command.js";
|
|
4
|
+
import { runPrivacy } from "../lib/privacy/command.js";
|
|
4
5
|
const argv = process.argv.slice(2);
|
|
5
6
|
try {
|
|
6
7
|
if (argv[0] === "device") await runDevice(argv.slice(1));
|
|
8
|
+
else if (argv[0] === "privacy") await runPrivacy(argv.slice(1));
|
|
7
9
|
else await runMarketplace(argv);
|
|
8
10
|
} catch (error) {
|
|
9
11
|
console.error(error.message);
|
package/lib/device/client.js
CHANGED
|
@@ -4,9 +4,10 @@ import os from "node:os";
|
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import WebSocket from "ws";
|
|
7
|
-
import { rootDescriptor, resolveSafe, list, execute, terminalDirectory } from "./tools.js";
|
|
7
|
+
import { rootDescriptor, resolveSafe, list, execute, terminalDirectory, ownerFileList, ownerFileRead } from "./tools.js";
|
|
8
8
|
import { findNativeBinary, NativeTerminal } from "./native-terminal.js";
|
|
9
9
|
import { rotateDeviceCredential } from "./credential-rotation.js";
|
|
10
|
+
import { PrivacyManager, privateRead } from "../privacy/manager.js";
|
|
10
11
|
const PROTOCOL = 2;
|
|
11
12
|
const DEFAULT_SERVER = "https://yep.nihao.cool";
|
|
12
13
|
function serverOrigin(value) {
|
|
@@ -203,7 +204,7 @@ async function systemRoots(privateDir) {
|
|
|
203
204
|
}));
|
|
204
205
|
}
|
|
205
206
|
class DeviceAgent {
|
|
206
|
-
constructor(roots, { restricted = true, privateDir, token, connectionId, expiresAt, onTerminalOutput } = {}) {
|
|
207
|
+
constructor(roots, { restricted = true, privateDir, token, connectionId, expiresAt, onTerminalOutput, privacy } = {}) {
|
|
207
208
|
this.roots = roots;
|
|
208
209
|
this.restricted = restricted;
|
|
209
210
|
this.privateDir = privateDir;
|
|
@@ -211,13 +212,17 @@ class DeviceAgent {
|
|
|
211
212
|
this.connectionId = connectionId;
|
|
212
213
|
this.expiresAt = expiresAt;
|
|
213
214
|
this.onTerminalOutput = onTerminalOutput;
|
|
215
|
+
this.privacy = privacy;
|
|
216
|
+
if (privacy) for (const root of roots) root.privacy = privacy;
|
|
214
217
|
this.closed = false;
|
|
215
218
|
this.bindings = /* @__PURE__ */ new Map();
|
|
216
219
|
this.active = /* @__PURE__ */ new Map();
|
|
217
220
|
this.terminalSessions = new Map();
|
|
218
221
|
}
|
|
219
222
|
publicRoots() {
|
|
220
|
-
|
|
223
|
+
// The hello precedes the first cloud-policy fetch. Do not reveal local
|
|
224
|
+
// root names before that policy has been applied.
|
|
225
|
+
return this.roots.map(({ id, name }, index) => ({ id, name: this.privacy ? `授权目录 ${index + 1}` : name }));
|
|
221
226
|
}
|
|
222
227
|
cancel(frame = {}) {
|
|
223
228
|
const waiting = [];
|
|
@@ -237,7 +242,8 @@ class DeviceAgent {
|
|
|
237
242
|
this.terminalSessions.clear();
|
|
238
243
|
}
|
|
239
244
|
async terminalFrame(frame) {
|
|
240
|
-
const
|
|
245
|
+
const privacyEpoch=this.privacy?.epoch;
|
|
246
|
+
const base = { id: frame.id, sessionId: frame.sessionId, ...(this.privacy?{privacyRevision:this.privacy.activeRevision}:{}) };
|
|
241
247
|
if (typeof frame.sessionId !== "string" || !frame.sessionId || frame.sessionId.length > 200) throw new Error("Invalid terminal session");
|
|
242
248
|
if (["terminal_cancel", "terminal_close"].includes(frame.type)) {
|
|
243
249
|
if (frame.type === "terminal_close") {
|
|
@@ -262,7 +268,7 @@ class DeviceAgent {
|
|
|
262
268
|
let complete;
|
|
263
269
|
const done = new Promise(resolve => { complete = resolve; });
|
|
264
270
|
this.active.set(frame.id, { abort, sessionId: frame.sessionId, done });
|
|
265
|
-
const valid = () => !this.closed && !session.closed && !abort.signal.aborted;
|
|
271
|
+
const valid = () => !this.closed && !session.closed && !abort.signal.aborted && (!this.privacy || this.privacy.ready && privacyEpoch===this.privacy.epoch);
|
|
266
272
|
try {
|
|
267
273
|
const cwd = frame.path ?? "";
|
|
268
274
|
const changed = await terminalDirectory(root, cwd, frame.command, this.restricted);
|
|
@@ -292,11 +298,42 @@ class DeviceAgent {
|
|
|
292
298
|
}
|
|
293
299
|
async frame(frame) {
|
|
294
300
|
if (this.closed) throw new Error("Stale device connection");
|
|
301
|
+
if (this.privacy && !["cancel", "unbound", "terminal_cancel", "terminal_close"].includes(frame.type)) this.privacy.assertReady(frame.privacyRevision);
|
|
295
302
|
if (this.expiresAt && Date.now() >= Date.parse(this.expiresAt)) {
|
|
296
303
|
this.close();
|
|
297
304
|
throw new Error("Device authorization expired");
|
|
298
305
|
}
|
|
299
306
|
if (["terminal_exec", "terminal_cancel", "terminal_close"].includes(frame.type)) return this.terminalFrame(frame);
|
|
307
|
+
if (["owner_file_list", "owner_file_read"].includes(frame.type)) {
|
|
308
|
+
if (typeof frame.id !== "string" || !frame.id || this.active.has(frame.id) || this.active.size >= 8) throw new Error("Too many or duplicate local operations");
|
|
309
|
+
let root = this.roots.find(item => item.id === frame.folderId) ?? (!frame.folderId ? this.roots[0] : undefined);
|
|
310
|
+
let relative = frame.path ?? "";
|
|
311
|
+
if (this.privacy && typeof frame.absolutePath === "string" && frame.absolutePath.startsWith("yep-private://")) {
|
|
312
|
+
const value = new URL(frame.absolutePath);
|
|
313
|
+
root = this.roots.find(item => item.id === value.hostname);
|
|
314
|
+
if (!root) throw new Error("privacy_handle_invalid");
|
|
315
|
+
relative = decodeURIComponent(value.pathname.slice(1));
|
|
316
|
+
frame = { ...frame, absolutePath: undefined };
|
|
317
|
+
}
|
|
318
|
+
if (frame.absolutePath) {
|
|
319
|
+
if (typeof frame.absolutePath !== "string" || frame.absolutePath.length > 4096 || !path.isAbsolute(frame.absolutePath) || /[\0\r\n]/u.test(frame.absolutePath)) throw new Error("Invalid absolute path");
|
|
320
|
+
const location = path.resolve(frame.absolutePath);
|
|
321
|
+
const matches = this.roots.filter(item => { const rel = path.relative(item.absolute, location); return rel === "" || rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel); }).sort((a, b) => b.absolute.length - a.absolute.length);
|
|
322
|
+
root = matches[0];
|
|
323
|
+
if (!root) throw new Error("Path is outside authorized directories");
|
|
324
|
+
relative = path.relative(root.absolute, location).split(path.sep).join("/");
|
|
325
|
+
}
|
|
326
|
+
if (!root) throw new Error("Unknown file root");
|
|
327
|
+
const abort = new AbortController();
|
|
328
|
+
let complete;
|
|
329
|
+
const done = new Promise(resolve => { complete = resolve; });
|
|
330
|
+
this.active.set(frame.id, { abort, done });
|
|
331
|
+
try {
|
|
332
|
+
const result = frame.type === "owner_file_list" ? await ownerFileList(root, relative, frame.offset ?? 0) : await ownerFileRead(root, relative, frame.offset ?? 0, frame.version ?? "");
|
|
333
|
+
if (this.closed || abort.signal.aborted) throw new Error("Operation canceled");
|
|
334
|
+
return { type: "result", id: frame.id, ...result, ...(frame.type === "owner_file_list" ? { folders: this.publicRoots().map((root, index) => ({ ...root, absolutePath: this.privacy ? `yep-private://${root.id}/` : this.roots[index].absolute })) } : {}) };
|
|
335
|
+
} finally { this.active.delete(frame.id); complete(); }
|
|
336
|
+
}
|
|
300
337
|
const base = { id: frame.id, consultationId: frame.consultationId, bindingId: frame.bindingId, roundId: frame.roundId };
|
|
301
338
|
if (frame.type === "cancel") {
|
|
302
339
|
await this.cancel(frame);
|
|
@@ -323,6 +360,7 @@ class DeviceAgent {
|
|
|
323
360
|
const targetFile = item.kind === "file" ? path.basename(resolved.absolute) : void 0;
|
|
324
361
|
const absolute = targetFile ? path.dirname(resolved.absolute) : resolved.absolute;
|
|
325
362
|
const root2 = await rootDescriptor("folder-" + randomUUID(), absolute, this.privateDir);
|
|
363
|
+
if (this.privacy) { root2.privacy=this.privacy;root2.privacyScope=frame.consultationId;try{await this.privacy.metadata(root2.name);}catch{root2.name="授权目录";} }
|
|
326
364
|
if (targetFile) root2.targetFile = targetFile;
|
|
327
365
|
folders.push(root2);
|
|
328
366
|
}
|
|
@@ -357,7 +395,9 @@ class DeviceAgent {
|
|
|
357
395
|
});
|
|
358
396
|
this.active.set(frame.id, { abort, bindingId: frame.bindingId, roundId: frame.roundId, done });
|
|
359
397
|
try {
|
|
360
|
-
|
|
398
|
+
// AI file bindings always use structured, sanitized builtins. The
|
|
399
|
+
// explicitly enabled owner terminal is a separate system capability.
|
|
400
|
+
const result = await execute(root, request, { restricted: true, signal: abort.signal, secrets: [this.token] });
|
|
361
401
|
if (abort.signal.aborted || this.bindings.get(frame.bindingId) !== binding || frame.permissionVersion !== binding.permissionVersion) throw new Error("Authorization changed during operation");
|
|
362
402
|
return { ...base, type: "result", result };
|
|
363
403
|
} finally {
|
|
@@ -366,11 +406,22 @@ class DeviceAgent {
|
|
|
366
406
|
}
|
|
367
407
|
}
|
|
368
408
|
}
|
|
369
|
-
async function start(server, config, credential, directories, { name, noOpen, signal } = {}) {
|
|
409
|
+
async function start(server, config, credential, directories, { name, noOpen, signal, privacyMode = "strict" } = {}) {
|
|
410
|
+
if (!directories.length && privacyMode === "strict") directories = [process.cwd()];
|
|
370
411
|
const restricted = directories.length > 0;
|
|
371
412
|
if (directories.length > 5) throw new Error("At most five startup directories");
|
|
372
413
|
const roots = restricted ? await Promise.all(directories.map((p, i) => rootDescriptor("root-" + (i + 1), p, config.directory))) : await systemRoots(config.directory);
|
|
373
414
|
if (!roots.length) throw new Error("No accessible filesystem roots");
|
|
415
|
+
const privacy = await PrivacyManager.create();
|
|
416
|
+
try {
|
|
417
|
+
privacy.watchLocal();
|
|
418
|
+
for (const root of roots) {
|
|
419
|
+
await privacy.checkPath(root.absolute);
|
|
420
|
+
try { await privacy.metadata(root.name); } catch { root.name = "授权目录"; }
|
|
421
|
+
root.privacy = privacy;
|
|
422
|
+
}
|
|
423
|
+
const privacyCache = path.join(privacy.directory, "public-" + createHash("sha256").update(server).update(credential.deviceId || createHash("sha256").update(credential.token).digest("hex")).digest("hex") + ".json");
|
|
424
|
+
try { await privacy.apply(JSON.parse((await privateRead(privacyCache, 3 * 1024 * 1024)).toString("utf8"))); } catch (error) { if (error.code !== "ENOENT") privacy.invalidate(); }
|
|
374
425
|
const nativeBinary = restricted ? null : await findNativeBinary();
|
|
375
426
|
if (!restricted && !nativeBinary) console.warn("未找到当前平台的原生终端组件,使用兼容命令模式。");
|
|
376
427
|
console.log(restricted ? "受限目录模式:仅内置命令,不能启动 shell 或外部程序。" : "完整系统模式:网页授权执行的命令拥有当前系统用户权限,可访问其他文件和网络;这不是沙箱。");
|
|
@@ -422,7 +473,8 @@ async function start(server, config, credential, directories, { name, noOpen, si
|
|
|
422
473
|
const url = new URL("/api/local-cli/connect", server);
|
|
423
474
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
424
475
|
const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${credential.token}` }, maxPayload: 15 * 1024 * 1024, handshakeTimeout: 15e3, followRedirects: false });
|
|
425
|
-
|
|
476
|
+
privacy.invalidate();
|
|
477
|
+
const agent = new DeviceAgent(roots, { restricted, privacy, privateDir: config.directory, token: credential.token, expiresAt: credential.expiresAt,
|
|
426
478
|
onTerminalOutput: frame => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame)); },
|
|
427
479
|
});
|
|
428
480
|
const native = nativeBinary ? new NativeTerminal(nativeBinary, frame => {
|
|
@@ -436,6 +488,31 @@ async function start(server, config, credential, directories, { name, noOpen, si
|
|
|
436
488
|
let credentialRotation = false;
|
|
437
489
|
let rotationTask = null;
|
|
438
490
|
let awaitingCredential = false;
|
|
491
|
+
let privacySync = null, privacyAgain = false, lastPrivacyCheck = 0;
|
|
492
|
+
privacy.onChange = () => { void agent.cancel(); };
|
|
493
|
+
privacy.onStatus = () => { if(ws.readyState===WebSocket.OPEN && privacy.desiredHash)ws.send(JSON.stringify({type:"privacy_policy_status",revision:privacy.desiredRevision,contentSHA256:privacy.desiredHash,state:privacy.ready?"applied":"failed",errorCode:privacy.ready?undefined:"privacy_apply_failed"})); };
|
|
494
|
+
const syncPrivacy = () => {
|
|
495
|
+
privacyAgain = true;
|
|
496
|
+
if (privacySync) return privacySync;
|
|
497
|
+
privacySync = (async () => {
|
|
498
|
+
while (privacyAgain && ws.readyState === WebSocket.OPEN) {
|
|
499
|
+
privacyAgain = false; let bundle;
|
|
500
|
+
try {
|
|
501
|
+
bundle = await api(server, "/api/local-cli/privacy", { token: credential.token });
|
|
502
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
503
|
+
ws.send(JSON.stringify({ type: "privacy_policy_status", revision: bundle.revision, contentSHA256: bundle.contentSHA256, state: "validating" }));
|
|
504
|
+
const applied = await privacy.apply(bundle, { cachePath: privacyCache });
|
|
505
|
+
if (applied && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "privacy_policy_status", revision: bundle.revision, contentSHA256: bundle.contentSHA256, state: "applied" }));
|
|
506
|
+
} catch {
|
|
507
|
+
privacy.invalidate();
|
|
508
|
+
if (bundle && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "privacy_policy_status", revision: bundle.revision, contentSHA256: bundle.contentSHA256, state: "failed", errorCode: "privacy_apply_failed" }));
|
|
509
|
+
console.warn("脱敏规则暂未就绪,文件任务已暂停,将自动重试。");
|
|
510
|
+
}
|
|
511
|
+
lastPrivacyCheck = Date.now();
|
|
512
|
+
}
|
|
513
|
+
})().finally(() => { privacySync = null; });
|
|
514
|
+
return privacySync;
|
|
515
|
+
};
|
|
439
516
|
const received = () => {
|
|
440
517
|
if (ws.readyState !== WebSocket.OPEN || fatal || signal?.aborted) return;
|
|
441
518
|
clearTimeout(receiveWatchdog);
|
|
@@ -477,7 +554,7 @@ async function start(server, config, credential, directories, { name, noOpen, si
|
|
|
477
554
|
signal?.addEventListener("abort", abort, { once: true });
|
|
478
555
|
ws.on("open", () => {
|
|
479
556
|
received();
|
|
480
|
-
ws.send(JSON.stringify({ type: "hello", protocol: PROTOCOL, consentVersion: "yep-device-v2", directoryMode: restricted ? "restricted" : "system", folders: agent.publicRoots(), commandsEnabled: true, fileSelection: true, capabilities: ["terminal", ...(native ? ["pty"] : [])] }));
|
|
557
|
+
ws.send(JSON.stringify({ type: "hello", protocol: PROTOCOL, consentVersion: "yep-device-v2", directoryMode: restricted ? "restricted" : "system", folders: agent.publicRoots(), commandsEnabled: true, fileSelection: true, capabilities: ["privacy-v1", "opaque-files-v1", "terminal", "files-v1", ...(native ? ["pty", "shell-selection"] : [])] }));
|
|
481
558
|
});
|
|
482
559
|
ws.on("ping", received);
|
|
483
560
|
ws.on("pong", received);
|
|
@@ -502,6 +579,7 @@ async function start(server, config, credential, directories, { name, noOpen, si
|
|
|
502
579
|
agent.connectionId = frame.connectionId;
|
|
503
580
|
credentialRotation = frame.credentialRotation === true;
|
|
504
581
|
ready = true;
|
|
582
|
+
void syncPrivacy();
|
|
505
583
|
delay = 1e3;
|
|
506
584
|
console.log("设备已上线。可在网页设备节点中打开终端,或在咨询中选择目录;退出此进程将断开。");
|
|
507
585
|
heartbeat = setInterval(() => {
|
|
@@ -515,6 +593,7 @@ async function start(server, config, credential, directories, { name, noOpen, si
|
|
|
515
593
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
|
|
516
594
|
updateCredential();
|
|
517
595
|
if (Date.now() >= nextRotation) rotate();
|
|
596
|
+
if (Date.now() - lastPrivacyCheck > 60000 + Math.random() * 30000 || !privacy.ready) void syncPrivacy();
|
|
518
597
|
}, 1e4);
|
|
519
598
|
return;
|
|
520
599
|
}
|
|
@@ -531,8 +610,10 @@ async function start(server, config, credential, directories, { name, noOpen, si
|
|
|
531
610
|
ws.send(JSON.stringify({ type: "pong" }));
|
|
532
611
|
return;
|
|
533
612
|
}
|
|
613
|
+
if (frame.type === "pong") return;
|
|
534
614
|
if (frame.type === "credential_updated") { awaitingCredential = false; return; }
|
|
535
615
|
if (frame.type === "credential_update_error") { awaitingCredential = true; return; }
|
|
616
|
+
if (frame.type === "privacy_policy_changed") { privacy.invalidate(); if (ready) void syncPrivacy(); return; }
|
|
536
617
|
if (!ready) return;
|
|
537
618
|
if (["pty_open", "pty_input", "pty_resize", "pty_close"].includes(frame.type)) {
|
|
538
619
|
try {
|
|
@@ -543,14 +624,19 @@ async function start(server, config, credential, directories, { name, noOpen, si
|
|
|
543
624
|
}
|
|
544
625
|
return;
|
|
545
626
|
}
|
|
627
|
+
const privacyEpoch = privacy.epoch;
|
|
546
628
|
const result = await agent.frame(frame);
|
|
547
|
-
if (result && ws.readyState === WebSocket.OPEN)
|
|
629
|
+
if (result && ws.readyState === WebSocket.OPEN) {
|
|
630
|
+
privacy.assertReady(frame.privacyRevision);
|
|
631
|
+
if (privacyEpoch !== privacy.epoch) throw new Error("privacy_policy_changed");
|
|
632
|
+
ws.send(JSON.stringify({ ...result, privacyRevision: privacy.activeRevision }));
|
|
633
|
+
}
|
|
548
634
|
} catch (e) {
|
|
549
635
|
if (fatal || signal?.aborted) return;
|
|
550
636
|
if (frame?.id && ws.readyState === WebSocket.OPEN) {
|
|
551
|
-
let error = String(e.message)
|
|
637
|
+
let error = /^privacy_[a-z_]+$/.test(String(e.message)) ? e.message : "privacy_operation_blocked";
|
|
552
638
|
for (const root of [...agent.roots].sort((a, b) => b.absolute.length - a.absolute.length)) error = error.split(root.absolute).join("[授权目录]");
|
|
553
|
-
ws.send(JSON.stringify({ type: frame.type?.startsWith("terminal_") ? "terminal_error" : "result", id: frame.id, sessionId: frame.sessionId, bindingId: frame.bindingId, consultationId: frame.consultationId, error: error.slice(0, 8192) }));
|
|
639
|
+
ws.send(JSON.stringify({ type: frame.type?.startsWith("terminal_") ? "terminal_error" : "result", id: frame.id, sessionId: frame.sessionId, bindingId: frame.bindingId, consultationId: frame.consultationId, privacyRevision: privacy.activeRevision, error: error.slice(0, 8192) }));
|
|
554
640
|
} else {
|
|
555
641
|
fatal = e;
|
|
556
642
|
native?.close();
|
|
@@ -578,7 +664,7 @@ async function start(server, config, credential, directories, { name, noOpen, si
|
|
|
578
664
|
console.log(`连接已断开;${Math.ceil(delay / 1e3)}秒后重连。原咨询绑定已失效,重连后需重新选择。`);
|
|
579
665
|
await retryDelay();
|
|
580
666
|
delay = Math.min(delay * 2, 3e4);
|
|
581
|
-
}
|
|
667
|
+
} } finally { await privacy.close(); }
|
|
582
668
|
}
|
|
583
669
|
export {
|
|
584
670
|
DeviceAgent,
|
package/lib/device/command.js
CHANGED
|
@@ -12,7 +12,8 @@ yep device status [--server https://your-site.example]
|
|
|
12
12
|
yep device logout [--server https://your-site.example]
|
|
13
13
|
|
|
14
14
|
选项:--name 名称、--no-open、--help、--version
|
|
15
|
-
|
|
15
|
+
默认严格保护模式:未指定目录时仅共享当前目录,只支持受限内置命令。
|
|
16
|
+
--privacy system 显式启用完整系统终端(非沙箱,终端流不受文件脱敏保护)。
|
|
16
17
|
授权最长24小时;start不安装后台服务。
|
|
17
18
|
默认连接 https://yep.nihao.cool;显式 --server 会记住源站,令牌仍按源站隔离。`;
|
|
18
19
|
export async function runDevice(args = process.argv.slice(2)) {
|
|
@@ -26,7 +27,7 @@ export async function runDevice(args = process.argv.slice(2)) {
|
|
|
26
27
|
return;
|
|
27
28
|
}
|
|
28
29
|
const command = args.shift();
|
|
29
|
-
let server, name, noOpen = false;
|
|
30
|
+
let server, name, noOpen = false, privacyMode = "strict";
|
|
30
31
|
const directories = [];
|
|
31
32
|
while (args.length) {
|
|
32
33
|
const a = args.shift();
|
|
@@ -37,6 +38,7 @@ export async function runDevice(args = process.argv.slice(2)) {
|
|
|
37
38
|
name = args.shift();
|
|
38
39
|
if (!name) throw new Error("--name requires a value");
|
|
39
40
|
} else if (a === "--no-open") noOpen = true;
|
|
41
|
+
else if (a === "--privacy") { privacyMode=args.shift();if (!["strict","system"].includes(privacyMode)) throw new Error("--privacy requires strict or system"); }
|
|
40
42
|
else if (a.startsWith("-")) throw new Error(`Unknown option ${a}`);
|
|
41
43
|
else directories.push(a);
|
|
42
44
|
}
|
|
@@ -94,7 +96,7 @@ export async function runDevice(args = process.argv.slice(2)) {
|
|
|
94
96
|
}
|
|
95
97
|
} else credential = null;
|
|
96
98
|
if (!credential) credential = await login(server, config, { name, noOpen, signal: controller.signal });
|
|
97
|
-
await start(server, config, credential, directories, { name, noOpen, signal: controller.signal });
|
|
99
|
+
await start(server, config, credential, directories, { name, noOpen, privacyMode, signal: controller.signal });
|
|
98
100
|
} finally {
|
|
99
101
|
process.removeListener("SIGINT", stop);
|
|
100
102
|
process.removeListener("SIGTERM", stop);
|
package/lib/device/tools.js
CHANGED
|
@@ -3,6 +3,7 @@ import { constants as FS } from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { StringDecoder } from "node:string_decoder";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
6
7
|
import { windowsExecution } from "./windows-execution.js";
|
|
7
8
|
const MAX_FILE = 10 * 1024 * 1024;
|
|
8
9
|
const MAX_OUTPUT = 64 * 1024;
|
|
@@ -27,6 +28,7 @@ async function rootDescriptor(id, location, privateDir) {
|
|
|
27
28
|
}
|
|
28
29
|
async function resolveSafe(root, relative = "", kind) {
|
|
29
30
|
safeRelative(relative);
|
|
31
|
+
if (root.privacy) { await root.privacy.checkPath(relative); await root.privacy.metadata(relative); }
|
|
30
32
|
const rootStat = await fs.lstat(root.absolute);
|
|
31
33
|
if (rootStat.isSymbolicLink() || rootStat.dev !== root.dev || rootStat.ino !== root.ino || !samePath(await fs.realpath(root.absolute), root.absolute)) throw new Error("Authorized directory was replaced");
|
|
32
34
|
let candidate = root.absolute;
|
|
@@ -37,7 +39,7 @@ async function resolveSafe(root, relative = "", kind) {
|
|
|
37
39
|
}
|
|
38
40
|
const st = await fs.lstat(candidate);
|
|
39
41
|
const real = await fs.realpath(candidate);
|
|
40
|
-
if (!samePath(real, candidate) || !within(root.absolute, real) || root.privateDir && within(root.privateDir, real)) throw new Error("Path escapes authorization");
|
|
42
|
+
if (!samePath(real, candidate) || !within(root.absolute, real) || root.privateDir && within(root.privateDir, real) || root.privacy && within(root.privacy.directory, real)) throw new Error("Path escapes authorization");
|
|
41
43
|
if (kind === "file" && !st.isFile() || kind === "directory" && !st.isDirectory()) throw new Error("Unexpected file type");
|
|
42
44
|
return { absolute: candidate, stat: st };
|
|
43
45
|
}
|
|
@@ -71,11 +73,18 @@ async function readFile(root, relative, maximum = MAX_FILE) {
|
|
|
71
73
|
try {
|
|
72
74
|
const before = await fd.stat();
|
|
73
75
|
if (before.ino !== resolved.stat.ino || before.dev !== resolved.stat.dev || !before.isFile()) throw new Error("File changed during authorization");
|
|
76
|
+
if (before.size > maximum) throw new Error("File exceeds size limit");
|
|
74
77
|
const buffer = Buffer.alloc(Math.min(maximum + 1, before.size + 1));
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
let bytesRead = 0;
|
|
79
|
+
while (bytesRead < buffer.length) { const chunk = await fd.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead); if (!chunk.bytesRead) break; bytesRead += chunk.bytesRead; }
|
|
80
|
+
const after = await fd.stat();
|
|
81
|
+
if (bytesRead > maximum || bytesRead !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) throw new Error("File changed during read");
|
|
77
82
|
await resolveSafe(root, relative, "file");
|
|
78
|
-
|
|
83
|
+
const data = buffer.subarray(0, bytesRead);
|
|
84
|
+
if (!root.privacy) return data;
|
|
85
|
+
const safe = (await root.privacy.scan(data, relative, root.privacyScope)).data;
|
|
86
|
+
Object.defineProperty(safe,"snapshotIdentity",{value:`${before.dev}:${before.ino}:${before.size}:${before.mtimeMs}:${before.ctimeMs}`});
|
|
87
|
+
return safe;
|
|
79
88
|
} finally {
|
|
80
89
|
await fd.close();
|
|
81
90
|
}
|
|
@@ -105,6 +114,45 @@ async function list(root, relative = "", offset = 0, includeFiles = true) {
|
|
|
105
114
|
}
|
|
106
115
|
return { folderId: root.id, path: relative, parentPath: relative ? relative.split("/").slice(0, -1).join("/") : null, entries: page, offset, hasMore: offset + 200 < filtered.length };
|
|
107
116
|
}
|
|
117
|
+
// Owner-only browser operations. Absolute paths are returned only on this
|
|
118
|
+
// protocol, never in public roots or AI bindings.
|
|
119
|
+
async function ownerFileList(root, relative = "", offset = 0) {
|
|
120
|
+
const result = await list(root, relative, offset, true);
|
|
121
|
+
const entries = [];
|
|
122
|
+
for (const entry of result.entries) {
|
|
123
|
+
try {
|
|
124
|
+
const { absolute, stat } = await resolveSafe(root, entry.path);
|
|
125
|
+
entries.push({ ...entry, absolutePath: root.privacy ? `yep-private://${root.id}/${encodeURIComponent(entry.path)}` : absolute, isDirectory: stat.isDirectory(), size: stat.isFile() ? stat.size : 0, modifiedAt: stat.mtime.toISOString() });
|
|
126
|
+
} catch { /* A removed or inaccessible entry does not break the directory. */ }
|
|
127
|
+
}
|
|
128
|
+
return { ...result, nextOffset: result.hasMore ? offset + 200 : null, absolutePath: root.privacy ? `yep-private://${root.id}/${encodeURIComponent(relative)}` : (await resolveSafe(root, relative, "directory")).absolute, entries };
|
|
129
|
+
}
|
|
130
|
+
async function ownerFileRead(root, relative, offset = 0, version = "") {
|
|
131
|
+
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid file offset");
|
|
132
|
+
if (root.privacy) {
|
|
133
|
+
// Offsets and versions refer to the sanitized snapshot. Never slice the
|
|
134
|
+
// original first: a credential may cross a transport chunk boundary.
|
|
135
|
+
const epoch = root.privacy.epoch;
|
|
136
|
+
const data = await readFile(root, relative);
|
|
137
|
+
const identity = createHash("sha256").update(String(epoch)).update(data.snapshotIdentity).update(data).digest("hex");
|
|
138
|
+
if (version && version !== identity || offset > data.length) throw new Error("privacy_snapshot_changed");
|
|
139
|
+
return { name: path.basename(relative), size: data.length, offset, version: identity, data: data.subarray(offset, offset + 256 * 1024).toString("base64"), eof: offset + Math.min(256 * 1024, data.length - offset) === data.length };
|
|
140
|
+
}
|
|
141
|
+
const resolved = await resolveSafe(root, relative, "file");
|
|
142
|
+
const fd = await pinnedOpen(root, relative);
|
|
143
|
+
try {
|
|
144
|
+
const before = await fd.stat();
|
|
145
|
+
const identity = `${before.dev}:${before.ino}:${before.size}:${before.mtimeMs}:${before.ctimeMs}`;
|
|
146
|
+
if (before.ino !== resolved.stat.ino || before.dev !== resolved.stat.dev || !before.isFile() || version && version !== identity) throw new Error("File changed during download; please retry");
|
|
147
|
+
if (offset > before.size) throw new Error("Invalid file offset");
|
|
148
|
+
const buffer = Buffer.alloc(Math.min(256 * 1024, before.size - offset));
|
|
149
|
+
const { bytesRead } = await fd.read(buffer, 0, buffer.length, offset);
|
|
150
|
+
const after = await fd.stat();
|
|
151
|
+
if (`${after.dev}:${after.ino}:${after.size}:${after.mtimeMs}:${after.ctimeMs}` !== identity) throw new Error("File changed during download; please retry");
|
|
152
|
+
await resolveSafe(root, relative, "file");
|
|
153
|
+
return { name: path.basename(relative), size: before.size, offset, version: identity, data: buffer.subarray(0, bytesRead).toString("base64"), eof: offset + bytesRead === before.size };
|
|
154
|
+
} finally { await fd.close(); }
|
|
155
|
+
}
|
|
108
156
|
function utf8(data) {
|
|
109
157
|
const text = new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
110
158
|
if (text.includes("\0")) throw new Error("File is not UTF-8 text");
|
|
@@ -267,7 +315,8 @@ async function execute(root, request, { restricted = true, signal, secrets = [],
|
|
|
267
315
|
const result = await builtin(root, request.path ?? "", request.command);
|
|
268
316
|
if (signal?.aborted) throw new Error("Operation canceled");
|
|
269
317
|
let stdout = "";
|
|
270
|
-
|
|
318
|
+
const protectedText = root.privacy ? (await root.privacy.scan(Buffer.from(result.text), "command-output.txt", root.privacyScope)).data.toString("utf8") : result.text;
|
|
319
|
+
outputRedactor(secrets, text => { stdout += text; onOutput?.("stdout", text); })(protectedText, true, result.truncated);
|
|
271
320
|
return { stdout, stderr: "", exitCode: 0, truncated: result.truncated ?? false };
|
|
272
321
|
}
|
|
273
322
|
const { absolute } = await resolveSafe(root, request.path ?? "", "directory");
|
|
@@ -337,6 +386,8 @@ export {
|
|
|
337
386
|
execute,
|
|
338
387
|
fileOperation,
|
|
339
388
|
list,
|
|
389
|
+
ownerFileList,
|
|
390
|
+
ownerFileRead,
|
|
340
391
|
resolveSafe,
|
|
341
392
|
rootDescriptor,
|
|
342
393
|
safeRelative,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { PrivacyManager, privacyDirectory, privateWrite } from "./manager.js";
|
|
4
|
+
import { defaultPolicy } from "./worker.js";
|
|
5
|
+
import { rootDescriptor, fileOperation } from "../device/tools.js";
|
|
6
|
+
|
|
7
|
+
const help = `本地脱敏(不联网、不遥测、不修改原件)
|
|
8
|
+
yep privacy init
|
|
9
|
+
yep privacy rules
|
|
10
|
+
yep privacy validate --offline
|
|
11
|
+
yep privacy test --offline
|
|
12
|
+
yep privacy scan <文件或目录> --offline
|
|
13
|
+
yep privacy preview <文件> --offline
|
|
14
|
+
yep privacy export <文件> --out <脱敏副本> --offline
|
|
15
|
+
私有配置:~/.config/yep/privacy/policy.yaml 或 policy.json(只使用一个)。
|
|
16
|
+
规则修改无需重新编译或重新安装 CLI。`;
|
|
17
|
+
|
|
18
|
+
export async function runPrivacy(argv) {
|
|
19
|
+
const args = [...argv], command = args.shift();
|
|
20
|
+
if (!command || command === "--help") { console.log(help); return; }
|
|
21
|
+
let input, output;
|
|
22
|
+
while (args.length) { const value = args.shift(); if (value === "--offline") continue; if (value === "--out") { output = args.shift(); if (!output) throw new Error("--out 需要路径"); } else if (value.startsWith("-") || input) throw new Error("参数无效;请运行 yep privacy --help"); else input = value; }
|
|
23
|
+
if (command === "init") {
|
|
24
|
+
const location = path.join(privacyDirectory(), "policy.json");
|
|
25
|
+
for (const name of ["policy.json", "policy.yaml"]) { try { await fs.lstat(path.join(privacyDirectory(),name)); throw new Error("本地规则已存在,请直接编辑。" ); } catch (error) { if (error.code !== "ENOENT") throw error; } }
|
|
26
|
+
await privateWrite(location, JSON.stringify(defaultPolicy(), null, 2) + "\n"); console.log(`本地规则已创建:${location}`); return;
|
|
27
|
+
}
|
|
28
|
+
if (!["rules", "validate", "test", "scan", "preview", "export"].includes(command)) throw new Error("未知脱敏命令");
|
|
29
|
+
const manager = await PrivacyManager.create();
|
|
30
|
+
try {
|
|
31
|
+
if (command === "rules") { const response = await manager.worker.request("load", { configs: manager.localConfigs }); console.log(JSON.stringify({ baseline: "yep-privacy-default-v1", detectors: response.rules, localRules: manager.localConfigs.reduce((n,c) => n + (c.rules?.length ?? 0),0) },null,2)); return; }
|
|
32
|
+
if (command === "validate" || command === "test") { console.log("本地规则校验、编译与配置用例测试通过;原件未发送。"); return; }
|
|
33
|
+
if (!input) throw new Error("需要文件或目录路径");
|
|
34
|
+
const location = path.resolve(input);
|
|
35
|
+
async function scan(file) {
|
|
36
|
+
const root = await rootDescriptor("local-scan", path.dirname(file), manager.directory); root.privacy = manager;
|
|
37
|
+
const result = await fileOperation(root, { operation: "read", path: path.basename(file) }); return Buffer.from(result.data,"base64");
|
|
38
|
+
}
|
|
39
|
+
if (command === "preview" || command === "export") {
|
|
40
|
+
const data = await scan(location);
|
|
41
|
+
if (command === "preview") process.stdout.write(data);
|
|
42
|
+
else { if (!output || path.resolve(output) === location) throw new Error("--out 必须指定另一个路径,原件不能覆盖。"); const handle = await fs.open(path.resolve(output), "wx", 0o600); try { await handle.writeFile(data); } finally { await handle.close(); } console.log("已在本地生成导出副本,原件未修改、未上传。"); }
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
let visited = 0, blocked = 0;
|
|
46
|
+
async function visit(file) {
|
|
47
|
+
if (++visited > 10000) throw new Error("扫描条目超过上限");
|
|
48
|
+
const stat = await fs.lstat(file);
|
|
49
|
+
if (stat.isSymbolicLink() || !stat.isFile() && !stat.isDirectory()) { blocked++; return; }
|
|
50
|
+
try { await manager.checkPath(file); } catch { blocked++; console.log(`BLOCK ${path.relative(location,file) || path.basename(file)}`); return; }
|
|
51
|
+
if (stat.isDirectory()) { for (const entry of await fs.readdir(file)) await visit(path.join(file,entry)); }
|
|
52
|
+
else { try { await scan(file); console.log(`CHECKED ${path.relative(location,file) || path.basename(file)}`); } catch { blocked++; console.log(`BLOCK ${path.relative(location,file) || path.basename(file)}`); } }
|
|
53
|
+
}
|
|
54
|
+
await visit(location); console.log(`本地检查完成:${visited} 条,${blocked} 条阻断,未发送原件。`); if (blocked) process.exitCode = 2;
|
|
55
|
+
} finally { await manager.close(); }
|
|
56
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { createHash, createHmac, randomUUID } from "node:crypto";
|
|
5
|
+
import { PrivacyWorker, defaultPolicy, sessionKey } from "./worker.js";
|
|
6
|
+
import { protectPrivate, verifyPrivate } from "./permissions.js";
|
|
7
|
+
import { constants as FS } from "node:fs";
|
|
8
|
+
|
|
9
|
+
export function privacyDirectory() { return process.env.YEP_PRIVACY_CONFIG_DIR || path.join(process.env.YEP_CONFIG_DIR || path.join(process.env.YEP_HOME || os.homedir(), ".config", "yep"), "privacy"); }
|
|
10
|
+
export async function privateRead(location, maximum = 1024 * 1024) {
|
|
11
|
+
const before = await verifyPrivate(location);
|
|
12
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > maximum || process.platform !== "win32" && (before.mode & 0o077 || before.uid !== process.getuid())) throw new Error("privacy_local_config_unsafe");
|
|
13
|
+
const handle = await fs.open(location, FS.O_RDONLY | (FS.O_NOFOLLOW || 0));
|
|
14
|
+
try {
|
|
15
|
+
const st = await handle.stat(); if (st.ino !== before.ino || st.dev !== before.dev) throw new Error("privacy_local_config_changed");
|
|
16
|
+
const buffer = Buffer.alloc(before.size + 1);let size=0;
|
|
17
|
+
while(size<buffer.length){const read=await handle.read(buffer,size,buffer.length-size,size);if(!read.bytesRead)break;size+=read.bytesRead;}
|
|
18
|
+
const data=buffer.subarray(0,size); const after = await handle.stat();
|
|
19
|
+
if (data.length > maximum || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) throw new Error("privacy_local_config_changed");
|
|
20
|
+
return data;
|
|
21
|
+
} finally { await handle.close(); }
|
|
22
|
+
}
|
|
23
|
+
export async function privateWrite(location, data) {
|
|
24
|
+
const created = await fs.mkdir(path.dirname(location), { recursive: true, mode: 0o700 });
|
|
25
|
+
if (created) await protectPrivate(path.dirname(location), true);
|
|
26
|
+
await verifyPrivate(path.dirname(location), true);
|
|
27
|
+
const st = await fs.lstat(path.dirname(location));
|
|
28
|
+
if (!st.isDirectory() || st.isSymbolicLink() || process.platform !== "win32" && (st.mode & 0o077 || st.uid !== process.getuid())) throw new Error("privacy_local_config_unsafe");
|
|
29
|
+
const temporary = location + "." + randomUUID();
|
|
30
|
+
try { const handle = await fs.open(temporary, "wx", 0o600); try { await protectPrivate(temporary); await handle.writeFile(data); await handle.sync(); } finally { await handle.close(); } await fs.rename(temporary, location); if(process.platform!=="win32"){const directory=await fs.open(path.dirname(location),"r");try{await directory.sync();}finally{await directory.close();}} }
|
|
31
|
+
finally { await fs.unlink(temporary).catch(() => {}); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class PrivacyManager {
|
|
35
|
+
constructor(worker, directory) {
|
|
36
|
+
this.worker = worker; this.directory = path.resolve(directory); this.epoch = 0; this.ready = false; this.activeRevision = -1; this.desiredRevision = -1;
|
|
37
|
+
this.key = sessionKey(); this.publicConfigs = []; this.localConfigs = []; this.cache = new Map(); this.cacheBytes = 0; this.localFingerprint = "";
|
|
38
|
+
this.sequence = Promise.resolve(); this.localError = false; this.onChange = () => {}; this.onStatus=()=>{};
|
|
39
|
+
}
|
|
40
|
+
static async create({ directory = privacyDirectory() } = {}) {
|
|
41
|
+
const manager = new PrivacyManager(await PrivacyWorker.create(), directory);
|
|
42
|
+
try { await manager.reloadLocal(true); return manager; } catch (error) { await manager.close(); throw error; }
|
|
43
|
+
}
|
|
44
|
+
serial(job) { const result = this.sequence.then(() => { if (this.closed) throw new Error("privacy_manager_closed"); return job(); }); this.sequence = result.catch(() => {}); return result; }
|
|
45
|
+
invalidate() { this.ready = false; this.epoch++; this.cache.clear(); this.cacheBytes = 0; this.onChange(); }
|
|
46
|
+
async readLocal() {
|
|
47
|
+
let raw, filename;
|
|
48
|
+
for (const name of ["policy.yaml", "policy.json"]) {
|
|
49
|
+
try { const value = await privateRead(path.join(this.directory, name)); if (raw) throw new Error("privacy_multiple_local_policies"); raw = value; filename = name; }
|
|
50
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
51
|
+
}
|
|
52
|
+
if (!raw) return { configs: [], fingerprint: "none" };
|
|
53
|
+
const parsed = JSON.parse((await this.worker.request("parse", {}, raw)).data.toString("utf8"));
|
|
54
|
+
const contents = [raw];
|
|
55
|
+
for (const rule of parsed.rules ?? []) if (rule.match?.type === "dictionary" && rule.match.source) {
|
|
56
|
+
const source = rule.match.source;
|
|
57
|
+
if (typeof source !== "string" || source.includes("\\") || path.isAbsolute(source) || source.split("/").includes("..")) throw new Error("privacy_dictionary_path_invalid");
|
|
58
|
+
const location = path.resolve(this.directory, source);
|
|
59
|
+
for (let current = path.dirname(location); current !== path.dirname(this.directory); current = path.dirname(current)) {
|
|
60
|
+
const st = await fs.lstat(current); if (st.isSymbolicLink() || !st.isDirectory()) throw new Error("privacy_dictionary_path_invalid");
|
|
61
|
+
}
|
|
62
|
+
const data = await privateRead(location, 512 * 1024); contents.push(data);
|
|
63
|
+
const values = JSON.parse(data.toString("utf8")); if (!Array.isArray(values) || values.some(value => typeof value !== "string")) throw new Error("privacy_dictionary_invalid");
|
|
64
|
+
rule.match.values = values; delete rule.match.source;
|
|
65
|
+
}
|
|
66
|
+
return { configs: [parsed], fingerprint: createHash("sha256").update(filename).update(Buffer.concat(contents)).digest("hex") };
|
|
67
|
+
}
|
|
68
|
+
async reloadLocal(initial = false) {
|
|
69
|
+
return this.serial(async () => {
|
|
70
|
+
try {
|
|
71
|
+
const next = await this.readLocal(); if (!initial && next.fingerprint === this.localFingerprint && !this.localError) return;
|
|
72
|
+
this.invalidate();
|
|
73
|
+
await this.worker.request("load", { configs: [...this.publicConfigs, ...next.configs] });
|
|
74
|
+
if (this.closed) throw new Error("privacy_manager_closed");
|
|
75
|
+
this.localConfigs = next.configs; this.localFingerprint = next.fingerprint; this.localError = false;
|
|
76
|
+
this.ready = initial || this.activeRevision >= this.desiredRevision;
|
|
77
|
+
this.onStatus();
|
|
78
|
+
} catch { this.localError = true; this.invalidate(); this.onStatus(); throw new Error("privacy_local_policy_invalid"); }
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
watchLocal() {
|
|
82
|
+
if (this.watcher) return;
|
|
83
|
+
// Polling also catches atomic editor replacements and dictionary edits.
|
|
84
|
+
this.watcher = setInterval(() => { void this.reloadLocal().catch(() => {}); }, 3000); this.watcher.unref();
|
|
85
|
+
}
|
|
86
|
+
async apply(bundle, { cachePath } = {}) {
|
|
87
|
+
if (bundle.schemaVersion!==1 || !Number.isSafeInteger(bundle.revision) || bundle.revision < 0 || typeof bundle.content !== "string" || bundle.content.length > 2 * 1024 * 1024 || !/^[0-9a-f]{64}$/.test(bundle.contentSHA256) || createHash("sha256").update(bundle.content).digest("hex") !== bundle.contentSHA256) throw new Error("privacy_bundle_invalid");
|
|
88
|
+
if (bundle.revision < this.desiredRevision) return false;
|
|
89
|
+
this.desiredRevision = bundle.revision;
|
|
90
|
+
this.desiredHash = bundle.contentSHA256;
|
|
91
|
+
if (this.activeRevision === bundle.revision && this.activeHash === bundle.contentSHA256 && !this.localError && this.ready) return true;
|
|
92
|
+
this.invalidate(); const desired = bundle.revision;
|
|
93
|
+
return this.serial(async () => {
|
|
94
|
+
if (desired !== this.desiredRevision) return false;
|
|
95
|
+
const configs = JSON.parse(bundle.content);
|
|
96
|
+
if (!Array.isArray(configs) || configs.length > 32 || configs.some(config => (config.rules ?? []).some(rule => rule.match?.type === "dictionary" || rule.match?.source || rule.match?.values))) throw new Error("privacy_public_policy_invalid");
|
|
97
|
+
await this.worker.request("load", { configs: [...configs, ...this.localConfigs] });
|
|
98
|
+
if (this.closed) throw new Error("privacy_manager_closed");
|
|
99
|
+
if (desired !== this.desiredRevision) return false;
|
|
100
|
+
if (cachePath) await privateWrite(cachePath, JSON.stringify(bundle));
|
|
101
|
+
if (this.closed) throw new Error("privacy_manager_closed");
|
|
102
|
+
this.publicConfigs = configs; this.activeRevision = desired; this.activeHash = bundle.contentSHA256;
|
|
103
|
+
this.ready = !this.localError; this.epoch++; this.cache.clear(); this.cacheBytes = 0;
|
|
104
|
+
return this.ready;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
assertReady(requiredRevision = this.desiredRevision) {
|
|
108
|
+
if (this.closed || !this.ready || this.localError || this.worker.failed || requiredRevision > this.activeRevision) throw new Error("privacy_policy_not_ready");
|
|
109
|
+
}
|
|
110
|
+
async checkPath(relative) { this.assertReady(); await this.worker.request("path", { path: relative }); }
|
|
111
|
+
async metadata(text) {
|
|
112
|
+
const result = await this.scan(Buffer.from(text), "metadata.txt");
|
|
113
|
+
if (result.action !== "allow") throw new Error("privacy_metadata_blocked");
|
|
114
|
+
return text;
|
|
115
|
+
}
|
|
116
|
+
async scan(data, relative = "content.txt", scope = "") {
|
|
117
|
+
this.assertReady(); const epoch = this.epoch;
|
|
118
|
+
const hash = createHash("sha256").update(String(epoch)).update(scope).update(relative).update(data).digest("hex");
|
|
119
|
+
const cached = this.cache.get(hash); if (cached) return { ...cached, data: Buffer.from(cached.data) };
|
|
120
|
+
const key = createHmac("sha256", Buffer.from(this.key, "base64")).update(scope).digest("base64");
|
|
121
|
+
const result = await this.worker.request("scan", { path: relative, key }, data);
|
|
122
|
+
if (!this.ready || this.epoch !== epoch) throw new Error("privacy_policy_changed");
|
|
123
|
+
if (result.data.length <= 2 * 1024 * 1024) {
|
|
124
|
+
while (this.cache.size >= 32 || this.cacheBytes + result.data.length > 8 * 1024 * 1024) { const first = this.cache.keys().next().value; if (!first) break; this.cacheBytes -= this.cache.get(first).data.length; this.cache.delete(first); }
|
|
125
|
+
this.cache.set(hash, { ...result, data: Buffer.from(result.data) }); this.cacheBytes += result.data.length;
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
async close() {
|
|
130
|
+
this.closed = true;
|
|
131
|
+
clearInterval(this.watcher);
|
|
132
|
+
this.invalidate();
|
|
133
|
+
this.worker.close();
|
|
134
|
+
// A cache write already in progress must finish before callers remove the
|
|
135
|
+
// private directory or start another process using it.
|
|
136
|
+
await this.sequence;
|
|
137
|
+
this.ready = false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
function run(executable, args, env = process.env) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn(executable, args, { env, stdio: "ignore", windowsHide: true });
|
|
8
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error("privacy_local_config_unsafe")); }, 10000);
|
|
9
|
+
child.once("error", () => { clearTimeout(timer); reject(new Error("privacy_local_config_unsafe")); });
|
|
10
|
+
child.once("exit", code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error("privacy_local_config_unsafe")); });
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export async function protectPrivate(location, directory = false) {
|
|
14
|
+
if (process.platform !== "win32") return fs.chmod(location, directory ? 0o700 : 0o600);
|
|
15
|
+
const identity = process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` : process.env.USERNAME;
|
|
16
|
+
if (!identity) throw new Error("privacy_local_config_unsafe");
|
|
17
|
+
await run("icacls", [location, "/inheritance:r", "/grant:r", `${identity}:${directory ? "(OI)(CI)" : ""}F`]);
|
|
18
|
+
await verifyPrivate(location, directory);
|
|
19
|
+
}
|
|
20
|
+
export async function verifyPrivate(location, directory = false) {
|
|
21
|
+
const st = await fs.lstat(location);
|
|
22
|
+
if (st.isSymbolicLink() || (directory ? !st.isDirectory() : !st.isFile())) throw new Error("privacy_local_config_unsafe");
|
|
23
|
+
if (process.platform !== "win32") {
|
|
24
|
+
if (st.mode & 0o077 || st.uid !== process.getuid()) throw new Error("privacy_local_config_unsafe");
|
|
25
|
+
} else {
|
|
26
|
+
const script = "$ErrorActionPreference='Stop'; $acl=Get-Acl -LiteralPath $env:YEP_PRIVACY_ACL_PATH; if(-not $acl.AreAccessRulesProtected){exit 2}; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; foreach($rule in $acl.Access){if($rule.AccessControlType -eq 'Allow'){$who=$rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value; if($who -ne $sid -and $who -ne 'S-1-5-18' -and $who -ne 'S-1-5-32-544'){exit 3}}}; exit 0";
|
|
27
|
+
await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { ...process.env, YEP_PRIVACY_ACL_PATH: location });
|
|
28
|
+
}
|
|
29
|
+
for (let parent=path.dirname(location);;parent=path.dirname(parent)) {
|
|
30
|
+
const ancestor=await fs.lstat(parent);if(ancestor.isSymbolicLink()||!ancestor.isDirectory())throw new Error("privacy_local_config_unsafe");
|
|
31
|
+
if(path.dirname(parent)===parent)break;
|
|
32
|
+
}
|
|
33
|
+
return st;
|
|
34
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { findNativeBinary } from "../device/native-terminal.js";
|
|
4
|
+
|
|
5
|
+
export class PrivacyWorker {
|
|
6
|
+
constructor(binary) {
|
|
7
|
+
this.child = spawn(binary, ["privacy-worker"], { stdio: ["pipe", "pipe", "ignore"], windowsHide: true, env: { ...(process.platform === "win32" ? { SystemRoot: process.env.SystemRoot } : {}), GOMEMLIMIT: "256MiB", GOMAXPROCS: "2" } });
|
|
8
|
+
this.pending = new Map(); this.sequence = 0; this.buffer = Buffer.alloc(0); this.header = null; this.failed = false;
|
|
9
|
+
const failed = () => {
|
|
10
|
+
if (this.failed) return;
|
|
11
|
+
this.failed = true; this.buffer = Buffer.alloc(0);
|
|
12
|
+
for (const job of this.pending.values()) { clearTimeout(job.timer); job.reject(new Error("privacy_worker_unavailable")); }
|
|
13
|
+
this.pending.clear(); this.child.kill();
|
|
14
|
+
};
|
|
15
|
+
this.child.on("error", failed); this.child.on("exit", failed); this.child.stdin.on("error", failed);
|
|
16
|
+
this.child.stdout.on("data", chunk => {
|
|
17
|
+
if (this.failed) return;
|
|
18
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
19
|
+
if (this.buffer.length > 22 * 1024 * 1024) return failed();
|
|
20
|
+
try {
|
|
21
|
+
while (true) {
|
|
22
|
+
if (!this.header) {
|
|
23
|
+
if (this.buffer.length < 4) break;
|
|
24
|
+
const length = this.buffer.readUInt32BE(0);
|
|
25
|
+
if (length < 1 || length > 65536) return failed();
|
|
26
|
+
if (this.buffer.length < 4 + length) break;
|
|
27
|
+
this.header = JSON.parse(this.buffer.subarray(4, 4 + length).toString("utf8")); this.buffer = this.buffer.subarray(4 + length);
|
|
28
|
+
if (!Number.isSafeInteger(this.header.bytes) || this.header.bytes < 0 || this.header.bytes > 20 * 1024 * 1024) return failed();
|
|
29
|
+
}
|
|
30
|
+
if (this.buffer.length < this.header.bytes) break;
|
|
31
|
+
const response = this.header, data = Buffer.from(this.buffer.subarray(0, response.bytes));
|
|
32
|
+
this.buffer = this.buffer.subarray(response.bytes); this.header = null;
|
|
33
|
+
const job = this.pending.get(response.id); if (!job) return failed();
|
|
34
|
+
this.pending.delete(response.id); clearTimeout(job.timer);
|
|
35
|
+
if (response.error) job.reject(new Error(/^privacy_[a-z_]+$/.test(response.error) ? response.error : "privacy_scan_failed"));
|
|
36
|
+
else job.resolve({ ...response, data });
|
|
37
|
+
}
|
|
38
|
+
} catch { failed(); }
|
|
39
|
+
});
|
|
40
|
+
this.fail = failed;
|
|
41
|
+
}
|
|
42
|
+
request(operation, options = {}, data = Buffer.alloc(0)) {
|
|
43
|
+
if (this.failed || this.pending.size >= 8 || data.length > 10 * 1024 * 1024) return Promise.reject(new Error("privacy_worker_busy"));
|
|
44
|
+
const id = ++this.sequence;
|
|
45
|
+
const header = Buffer.from(JSON.stringify({ ...options, id, operation, bytes: data.length }));
|
|
46
|
+
if (header.length > 3 * 1024 * 1024) return Promise.reject(new Error("privacy_config_too_large"));
|
|
47
|
+
const prefix = Buffer.alloc(4); prefix.writeUInt32BE(header.length);
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const timer = setTimeout(() => this.fail(), 15000);
|
|
50
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
51
|
+
this.child.stdin.cork(); this.child.stdin.write(prefix); this.child.stdin.write(header); this.child.stdin.write(data); this.child.stdin.uncork();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
close() { this.fail(); }
|
|
55
|
+
static async create() {
|
|
56
|
+
const binary = await findNativeBinary();
|
|
57
|
+
if (!binary) throw new Error("privacy_native_component_missing: 请更新 CLI,当前平台缺少本地脱敏组件。");
|
|
58
|
+
return new PrivacyWorker(binary);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const defaultPolicy = () => ({ version: 1, extends: "yep-privacy-default-v1", profile: "strict", onFailure: "block", unsupportedFiles: "allow", rules: [] });
|
|
63
|
+
export const sessionKey = () => randomBytes(32).toString("base64");
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/native/manifest.json
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
|
-
"sourceSHA256": "
|
|
3
|
+
"sourceSHA256": "f50333c164d2c15cc8b72308e0d7660bb87298f086019cd7d596e878f1f1daca",
|
|
4
4
|
"files": {
|
|
5
5
|
"win32-x64/yep-node.exe": {
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "82ea2f49afca3d46defb4cbfe09158635e00c5f4fe8980633ba9909ddef16843",
|
|
7
7
|
"goos": "windows",
|
|
8
8
|
"goarch": "amd64"
|
|
9
9
|
},
|
|
10
10
|
"win32-arm64/yep-node.exe": {
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "3d974c4b22c97ea5ef2389d55d6daf3dffe27ed50bd54dd6cbcbf309001f2196",
|
|
12
12
|
"goos": "windows",
|
|
13
13
|
"goarch": "arm64"
|
|
14
14
|
},
|
|
15
15
|
"darwin-x64/yep-node": {
|
|
16
|
-
"sha256": "
|
|
16
|
+
"sha256": "d3379f423d57598382ca56018b206b4f6eddafd3f1e733cf22070fd9d27a5eda",
|
|
17
17
|
"goos": "darwin",
|
|
18
18
|
"goarch": "amd64"
|
|
19
19
|
},
|
|
20
20
|
"darwin-arm64/yep-node": {
|
|
21
|
-
"sha256": "
|
|
21
|
+
"sha256": "ec47bef2b4f5f221dc0363710e95bd7df412e509e411b93ec9b786c1473fea41",
|
|
22
22
|
"goos": "darwin",
|
|
23
23
|
"goarch": "arm64"
|
|
24
24
|
},
|
|
25
25
|
"linux-x64/yep-node": {
|
|
26
|
-
"sha256": "
|
|
26
|
+
"sha256": "5bc06697598bb6bb0d36b08bf0eabe877204c3f73f48cbfac675fdf1590d0499",
|
|
27
27
|
"goos": "linux",
|
|
28
28
|
"goarch": "amd64"
|
|
29
29
|
},
|
|
30
30
|
"linux-arm64/yep-node": {
|
|
31
|
-
"sha256": "
|
|
31
|
+
"sha256": "8eee868c965b7388b23935c9da6124cb2b59f9d03ff6d2ba36981e8d04fb2136",
|
|
32
32
|
"goos": "linux",
|
|
33
33
|
"goarch": "arm64"
|
|
34
34
|
}
|
|
Binary file
|
|
Binary file
|