@trim21/personal-pi-extensions 0.0.422 → 0.0.424
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/package.json +3 -1
- package/src/bwrap/approval-rules.ts +18 -57
- package/src/bwrap/approval-suggest.ts +53 -0
- package/src/bwrap/core.ts +102 -10
- package/src/bwrap/holder.js +61 -0
- package/src/bwrap/holder.ts +79 -0
- package/src/bwrap/network-stack.ts +230 -0
- package/src/bwrap/runtime.ts +57 -7
- package/src/bwrap/singbox-config.ts +77 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.424",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"format": "prettier --write .",
|
|
24
24
|
"test": "vitest run",
|
|
25
25
|
"test:coverage": "vitest run --coverage",
|
|
26
|
+
"build:holder": "esbuild src/bwrap/holder.ts --format=esm --target=node24 --outfile=src/bwrap/holder.js",
|
|
26
27
|
"prepare": "husky"
|
|
27
28
|
},
|
|
28
29
|
"peerDependencies": {
|
|
@@ -40,6 +41,7 @@
|
|
|
40
41
|
"@types/turndown": "^5.0.6",
|
|
41
42
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
42
43
|
"@vitest/coverage-v8": "^4.1.10",
|
|
44
|
+
"esbuild": "^0.28.2",
|
|
43
45
|
"eslint": "^10.8.1",
|
|
44
46
|
"eslint-config-prettier": "10.1.8",
|
|
45
47
|
"eslint-plugin-erasable-syntax-only": "0.7.1",
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* approval-rules —— bash
|
|
3
|
-
* 用 tree-sitter 解析命令(含嵌套 `$(...)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* approval-rules —— bash 命令匹配引擎(对齐 opencode 的权限方法):
|
|
3
|
+
* 用 tree-sitter 解析命令(含嵌套 `$(...)`),对每条命令的原文做通配
|
|
4
|
+
* 匹配,对 allow/deny 规则求值。规则匹配不经过任何命令归一——BashArity
|
|
5
|
+
* 归一模式只用于 "allow forever" 的建议规则(见 approval-suggest.ts)。
|
|
6
|
+
* 接入 bwrap 的 `dangerouslyDisableSandbox` 审批:命中规则自动
|
|
6
7
|
* 放行/拒绝,未命中才弹审批对话框。文件输出重定向(`>` / `>>` / `&>`
|
|
7
8
|
* 等)不会因命令规则自动放行,避免 `echo *` 把 `echo '' > file` 带过。
|
|
8
9
|
*
|
|
9
10
|
* 参考实现:
|
|
10
|
-
* - opencode packages/opencode/src/permission/arity.ts(BashArity 表)
|
|
11
11
|
* - opencode packages/core/src/util/wildcard.ts(通配匹配)
|
|
12
12
|
* - opencode packages/opencode/src/tool/shell.ts(tree-sitter 命令提取)
|
|
13
13
|
*/
|
|
@@ -173,34 +173,6 @@ export async function parseBashCommands(command: string): Promise<ParsedBash> {
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
// ── BashArity:命令前缀 → token 数(参考 opencode arity.ts)─────────────────
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* 命令前缀 → 定义该命令的 token 数。`git checkout main` → `git` 的 arity 2,
|
|
180
|
-
* 权限模式取前 2 个 token + `*`(`git checkout *`),避免具体参数进规则。
|
|
181
|
-
* 表来自 opencode packages/opencode/src/permission/arity.ts(Apache-2.0),
|
|
182
|
-
* 数据存放在 arity.json(所有 key 带引号)。
|
|
183
|
-
*/
|
|
184
|
-
const ARITY = JSON.parse(readFileSync(new URL("arity.json", import.meta.url), "utf8")) as Record<
|
|
185
|
-
string,
|
|
186
|
-
number
|
|
187
|
-
>;
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* 生成命令的权限模式:BashArity 前缀 + `*`。
|
|
191
|
-
* `git checkout main` → `git checkout *`;未收录的命令 → 命令名 + `*`。
|
|
192
|
-
*/
|
|
193
|
-
export function commandPattern(command: BashCommand): string {
|
|
194
|
-
const tokens = [command.name, ...command.args];
|
|
195
|
-
for (let len = tokens.length; len > 0; len--) {
|
|
196
|
-
const prefix = tokens.slice(0, len).join(" ");
|
|
197
|
-
const arity = ARITY[prefix];
|
|
198
|
-
if (arity !== undefined) return [...tokens.slice(0, arity), "*"].join(" ");
|
|
199
|
-
}
|
|
200
|
-
if (tokens.length === 0) return "*";
|
|
201
|
-
return [tokens[0], "*"].join(" ");
|
|
202
|
-
}
|
|
203
|
-
|
|
204
176
|
// ── 通配匹配(参考 opencode wildcard.ts)────────────────────────────────────
|
|
205
177
|
|
|
206
178
|
/**
|
|
@@ -218,31 +190,15 @@ export function matchRule(input: string, pattern: string): boolean {
|
|
|
218
190
|
|
|
219
191
|
// ── 规则求值 ────────────────────────────────────────────────────────────────
|
|
220
192
|
|
|
221
|
-
/**
|
|
222
|
-
* 命令(含所有嵌套命令)的权限模式列表(命令替换里的命令也展开)。
|
|
223
|
-
* 供规则求值与"allow forever"写规则复用。
|
|
224
|
-
*/
|
|
225
|
-
function patternsFromCommands(commands: BashCommand[]): string[] {
|
|
226
|
-
const flat: string[] = [];
|
|
227
|
-
const visit = (cmd: BashCommand) => {
|
|
228
|
-
flat.push(commandPattern(cmd));
|
|
229
|
-
for (const nested of cmd.nested) visit(nested);
|
|
230
|
-
};
|
|
231
|
-
for (const cmd of commands) visit(cmd);
|
|
232
|
-
return flat;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export async function commandPatternsFor(command: string): Promise<string[]> {
|
|
236
|
-
const parsed = await parseBashCommands(command);
|
|
237
|
-
return patternsFromCommands(parsed.commands);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
193
|
/**
|
|
241
194
|
* 对命令(含所有嵌套命令)求值:
|
|
242
195
|
* - deny 优先:任一命令命中 deny 规则即整体拒绝
|
|
243
196
|
* - 文件输出重定向不自动放行:即使命令规则全匹配,也返回 undefined 交人审
|
|
244
197
|
* - allow 需全量:所有命令都命中 allow 规则才整体放行,否则返回
|
|
245
198
|
* undefined(有命令未命中规则,交给人审),避免未允许的命令被同链放行带过。
|
|
199
|
+
* - 匹配输入是命令原文(tree-sitter command 节点 text,含嵌套逐条展开),
|
|
200
|
+
* 对齐 opencode shell.ts 的 patterns;通配规则按字面写,`--` 与普通
|
|
201
|
+
* token 无区别。
|
|
246
202
|
* 规则内后写优先(findLast,对齐 opencode PermissionV2)。
|
|
247
203
|
*/
|
|
248
204
|
export async function evaluateBashApproval(
|
|
@@ -250,14 +206,19 @@ export async function evaluateBashApproval(
|
|
|
250
206
|
rules: readonly ApprovalRule[],
|
|
251
207
|
): Promise<ApprovalAction | undefined> {
|
|
252
208
|
const parsed = await parseBashCommands(command);
|
|
253
|
-
const
|
|
254
|
-
|
|
209
|
+
const raws: string[] = [];
|
|
210
|
+
const visit = (cmd: BashCommand) => {
|
|
211
|
+
raws.push(cmd.raw);
|
|
212
|
+
for (const nested of cmd.nested) visit(nested);
|
|
213
|
+
};
|
|
214
|
+
for (const cmd of parsed.commands) visit(cmd);
|
|
215
|
+
if (raws.length === 0) return;
|
|
255
216
|
let allowed = 0;
|
|
256
|
-
for (const
|
|
257
|
-
const rule = rules.findLast((r) => matchRule(
|
|
217
|
+
for (const raw of raws) {
|
|
218
|
+
const rule = rules.findLast((r) => matchRule(raw, r.pattern));
|
|
258
219
|
if (rule?.action === "deny") return "deny";
|
|
259
220
|
if (rule?.action === "allow") allowed++;
|
|
260
221
|
}
|
|
261
222
|
if (parsed.hasFileOutputRedirect) return;
|
|
262
|
-
return allowed ===
|
|
223
|
+
return allowed === raws.length ? "allow" : undefined;
|
|
263
224
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* approval-suggest —— "allow forever" 的建议规则生成:
|
|
3
|
+
* 用 BashArity 表把命令归一成模式(`git checkout main` → `git checkout *`),
|
|
4
|
+
* 供用户选择"永久允许"时写入配置。归一模式不参与规则判定——匹配引擎
|
|
5
|
+
* (approval-rules)按命令原文做通配匹配。
|
|
6
|
+
*
|
|
7
|
+
* 参考实现:opencode packages/opencode/src/permission/arity.ts(Apache-2.0)
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
import { type BashCommand, parseBashCommands } from "./approval-rules.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 命令前缀 → 定义该命令的 token 数。`git checkout main` → `git` 的 arity 2,
|
|
15
|
+
* 权限模式取前 2 个 token + `*`(`git checkout *`),避免具体参数进规则。
|
|
16
|
+
* 表来自 opencode packages/opencode/src/permission/arity.ts(Apache-2.0),
|
|
17
|
+
* 数据存放在 arity.json(所有 key 带引号)。
|
|
18
|
+
*/
|
|
19
|
+
const ARITY = JSON.parse(readFileSync(new URL("arity.json", import.meta.url), "utf8")) as Record<
|
|
20
|
+
string,
|
|
21
|
+
number
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 生成命令的 BashArity 建议模式:BashArity 前缀 + `*`。
|
|
26
|
+
* `git checkout main` → `git checkout *`;未收录的命令 → 命令名 + `*`。
|
|
27
|
+
*/
|
|
28
|
+
export function commandPattern(command: BashCommand): string {
|
|
29
|
+
const tokens = [command.name, ...command.args];
|
|
30
|
+
for (let len = tokens.length; len > 0; len--) {
|
|
31
|
+
const prefix = tokens.slice(0, len).join(" ");
|
|
32
|
+
const arity = ARITY[prefix];
|
|
33
|
+
if (arity !== undefined) return [...tokens.slice(0, arity), "*"].join(" ");
|
|
34
|
+
}
|
|
35
|
+
if (tokens.length === 0) return "*";
|
|
36
|
+
return [tokens[0], "*"].join(" ");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 命令(含所有嵌套命令)的 BashArity 建议模式列表(命令替换里的命令也展开)。 */
|
|
40
|
+
function patternsFromCommands(commands: BashCommand[]): string[] {
|
|
41
|
+
const flat: string[] = [];
|
|
42
|
+
const visit = (cmd: BashCommand) => {
|
|
43
|
+
flat.push(commandPattern(cmd));
|
|
44
|
+
for (const nested of cmd.nested) visit(nested);
|
|
45
|
+
};
|
|
46
|
+
for (const cmd of commands) visit(cmd);
|
|
47
|
+
return flat;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function commandPatternsFor(command: string): Promise<string[]> {
|
|
51
|
+
const parsed = await parseBashCommands(command);
|
|
52
|
+
return patternsFromCommands(parsed.commands);
|
|
53
|
+
}
|
package/src/bwrap/core.ts
CHANGED
|
@@ -11,10 +11,17 @@ import { Value } from "typebox/value";
|
|
|
11
11
|
|
|
12
12
|
import { expandHome } from "../lib/path.js";
|
|
13
13
|
import { type ApprovalRule } from "./approval-rules.js";
|
|
14
|
+
import { type NetworkStack, resolveDnsServers, startNetworkStack } from "./network-stack.js";
|
|
14
15
|
|
|
15
16
|
const PROTECTED_DIRS = [".pi", ".agent"];
|
|
16
17
|
|
|
17
|
-
export const BWRAP_MODES = [
|
|
18
|
+
export const BWRAP_MODES = [
|
|
19
|
+
"allow-all",
|
|
20
|
+
"workspace-write",
|
|
21
|
+
"allow-net",
|
|
22
|
+
"net-allowlist",
|
|
23
|
+
"readonly",
|
|
24
|
+
] as const;
|
|
18
25
|
|
|
19
26
|
export type BwrapMode = (typeof BWRAP_MODES)[number];
|
|
20
27
|
|
|
@@ -25,6 +32,9 @@ const bwrapConfigProperties = {
|
|
|
25
32
|
extraWritablePaths: Type.Array(Type.String()),
|
|
26
33
|
tmpfsPaths: Type.Array(Type.String()),
|
|
27
34
|
extraArgs: Type.Array(Type.String()),
|
|
35
|
+
networkAllowlist: Type.Array(Type.String()),
|
|
36
|
+
singBoxPath: Type.Optional(Type.String()),
|
|
37
|
+
slirp4netnsPath: Type.Optional(Type.String()),
|
|
28
38
|
approvalRules: Type.Optional(
|
|
29
39
|
Type.Array(
|
|
30
40
|
Type.Object(
|
|
@@ -58,6 +68,10 @@ export interface ResolvedBwrap {
|
|
|
58
68
|
extraWritablePaths: string[];
|
|
59
69
|
tmpfsPaths: string[];
|
|
60
70
|
extraArgs: string[];
|
|
71
|
+
/** per-domain 过滤的允许域名(非空 = 启用 sing-box 网络过滤)。 */
|
|
72
|
+
networkAllowlist: string[];
|
|
73
|
+
singBoxPath?: string;
|
|
74
|
+
slirp4netnsPath?: string;
|
|
61
75
|
/** 全权限执行的自动审批规则(allow/deny 命令模式)。 */
|
|
62
76
|
approvalRules: ApprovalRule[];
|
|
63
77
|
}
|
|
@@ -68,6 +82,7 @@ const DEFAULT_CONFIG: BwrapConfig = {
|
|
|
68
82
|
extraWritablePaths: [],
|
|
69
83
|
tmpfsPaths: [],
|
|
70
84
|
extraArgs: [],
|
|
85
|
+
networkAllowlist: [],
|
|
71
86
|
};
|
|
72
87
|
|
|
73
88
|
export function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
|
|
@@ -78,6 +93,9 @@ export function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
|
|
|
78
93
|
extraWritablePaths: config.extraWritablePaths,
|
|
79
94
|
tmpfsPaths: config.tmpfsPaths ?? [],
|
|
80
95
|
extraArgs: config.extraArgs ?? [],
|
|
96
|
+
networkAllowlist: config.networkAllowlist ?? [],
|
|
97
|
+
singBoxPath: config.singBoxPath,
|
|
98
|
+
slirp4netnsPath: config.slirp4netnsPath,
|
|
81
99
|
approvalRules: config.approvalRules ?? [],
|
|
82
100
|
};
|
|
83
101
|
switch (config.mode) {
|
|
@@ -90,6 +108,9 @@ export function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
|
|
|
90
108
|
case "allow-net": {
|
|
91
109
|
return { ...base, bwrapEnabled: true, network: true };
|
|
92
110
|
}
|
|
111
|
+
case "net-allowlist": {
|
|
112
|
+
return { ...base, bwrapEnabled: true, network: true };
|
|
113
|
+
}
|
|
93
114
|
case "readonly": {
|
|
94
115
|
return { ...base, bwrapEnabled: true, network: false, writablePaths: [] };
|
|
95
116
|
}
|
|
@@ -115,6 +136,9 @@ function deepMerge(base: BwrapConfig, overrides: Partial<BwrapConfig>): BwrapCon
|
|
|
115
136
|
extraWritablePaths: [...base.extraWritablePaths, ...(overrides.extraWritablePaths ?? [])],
|
|
116
137
|
tmpfsPaths: overrides.tmpfsPaths ?? base.tmpfsPaths,
|
|
117
138
|
extraArgs: overrides.extraArgs ?? base.extraArgs,
|
|
139
|
+
networkAllowlist: overrides.networkAllowlist ?? base.networkAllowlist,
|
|
140
|
+
singBoxPath: overrides.singBoxPath ?? base.singBoxPath,
|
|
141
|
+
slirp4netnsPath: overrides.slirp4netnsPath ?? base.slirp4netnsPath,
|
|
118
142
|
approvalRules: [...(base.approvalRules ?? []), ...(overrides.approvalRules ?? [])],
|
|
119
143
|
};
|
|
120
144
|
}
|
|
@@ -190,6 +214,41 @@ export function findBwrap(override?: string): string {
|
|
|
190
214
|
return findDefaultBwrap();
|
|
191
215
|
}
|
|
192
216
|
|
|
217
|
+
function findCommandInPath(name: string, hint: string): string {
|
|
218
|
+
const pathEnv = process.env.PATH ?? "";
|
|
219
|
+
for (const directory of pathEnv.split(delimiter)) {
|
|
220
|
+
const candidate = join(directory, name);
|
|
221
|
+
if (existsSync(candidate)) return candidate;
|
|
222
|
+
}
|
|
223
|
+
throw new Error(hint);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function findSingBox(override?: string): string {
|
|
227
|
+
if (override) {
|
|
228
|
+
if (!existsSync(override)) {
|
|
229
|
+
throw new Error(`sing-box not found at configured path: ${override}`);
|
|
230
|
+
}
|
|
231
|
+
return override;
|
|
232
|
+
}
|
|
233
|
+
return findCommandInPath(
|
|
234
|
+
"sing-box",
|
|
235
|
+
"sing-box not found in PATH. Install it from https://sing-box.sagernet.org/",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function findSlirp4netns(override?: string): string {
|
|
240
|
+
if (override) {
|
|
241
|
+
if (!existsSync(override)) {
|
|
242
|
+
throw new Error(`slirp4netns not found at configured path: ${override}`);
|
|
243
|
+
}
|
|
244
|
+
return override;
|
|
245
|
+
}
|
|
246
|
+
return findCommandInPath(
|
|
247
|
+
"slirp4netns",
|
|
248
|
+
"slirp4netns not found in PATH. Install it from https://github.com/rootless-containers/slirp4netns",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
193
252
|
/** 扫描 .git 时跳过的目录:包/依赖/构建产物,嵌套 git 仓库几乎不会出现在这里。 */
|
|
194
253
|
const GIT_DIR_SCAN_SKIP = new Set([
|
|
195
254
|
"node_modules",
|
|
@@ -310,6 +369,19 @@ function killChild(child: ChildProcess): void {
|
|
|
310
369
|
}
|
|
311
370
|
}
|
|
312
371
|
|
|
372
|
+
/** 为 net-allowlist 模式创建常驻网络栈;非该模式返回 undefined。 */
|
|
373
|
+
export async function createNetworkStack(
|
|
374
|
+
resolved: ResolvedBwrap,
|
|
375
|
+
): Promise<NetworkStack | undefined> {
|
|
376
|
+
if (!resolved.network || resolved.networkAllowlist.length === 0) return undefined;
|
|
377
|
+
return startNetworkStack({
|
|
378
|
+
allowlist: resolved.networkAllowlist,
|
|
379
|
+
dnsServers: await resolveDnsServers(),
|
|
380
|
+
singBoxPath: findSingBox(resolved.singBoxPath),
|
|
381
|
+
slirp4netnsPath: findSlirp4netns(resolved.slirp4netnsPath),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
313
385
|
/**
|
|
314
386
|
* @param workspace session 工作区:writablePaths 的 "." 与 PROTECTED_DIRS 都基于它解析,
|
|
315
387
|
* 与当次命令的 cwd(仅作为进程执行目录)解耦,避免 workdir 参数漂移可写边界。
|
|
@@ -317,6 +389,7 @@ function killChild(child: ChildProcess): void {
|
|
|
317
389
|
export function createBwrapBashOperations(
|
|
318
390
|
resolved: ResolvedBwrap,
|
|
319
391
|
workspace: string,
|
|
392
|
+
networkStack?: NetworkStack,
|
|
320
393
|
): BashOperations {
|
|
321
394
|
// 沙箱内不透传 PATH,execvp 的默认路径可能找不到 bash(如 NixOS),故在父进程解析绝对路径
|
|
322
395
|
const shell = getShellConfig().shell;
|
|
@@ -333,7 +406,6 @@ export function createBwrapBashOperations(
|
|
|
333
406
|
throw new Error("HOME is not set; refusing to run bash in a clean environment");
|
|
334
407
|
}
|
|
335
408
|
|
|
336
|
-
const seccompFd = resolved.network ? undefined : getSeccompFd();
|
|
337
409
|
const baseArgs = [
|
|
338
410
|
"--ro-bind",
|
|
339
411
|
"/",
|
|
@@ -344,6 +416,33 @@ export function createBwrapBashOperations(
|
|
|
344
416
|
"--proc",
|
|
345
417
|
"/proc",
|
|
346
418
|
];
|
|
419
|
+
const env = {
|
|
420
|
+
HOME: home,
|
|
421
|
+
SHELL: "/bin/bash",
|
|
422
|
+
TERM: "dumb",
|
|
423
|
+
LANG: "C.UTF-8",
|
|
424
|
+
// 基础 PATH:profile 加载阶段(设置 PATH 前)需要系统命令(如 id),由 profile 随后覆盖;不含 sbin
|
|
425
|
+
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
if (resolved.network && resolved.networkAllowlist.length > 0) {
|
|
429
|
+
if (!networkStack) {
|
|
430
|
+
throw new Error("Network stack is not initialized for net-allowlist mode");
|
|
431
|
+
}
|
|
432
|
+
return networkStack.exec({
|
|
433
|
+
command,
|
|
434
|
+
cwd,
|
|
435
|
+
bwrapPath: findBwrap(resolved.bwrapPath),
|
|
436
|
+
bwrapArgs: baseArgs,
|
|
437
|
+
shell,
|
|
438
|
+
env,
|
|
439
|
+
onData,
|
|
440
|
+
signal,
|
|
441
|
+
timeout,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const seccompFd = resolved.network ? undefined : getSeccompFd();
|
|
347
446
|
const child = spawn(
|
|
348
447
|
findBwrap(resolved.bwrapPath),
|
|
349
448
|
seccompFd === undefined
|
|
@@ -356,14 +455,7 @@ export function createBwrapBashOperations(
|
|
|
356
455
|
seccompFd === undefined
|
|
357
456
|
? ["ignore", "pipe", "pipe"]
|
|
358
457
|
: ["ignore", "pipe", "pipe", seccompFd],
|
|
359
|
-
env
|
|
360
|
-
HOME: home,
|
|
361
|
-
SHELL: "/bin/bash",
|
|
362
|
-
TERM: "dumb",
|
|
363
|
-
LANG: "C.UTF-8",
|
|
364
|
-
// 基础 PATH:profile 加载阶段(设置 PATH 前)需要系统命令(如 id),由 profile 随后覆盖;不含 sbin
|
|
365
|
-
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
366
|
-
},
|
|
458
|
+
env,
|
|
367
459
|
},
|
|
368
460
|
);
|
|
369
461
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { open, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
|
+
const args = process.argv.slice(2);
|
|
5
|
+
const configPath = args[0];
|
|
6
|
+
const singBoxPath = args[1];
|
|
7
|
+
const readyFile = args[2];
|
|
8
|
+
if (!configPath || !singBoxPath || !readyFile) {
|
|
9
|
+
process.exit(2);
|
|
10
|
+
}
|
|
11
|
+
function tap0Exists() {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const child = spawn("ip", ["link", "show", "tap0"], { stdio: "ignore" });
|
|
14
|
+
child.once("exit", (code) => resolve(code === 0));
|
|
15
|
+
child.once("error", () => resolve(false));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async function waitFor(attempts, delayMs, predicate) {
|
|
19
|
+
for (let i = 0; i < attempts; i++) {
|
|
20
|
+
if (await predicate()) return true;
|
|
21
|
+
await sleep(delayMs);
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
async function main() {
|
|
26
|
+
await waitFor(100, 100, tap0Exists);
|
|
27
|
+
const logPath = `${configPath}.log`;
|
|
28
|
+
const logFd = await open(logPath, "w");
|
|
29
|
+
const singbox = spawn(singBoxPath, ["run", "-c", configPath], {
|
|
30
|
+
env: { ...process.env, ENABLE_DEPRECATED_MISSING_DOMAIN_RESOLVER: "true" },
|
|
31
|
+
stdio: ["ignore", logFd.fd, logFd.fd]
|
|
32
|
+
});
|
|
33
|
+
const stop = () => {
|
|
34
|
+
singbox.kill("SIGTERM");
|
|
35
|
+
};
|
|
36
|
+
process.on("SIGTERM", () => {
|
|
37
|
+
stop();
|
|
38
|
+
process.exit(0);
|
|
39
|
+
});
|
|
40
|
+
process.on("SIGINT", () => {
|
|
41
|
+
stop();
|
|
42
|
+
process.exit(0);
|
|
43
|
+
});
|
|
44
|
+
await waitFor(200, 100, async () => {
|
|
45
|
+
try {
|
|
46
|
+
const log = await readFile(logPath, "utf8");
|
|
47
|
+
return log.includes("sing-box started");
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
await sleep(4e3);
|
|
53
|
+
await writeFile(readyFile, "");
|
|
54
|
+
await new Promise((resolve) => singbox.once("exit", () => resolve()));
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await main();
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error(error);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/* eslint-disable no-console, unicorn/no-process-exit -- 独立 CLI 脚本,由 unshare 直接执行 */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { open, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
|
|
6
|
+
// 用法:unshare -Urn node holder.ts <config> <singbox> <ready-file>
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const configPath = args[0];
|
|
9
|
+
const singBoxPath = args[1];
|
|
10
|
+
const readyFile = args[2];
|
|
11
|
+
if (!configPath || !singBoxPath || !readyFile) {
|
|
12
|
+
process.exit(2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function tap0Exists(): Promise<boolean> {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const child = spawn("ip", ["link", "show", "tap0"], { stdio: "ignore" });
|
|
18
|
+
child.once("exit", (code) => resolve(code === 0));
|
|
19
|
+
child.once("error", () => resolve(false));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function waitFor(
|
|
24
|
+
attempts: number,
|
|
25
|
+
delayMs: number,
|
|
26
|
+
predicate: () => Promise<boolean>,
|
|
27
|
+
): Promise<boolean> {
|
|
28
|
+
for (let i = 0; i < attempts; i++) {
|
|
29
|
+
if (await predicate()) return true;
|
|
30
|
+
await sleep(delayMs);
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function main(): Promise<void> {
|
|
36
|
+
// slirp4netns 由宿主侧 spawn,这里等它建好 tap0
|
|
37
|
+
await waitFor(100, 100, tap0Exists);
|
|
38
|
+
|
|
39
|
+
const logPath = `${configPath}.log`;
|
|
40
|
+
const logFd = await open(logPath, "w");
|
|
41
|
+
const singbox = spawn(singBoxPath, ["run", "-c", configPath], {
|
|
42
|
+
env: { ...process.env, ENABLE_DEPRECATED_MISSING_DOMAIN_RESOLVER: "true" },
|
|
43
|
+
stdio: ["ignore", logFd.fd, logFd.fd],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const stop = (): void => {
|
|
47
|
+
singbox.kill("SIGTERM");
|
|
48
|
+
};
|
|
49
|
+
process.on("SIGTERM", () => {
|
|
50
|
+
stop();
|
|
51
|
+
process.exit(0);
|
|
52
|
+
});
|
|
53
|
+
process.on("SIGINT", () => {
|
|
54
|
+
stop();
|
|
55
|
+
process.exit(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// tun0 设备在 sing-box 启动早期就创建,但 gvisor 栈初始化仍需时间;
|
|
59
|
+
// 以 "sing-box started" 日志作为进程就绪标志,再等待栈初始化完成
|
|
60
|
+
await waitFor(200, 100, async () => {
|
|
61
|
+
try {
|
|
62
|
+
const log = await readFile(logPath, "utf8");
|
|
63
|
+
return log.includes("sing-box started");
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
await sleep(4000);
|
|
69
|
+
await writeFile(readyFile, "");
|
|
70
|
+
|
|
71
|
+
await new Promise<void>((resolve) => singbox.once("exit", () => resolve()));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
await main();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error(error);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, mkdtemp, readFile, readlink, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { generateSingboxConfig } from "./singbox-config.js";
|
|
8
|
+
|
|
9
|
+
export interface NetworkStackOptions {
|
|
10
|
+
/** 允许直连的域名列表(session 启动时固定,reload 才会重新加载)。 */
|
|
11
|
+
readonly allowlist: readonly string[];
|
|
12
|
+
/** 真实 DNS 服务器列表,按顺序 fallback。 */
|
|
13
|
+
readonly dnsServers: readonly string[];
|
|
14
|
+
readonly singBoxPath: string;
|
|
15
|
+
readonly slirp4netnsPath: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface NetworkStackExecOptions {
|
|
19
|
+
readonly command: string;
|
|
20
|
+
readonly cwd: string;
|
|
21
|
+
readonly bwrapPath: string;
|
|
22
|
+
/** bwrap 的完整参数(不含 -- 后的 shell 与命令),由 core.ts 组装。 */
|
|
23
|
+
readonly bwrapArgs: readonly string[];
|
|
24
|
+
readonly shell: string;
|
|
25
|
+
readonly env: Readonly<Record<string, string>>;
|
|
26
|
+
readonly onData: (data: Buffer) => void;
|
|
27
|
+
readonly signal?: AbortSignal;
|
|
28
|
+
readonly timeout?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const NAMESERVER_PATTERN = /^\s*nameserver\s+(\S+)/;
|
|
32
|
+
const HOLDER_PATH = fileURLToPath(new URL("holder.js", import.meta.url));
|
|
33
|
+
// ip 通常位于 /usr/sbin 或 /sbin,进程默认 PATH 不含它们;node 则依赖宿主完整 PATH
|
|
34
|
+
const SBIN_PATH_SUFFIX = "/usr/local/sbin:/usr/sbin:/sbin";
|
|
35
|
+
|
|
36
|
+
function killProcess(pid: number | undefined): void {
|
|
37
|
+
if (!pid) return;
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, "SIGTERM");
|
|
40
|
+
} catch {
|
|
41
|
+
// 已退出
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 读宿主机 /etc/resolv.conf 的全部 IPv4 nameserver,按声明顺序返回。 */
|
|
46
|
+
export async function resolveDnsServers(): Promise<string[]> {
|
|
47
|
+
const content = await readFile("/etc/resolv.conf", "utf8");
|
|
48
|
+
const nameservers = content
|
|
49
|
+
.split("\n")
|
|
50
|
+
.map((line) => NAMESERVER_PATTERN.exec(line)?.[1])
|
|
51
|
+
.filter((value): value is string => value !== undefined);
|
|
52
|
+
// slirp4netns 出口仅 IPv4,IPv6 nameserver 无法出站,故只保留 IPv4
|
|
53
|
+
const ipv4 = nameservers.filter((value) => !value.includes(":"));
|
|
54
|
+
if (ipv4.length === 0) {
|
|
55
|
+
throw new Error("No IPv4 nameserver found in /etc/resolv.conf");
|
|
56
|
+
}
|
|
57
|
+
return ipv4;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 等待目标进程进入新的 user namespace(unshare 完成),返回后 slirp4netns 才能 setns。 */
|
|
61
|
+
async function waitForNewUserns(pid: number, timeoutMs = 5000): Promise<void> {
|
|
62
|
+
const self = await readlink("/proc/self/ns/user");
|
|
63
|
+
const deadline = Date.now() + timeoutMs;
|
|
64
|
+
while (Date.now() < deadline) {
|
|
65
|
+
try {
|
|
66
|
+
if ((await readlink(`/proc/${pid}/ns/user`)) !== self) return;
|
|
67
|
+
} catch {
|
|
68
|
+
// /proc/<pid> 尚未就绪,重试
|
|
69
|
+
}
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
71
|
+
}
|
|
72
|
+
throw new Error("Timed out waiting for sandbox network namespace");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function waitForFile(path: string, timeoutMs = 15000): Promise<void> {
|
|
76
|
+
const deadline = Date.now() + timeoutMs;
|
|
77
|
+
while (Date.now() < deadline) {
|
|
78
|
+
try {
|
|
79
|
+
await access(path);
|
|
80
|
+
return;
|
|
81
|
+
} catch {
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw new Error("Timed out waiting for sandbox network stack to become ready");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function killChild(pid: number | undefined): void {
|
|
89
|
+
if (!pid) return;
|
|
90
|
+
try {
|
|
91
|
+
process.kill(-pid, "SIGKILL");
|
|
92
|
+
} catch {
|
|
93
|
+
try {
|
|
94
|
+
process.kill(pid, "SIGKILL");
|
|
95
|
+
} catch {
|
|
96
|
+
// 已退出
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface NetworkStack {
|
|
102
|
+
exec(options: NetworkStackExecOptions): Promise<{ exitCode: number | null }>;
|
|
103
|
+
stop(): Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface NetworkStackState {
|
|
107
|
+
holderPid: number;
|
|
108
|
+
slirpPid: number | undefined;
|
|
109
|
+
directory: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 兜底:调用方忘记 stop() 时,对象被 GC 回收后 kill 残留进程并清理目录。 */
|
|
113
|
+
const stackFinalizer = new FinalizationRegistry<NetworkStackState>((state) => {
|
|
114
|
+
killProcess(state.slirpPid);
|
|
115
|
+
killProcess(state.holderPid);
|
|
116
|
+
void rm(state.directory, { recursive: true, force: true }).catch(() => false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 启动常驻的 per-session 网络栈:holder 进程持有 netns(内部跑 sing-box 的
|
|
121
|
+
* TUN + fakeip + deny-by-default),slirp4netns 提供 egress。返回前
|
|
122
|
+
* netns/sing-box/slirp4netns 均已就绪,命令通过 nsenter 进入该 netns 执行。
|
|
123
|
+
*/
|
|
124
|
+
export async function startNetworkStack(options: NetworkStackOptions): Promise<NetworkStack> {
|
|
125
|
+
const { allowlist, dnsServers, singBoxPath, slirp4netnsPath } = options;
|
|
126
|
+
const directory = await mkdtemp(join(tmpdir(), "pi-netns-"));
|
|
127
|
+
const configPath = join(directory, "singbox.json");
|
|
128
|
+
const readyFile = join(directory, "ready");
|
|
129
|
+
await writeFile(configPath, generateSingboxConfig({ allowlist, dnsServers }));
|
|
130
|
+
|
|
131
|
+
const holder = spawn(
|
|
132
|
+
"unshare",
|
|
133
|
+
["-Urn", "--", "node", HOLDER_PATH, configPath, singBoxPath, readyFile],
|
|
134
|
+
{
|
|
135
|
+
env: {
|
|
136
|
+
HOME: process.env.HOME ?? "",
|
|
137
|
+
PATH: `${process.env.PATH ?? ""}:${SBIN_PATH_SUFFIX}`,
|
|
138
|
+
},
|
|
139
|
+
stdio: "ignore",
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
if (holder.pid === undefined) {
|
|
143
|
+
throw new Error("Failed to start network namespace holder");
|
|
144
|
+
}
|
|
145
|
+
const holderPid = holder.pid;
|
|
146
|
+
await waitForNewUserns(holderPid);
|
|
147
|
+
|
|
148
|
+
const slirp = spawn(
|
|
149
|
+
slirp4netnsPath,
|
|
150
|
+
[
|
|
151
|
+
"-c",
|
|
152
|
+
`--userns-path=/proc/${holderPid}/ns/user`,
|
|
153
|
+
"--netns-type=pid",
|
|
154
|
+
String(holderPid),
|
|
155
|
+
"tap0",
|
|
156
|
+
],
|
|
157
|
+
{ stdio: "ignore" },
|
|
158
|
+
);
|
|
159
|
+
const slirpPid = slirp.pid;
|
|
160
|
+
await waitForFile(readyFile);
|
|
161
|
+
|
|
162
|
+
const state: NetworkStackState = { holderPid, slirpPid, directory };
|
|
163
|
+
const stack: NetworkStack = {
|
|
164
|
+
exec: async (execOptions: NetworkStackExecOptions) => {
|
|
165
|
+
const child = spawn(
|
|
166
|
+
"nsenter",
|
|
167
|
+
[
|
|
168
|
+
"-U",
|
|
169
|
+
"-n",
|
|
170
|
+
"--preserve-credentials",
|
|
171
|
+
"-t",
|
|
172
|
+
String(holderPid),
|
|
173
|
+
"--",
|
|
174
|
+
execOptions.bwrapPath,
|
|
175
|
+
...execOptions.bwrapArgs,
|
|
176
|
+
"--",
|
|
177
|
+
execOptions.shell,
|
|
178
|
+
"-lc",
|
|
179
|
+
execOptions.command,
|
|
180
|
+
],
|
|
181
|
+
{
|
|
182
|
+
cwd: execOptions.cwd,
|
|
183
|
+
detached: true,
|
|
184
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
185
|
+
env: execOptions.env,
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return new Promise<{ exitCode: number | null }>((resolve, reject) => {
|
|
190
|
+
let timedOut = false;
|
|
191
|
+
let settled = false;
|
|
192
|
+
const timeoutHandle = execOptions.timeout
|
|
193
|
+
? setTimeout(() => {
|
|
194
|
+
timedOut = true;
|
|
195
|
+
killChild(child.pid);
|
|
196
|
+
}, execOptions.timeout * 1000)
|
|
197
|
+
: undefined;
|
|
198
|
+
const onAbort = (): void => {
|
|
199
|
+
killChild(child.pid);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
child.stdout?.on("data", execOptions.onData);
|
|
203
|
+
child.stderr?.on("data", execOptions.onData);
|
|
204
|
+
execOptions.signal?.addEventListener("abort", onAbort, { once: true });
|
|
205
|
+
|
|
206
|
+
child.once("error", (error) => {
|
|
207
|
+
if (settled) return;
|
|
208
|
+
settled = true;
|
|
209
|
+
reject(error);
|
|
210
|
+
});
|
|
211
|
+
child.once("close", (exitCode) => {
|
|
212
|
+
if (settled) return;
|
|
213
|
+
settled = true;
|
|
214
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
215
|
+
execOptions.signal?.removeEventListener("abort", onAbort);
|
|
216
|
+
if (execOptions.signal?.aborted) reject(new Error("aborted"));
|
|
217
|
+
else if (timedOut) reject(new Error(`timeout:${execOptions.timeout}`));
|
|
218
|
+
else resolve({ exitCode });
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
stop: async () => {
|
|
223
|
+
killProcess(state.slirpPid);
|
|
224
|
+
killProcess(state.holderPid);
|
|
225
|
+
await rm(state.directory, { recursive: true, force: true }).catch(() => false);
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
stackFinalizer.register(stack, state);
|
|
229
|
+
return stack;
|
|
230
|
+
}
|
package/src/bwrap/runtime.ts
CHANGED
|
@@ -23,15 +23,12 @@ import {
|
|
|
23
23
|
selectCheckboxActions,
|
|
24
24
|
selectWithOptionalInput,
|
|
25
25
|
} from "../lib/ui.js";
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
commandPatternsFor,
|
|
29
|
-
evaluateBashApproval,
|
|
30
|
-
matchRule,
|
|
31
|
-
} from "./approval-rules.js";
|
|
26
|
+
import { type ApprovalRule, evaluateBashApproval, matchRule } from "./approval-rules.js";
|
|
27
|
+
import { commandPatternsFor } from "./approval-suggest.js";
|
|
32
28
|
import {
|
|
33
29
|
type BwrapMode,
|
|
34
30
|
createBwrapBashOperations,
|
|
31
|
+
createNetworkStack,
|
|
35
32
|
findBwrap,
|
|
36
33
|
getBwrapConfigPaths,
|
|
37
34
|
loadBwrapConfig,
|
|
@@ -41,6 +38,7 @@ import {
|
|
|
41
38
|
resolveHeadlessBwrap,
|
|
42
39
|
} from "./core.js";
|
|
43
40
|
import { dcgSuggestion } from "./dcg-scan.js";
|
|
41
|
+
import { type NetworkStack } from "./network-stack.js";
|
|
44
42
|
|
|
45
43
|
export type EscalationDecision = { kind: "dialog" } | { kind: "deny"; reason: string };
|
|
46
44
|
|
|
@@ -234,6 +232,7 @@ function notifyMode(
|
|
|
234
232
|
"allow-all": "allow-all: sandbox off, network on",
|
|
235
233
|
"workspace-write": "workspace-write: sandbox on, network off",
|
|
236
234
|
"allow-net": "allow-net: sandbox on, network on, workspace writable",
|
|
235
|
+
"net-allowlist": "net-allowlist: sandbox on, network filtered by domain allowlist",
|
|
237
236
|
readonly: "readonly: sandbox on, network off, read-only fs",
|
|
238
237
|
};
|
|
239
238
|
ctx.ui.notify(labels[mode], "info");
|
|
@@ -243,6 +242,7 @@ export class BwrapRuntime {
|
|
|
243
242
|
private resolved: ResolvedBwrap | undefined;
|
|
244
243
|
private sandboxDisabled = false;
|
|
245
244
|
private bwrapUnavailable = false;
|
|
245
|
+
private networkStack: NetworkStack | undefined;
|
|
246
246
|
|
|
247
247
|
setup(pi: ExtensionAPI): void {
|
|
248
248
|
pi.registerFlag("no-bwrap", {
|
|
@@ -315,6 +315,8 @@ export class BwrapRuntime {
|
|
|
315
315
|
setMode(cwd: string, mode: BwrapMode): ResolvedBwrap {
|
|
316
316
|
this.resolved = resolveBwrap({ ...loadBwrapConfig(cwd), mode });
|
|
317
317
|
this.sandboxDisabled = false;
|
|
318
|
+
void this.networkStack?.stop();
|
|
319
|
+
this.networkStack = undefined;
|
|
318
320
|
return this.resolved;
|
|
319
321
|
}
|
|
320
322
|
|
|
@@ -322,6 +324,8 @@ export class BwrapRuntime {
|
|
|
322
324
|
this.resolved = undefined;
|
|
323
325
|
this.sandboxDisabled = false;
|
|
324
326
|
this.bwrapUnavailable = false;
|
|
327
|
+
void this.networkStack?.stop();
|
|
328
|
+
this.networkStack = undefined;
|
|
325
329
|
}
|
|
326
330
|
|
|
327
331
|
async execute(request: BwrapExecutionRequest): Promise<BwrapExecutionResult> {
|
|
@@ -360,7 +364,7 @@ export class BwrapRuntime {
|
|
|
360
364
|
const operations =
|
|
361
365
|
isWindows || needsApproval || !runtime.bwrapEnabled
|
|
362
366
|
? createLocalBashOperations()
|
|
363
|
-
: createBwrapBashOperations(runtime, workspace);
|
|
367
|
+
: createBwrapBashOperations(runtime, workspace, await this.getNetworkStack(runtime));
|
|
364
368
|
const output = new BashOutput(request.ctx.sessionManager.getSessionId());
|
|
365
369
|
const { onUpdate } = request;
|
|
366
370
|
|
|
@@ -450,6 +454,21 @@ export class BwrapRuntime {
|
|
|
450
454
|
return this.resolved;
|
|
451
455
|
}
|
|
452
456
|
|
|
457
|
+
/** net-allowlist 模式的常驻网络栈:惰性创建,session 期间复用(allowlist 固定)。 */
|
|
458
|
+
private async getNetworkStack(runtime: ResolvedBwrap): Promise<NetworkStack | undefined> {
|
|
459
|
+
if (runtime.mode !== "net-allowlist" || runtime.networkAllowlist.length === 0) {
|
|
460
|
+
if (this.networkStack) {
|
|
461
|
+
void this.networkStack.stop();
|
|
462
|
+
this.networkStack = undefined;
|
|
463
|
+
}
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
if (!this.networkStack) {
|
|
467
|
+
this.networkStack = await createNetworkStack(runtime);
|
|
468
|
+
}
|
|
469
|
+
return this.networkStack;
|
|
470
|
+
}
|
|
471
|
+
|
|
453
472
|
private async approveFullAccess(
|
|
454
473
|
ctx: ExtensionContext,
|
|
455
474
|
command: string,
|
|
@@ -650,12 +669,24 @@ export class BwrapRuntime {
|
|
|
650
669
|
description: "Sandbox on, network on, workspace writable",
|
|
651
670
|
flags: Type.Object({}),
|
|
652
671
|
},
|
|
672
|
+
"bwrap-net-allowlist": {
|
|
673
|
+
name: "bwrap-net-allowlist",
|
|
674
|
+
usage: "",
|
|
675
|
+
description: "Sandbox on, network filtered by domain allowlist, workspace writable",
|
|
676
|
+
flags: Type.Object({}),
|
|
677
|
+
},
|
|
653
678
|
"bwrap-readonly": {
|
|
654
679
|
name: "bwrap-readonly",
|
|
655
680
|
usage: "",
|
|
656
681
|
description: "Sandbox on, network off, no writes",
|
|
657
682
|
flags: Type.Object({}),
|
|
658
683
|
},
|
|
684
|
+
"bwrap-reload": {
|
|
685
|
+
name: "bwrap-reload",
|
|
686
|
+
usage: "",
|
|
687
|
+
description: "Reload bwrap config and restart the network stack",
|
|
688
|
+
flags: Type.Object({}),
|
|
689
|
+
},
|
|
659
690
|
} as const satisfies Record<string, CommandSpec<TObject>>;
|
|
660
691
|
|
|
661
692
|
pi.registerCommand("bwrap", {
|
|
@@ -689,6 +720,7 @@ export class BwrapRuntime {
|
|
|
689
720
|
["bwrap-allow-all", "allow-all"],
|
|
690
721
|
["bwrap-workspace-write", "workspace-write"],
|
|
691
722
|
["bwrap-allow-net", "allow-net"],
|
|
723
|
+
["bwrap-net-allowlist", "net-allowlist"],
|
|
692
724
|
["bwrap-readonly", "readonly"],
|
|
693
725
|
] as const) {
|
|
694
726
|
pi.registerCommand(name, {
|
|
@@ -699,6 +731,24 @@ export class BwrapRuntime {
|
|
|
699
731
|
),
|
|
700
732
|
});
|
|
701
733
|
}
|
|
734
|
+
|
|
735
|
+
pi.registerCommand("bwrap-reload", {
|
|
736
|
+
description: specs["bwrap-reload"].description,
|
|
737
|
+
handler: (args, ctx) =>
|
|
738
|
+
this.runCommand(pi, specs["bwrap-reload"], args, ctx, (commandCtx) => {
|
|
739
|
+
this.reload(commandCtx);
|
|
740
|
+
}),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private reload(ctx: ExtensionCommandContext): void {
|
|
745
|
+
this.resolved = undefined;
|
|
746
|
+
this.bwrapUnavailable = false;
|
|
747
|
+
void this.networkStack?.stop();
|
|
748
|
+
this.networkStack = undefined;
|
|
749
|
+
const runtime = this.resolve(ctx);
|
|
750
|
+
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", `bwrap: ${runtime.mode}`));
|
|
751
|
+
ctx.ui.notify(`bwrap config reloaded (mode: ${runtime.mode})`, "info");
|
|
702
752
|
}
|
|
703
753
|
|
|
704
754
|
private switchMode(pi: ExtensionAPI, mode: BwrapMode, ctx: ExtensionCommandContext): void {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const FAKEIP_RANGE = "198.18.0.0/15";
|
|
2
|
+
const TUN_ADDRESS = "198.18.0.1/16";
|
|
3
|
+
const EGRESS_INTERFACE = "tap0";
|
|
4
|
+
|
|
5
|
+
export interface SingboxConfigOptions {
|
|
6
|
+
/** 允许直连的域名列表(空列表 = 默认拒绝一切出网)。 */
|
|
7
|
+
readonly allowlist: readonly string[];
|
|
8
|
+
/** 真实 DNS 服务器地址列表(IPv4,UDP 53),按顺序 fallback,至少一个。 */
|
|
9
|
+
readonly dnsServers: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 生成 sing-box 1.13 配置:TUN + fakeip + deny-by-default allowlist。
|
|
14
|
+
*
|
|
15
|
+
* - 出站绑定到 slirp4netns 的 tap0,避免 auto_route 把 sing-box 自身出站
|
|
16
|
+
* 流量(尤其 DNS 解析)重新路由回 TUN 形成环。
|
|
17
|
+
* - 真实解析不设 domain_resolver(它只接受单个 server 且绕过规则),而是
|
|
18
|
+
* 用 DNS rules 的顺序 fallback 链:前 N-1 个 nameserver 带 ip_accept_any
|
|
19
|
+
* 过滤(响应不合格时落到下一条),最后一个作为 dns.final 兜底。
|
|
20
|
+
* 该用法在 1.13 已标记 deprecated,运行需设置
|
|
21
|
+
* ENABLE_DEPRECATED_MISSING_DOMAIN_RESOLVER=true。
|
|
22
|
+
*/
|
|
23
|
+
export function generateSingboxConfig(options: SingboxConfigOptions): string {
|
|
24
|
+
const { allowlist, dnsServers } = options;
|
|
25
|
+
const lastServer = dnsServers.at(-1);
|
|
26
|
+
if (!lastServer) {
|
|
27
|
+
throw new Error("At least one DNS server is required");
|
|
28
|
+
}
|
|
29
|
+
const config = {
|
|
30
|
+
log: { level: "info", timestamp: false },
|
|
31
|
+
dns: {
|
|
32
|
+
servers: [
|
|
33
|
+
...dnsServers.map((server, index) => ({
|
|
34
|
+
type: "udp",
|
|
35
|
+
tag: `remote-${index}`,
|
|
36
|
+
server,
|
|
37
|
+
server_port: 53,
|
|
38
|
+
})),
|
|
39
|
+
{ type: "fakeip", tag: "fakeip", inet4_range: FAKEIP_RANGE },
|
|
40
|
+
],
|
|
41
|
+
rules: [
|
|
42
|
+
{ query_type: ["A", "AAAA"], server: "fakeip" },
|
|
43
|
+
...dnsServers.slice(0, -1).map((_, index) => ({
|
|
44
|
+
server: `remote-${index}`,
|
|
45
|
+
ip_accept_any: true,
|
|
46
|
+
})),
|
|
47
|
+
],
|
|
48
|
+
final: `remote-${dnsServers.length - 1}`,
|
|
49
|
+
strategy: "prefer_ipv4",
|
|
50
|
+
independent_cache: true,
|
|
51
|
+
},
|
|
52
|
+
inbounds: [
|
|
53
|
+
{
|
|
54
|
+
type: "tun",
|
|
55
|
+
tag: "tun-in",
|
|
56
|
+
stack: "gvisor",
|
|
57
|
+
address: [TUN_ADDRESS],
|
|
58
|
+
auto_route: true,
|
|
59
|
+
strict_route: true,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
outbounds: [
|
|
63
|
+
{ type: "direct", tag: "direct" },
|
|
64
|
+
{ type: "block", tag: "block" },
|
|
65
|
+
],
|
|
66
|
+
route: {
|
|
67
|
+
default_interface: EGRESS_INTERFACE,
|
|
68
|
+
final: "block",
|
|
69
|
+
rules: [
|
|
70
|
+
{ action: "sniff" },
|
|
71
|
+
{ protocol: "dns", action: "hijack-dns" },
|
|
72
|
+
...(allowlist.length > 0 ? [{ domain: [...allowlist], outbound: "direct" }] : []),
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
77
|
+
}
|