@trim21/personal-pi-extensions 0.1.589 → 0.1.592
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 +345 -194
- package/package.json +1 -1
- package/src/bwrap/README.md +3 -3
- package/src/bwrap/core.ts +125 -97
- package/src/bwrap/runtime.ts +138 -80
- package/src/bwrap/sandbox.ts +16 -7
- package/src/spawn-agent-agents.ts +8 -7
- package/src/spawn-agent.ts +3 -3
package/package.json
CHANGED
package/src/bwrap/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# bwrap 沙箱与网络栈架构
|
|
2
2
|
|
|
3
|
-
本文档描述 `
|
|
3
|
+
本文档描述 `network: limited` 模式下的进程模型、网络路径与生命周期管理。
|
|
4
4
|
基础沙箱(bwrap 文件系统隔离)见 `core.ts` / `sandbox.ts`;本文聚焦网络栈
|
|
5
5
|
(`network-stack.ts` / `holder.ts` / `mihomo-config.ts`)。
|
|
6
6
|
|
|
7
7
|
## 进程模型
|
|
8
8
|
|
|
9
|
-
`
|
|
9
|
+
`network: limited` 模式下,一个沙箱 session 的常驻进程树(宿主侧视角,共 4 个):
|
|
10
10
|
|
|
11
11
|
```
|
|
12
12
|
pi 进程(network-stack.ts)
|
|
@@ -34,7 +34,7 @@ ns 跑命令。一个 session 内 N 条命令复用同一套常驻栈。
|
|
|
34
34
|
## 网络路径
|
|
35
35
|
|
|
36
36
|
- **mihomo(③)**:TUN(`auto-route` + `strict-route`)+ fakeip +
|
|
37
|
-
deny-by-default
|
|
37
|
+
deny-by-default。`network.allowlist` 域名进 `fake-ip-filter`(真实解析),DNS 层
|
|
38
38
|
`DOMAIN-SUFFIX,…,DIRECT`;连接层未命中 allowlist 的流量 `MATCH,REJECT`。
|
|
39
39
|
注意:fakeip 对不在 filter 里的域名**直接本地应答**,不会走到
|
|
40
40
|
`dns.rules` 的 REJECT——未允许域名是先拿 fakeip、连接层再被拒。
|
package/src/bwrap/core.ts
CHANGED
|
@@ -22,19 +22,18 @@ import {
|
|
|
22
22
|
|
|
23
23
|
const PROTECTED_DIRS = [".pi", ".agent"];
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"workspace-write",
|
|
28
|
-
"allow-net",
|
|
29
|
-
"net-allowlist",
|
|
30
|
-
"readonly",
|
|
31
|
-
] as const;
|
|
25
|
+
/** 文件系统策略:只读 / 工作区可写 / 完全放开。与网络策略正交,可任意组合。 */
|
|
26
|
+
export const FS_MODES = ["readonly", "workspace-write", "allow-all"] as const;
|
|
32
27
|
|
|
33
|
-
export type
|
|
28
|
+
export type FsMode = (typeof FS_MODES)[number];
|
|
34
29
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
/** 网络策略:断网 / 仅白名单过滤 / 完全放开。与文件系统策略正交,可任意组合。 */
|
|
31
|
+
export const NETWORK_MODES = ["block", "limited", "allow-all"] as const;
|
|
32
|
+
|
|
33
|
+
export type NetworkMode = (typeof NETWORK_MODES)[number];
|
|
34
|
+
|
|
35
|
+
const fsConfigProperties = {
|
|
36
|
+
mode: StringEnum(FS_MODES),
|
|
38
37
|
writablePaths: Type.Array(Type.String()),
|
|
39
38
|
extraWritablePaths: Type.Array(Type.String()),
|
|
40
39
|
denyPaths: Type.Array(
|
|
@@ -43,23 +42,31 @@ const bwrapConfigProperties = {
|
|
|
43
42
|
"沙箱内隐藏的路径:以 / 结尾为目录(挂空 tmpfs),否则为文件(--ro-bind-try /dev/null)",
|
|
44
43
|
}),
|
|
45
44
|
),
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const networkConfigProperties = {
|
|
48
|
+
mode: StringEnum(NETWORK_MODES),
|
|
49
|
+
allowlist: Type.Array(
|
|
50
|
+
Type.String({ description: "limited 模式允许直连的域名 / IP / CIDR,可带 :port" }),
|
|
49
51
|
),
|
|
50
52
|
mihomoPath: Type.Optional(Type.String()),
|
|
51
53
|
slirp4netnsPath: Type.Optional(Type.String()),
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const approvalRuleSchema = Type.Object(
|
|
57
|
+
{
|
|
58
|
+
action: StringEnum(["allow", "deny"] as const),
|
|
59
|
+
pattern: Type.String({ description: '命令模式,如 "git push *"、"npm install *"' }),
|
|
60
|
+
},
|
|
61
|
+
{ additionalProperties: true },
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const bwrapConfigProperties = {
|
|
65
|
+
fs: Type.Object(fsConfigProperties, { additionalProperties: true }),
|
|
66
|
+
network: Type.Object(networkConfigProperties, { additionalProperties: true }),
|
|
67
|
+
bwrapPath: Type.Optional(Type.String()),
|
|
68
|
+
extraArgs: Type.Array(Type.String()),
|
|
69
|
+
approvalRules: Type.Optional(Type.Array(approvalRuleSchema)),
|
|
63
70
|
};
|
|
64
71
|
|
|
65
72
|
// 配置文件容忍未知字段:schema 之外的字段(如新版本扩展新增的配置)会被忽略,
|
|
@@ -68,24 +75,37 @@ export const bwrapConfigSchema = Type.Object(bwrapConfigProperties, {
|
|
|
68
75
|
additionalProperties: true,
|
|
69
76
|
});
|
|
70
77
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
/** 配置文件形状:顶层与 fs / network 两级字段都可缺省,逐层回落默认值。 */
|
|
79
|
+
export const bwrapConfigFileSchema = Type.Object(
|
|
80
|
+
{
|
|
81
|
+
fs: Type.Optional(
|
|
82
|
+
Type.Partial(Type.Object(fsConfigProperties, { additionalProperties: true })),
|
|
83
|
+
),
|
|
84
|
+
network: Type.Optional(
|
|
85
|
+
Type.Partial(Type.Object(networkConfigProperties, { additionalProperties: true })),
|
|
86
|
+
),
|
|
87
|
+
bwrapPath: Type.Optional(Type.String()),
|
|
88
|
+
extraArgs: Type.Optional(Type.Array(Type.String())),
|
|
89
|
+
approvalRules: Type.Optional(Type.Array(approvalRuleSchema)),
|
|
90
|
+
},
|
|
91
|
+
{ additionalProperties: true },
|
|
92
|
+
);
|
|
74
93
|
|
|
75
94
|
export type BwrapConfig = Static<typeof bwrapConfigSchema>;
|
|
76
95
|
export type BwrapConfigFile = Static<typeof bwrapConfigFileSchema>;
|
|
77
96
|
|
|
78
97
|
export interface ResolvedBwrap {
|
|
79
|
-
|
|
98
|
+
fs: FsMode;
|
|
99
|
+
network: NetworkMode;
|
|
100
|
+
/** 是否需要 bwrap 包裹:fs 或 network 任一需要沙箱强制;两者都 allow-all 时直接执行。 */
|
|
80
101
|
bwrapEnabled: boolean;
|
|
81
|
-
network: boolean;
|
|
82
102
|
bwrapPath?: string;
|
|
83
103
|
writablePaths: string[];
|
|
84
104
|
extraWritablePaths: string[];
|
|
85
105
|
/** 沙箱内隐藏的路径:以 / 结尾为目录(挂空 tmpfs),否则为文件(--ro-bind-try /dev/null)。 */
|
|
86
106
|
denyPaths: string[];
|
|
87
107
|
extraArgs: string[];
|
|
88
|
-
/**
|
|
108
|
+
/** network limited 模式允许直连的域名 / IP / IP:port 白名单(空 = 全部拒绝)。 */
|
|
89
109
|
networkAllowlist: string[];
|
|
90
110
|
mihomoPath?: string;
|
|
91
111
|
slirp4netnsPath?: string;
|
|
@@ -94,44 +114,41 @@ export interface ResolvedBwrap {
|
|
|
94
114
|
}
|
|
95
115
|
|
|
96
116
|
const DEFAULT_CONFIG: BwrapConfig = {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
117
|
+
fs: {
|
|
118
|
+
mode: "workspace-write",
|
|
119
|
+
writablePaths: [".", "/tmp"],
|
|
120
|
+
extraWritablePaths: [],
|
|
121
|
+
denyPaths: [],
|
|
122
|
+
},
|
|
123
|
+
network: {
|
|
124
|
+
mode: "block",
|
|
125
|
+
allowlist: [],
|
|
126
|
+
},
|
|
101
127
|
extraArgs: [],
|
|
102
|
-
networkAllowlist: [],
|
|
103
128
|
};
|
|
104
129
|
|
|
105
130
|
export function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
|
|
106
|
-
|
|
107
|
-
|
|
131
|
+
return {
|
|
132
|
+
fs: config.fs.mode,
|
|
133
|
+
network: config.network.mode,
|
|
134
|
+
bwrapEnabled: config.fs.mode !== "allow-all" || config.network.mode !== "allow-all",
|
|
108
135
|
bwrapPath: config.bwrapPath,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
136
|
+
// readonly 无条件去掉默认可写路径(extraWritablePaths 仍是显式开口,保留);
|
|
137
|
+
// allow-all 在沙箱内把整棵根挂成可写,保护绑定(.pi/.agent/.git)随之取消。
|
|
138
|
+
writablePaths:
|
|
139
|
+
config.fs.mode === "readonly"
|
|
140
|
+
? []
|
|
141
|
+
: config.fs.mode === "allow-all"
|
|
142
|
+
? ["/"]
|
|
143
|
+
: config.fs.writablePaths,
|
|
144
|
+
extraWritablePaths: config.fs.extraWritablePaths,
|
|
145
|
+
denyPaths: config.fs.denyPaths,
|
|
112
146
|
extraArgs: config.extraArgs,
|
|
113
|
-
networkAllowlist: config.
|
|
114
|
-
mihomoPath: config.mihomoPath,
|
|
115
|
-
slirp4netnsPath: config.slirp4netnsPath,
|
|
147
|
+
networkAllowlist: config.network.allowlist,
|
|
148
|
+
mihomoPath: config.network.mihomoPath,
|
|
149
|
+
slirp4netnsPath: config.network.slirp4netnsPath,
|
|
116
150
|
approvalRules: config.approvalRules ?? [],
|
|
117
151
|
};
|
|
118
|
-
switch (config.mode) {
|
|
119
|
-
case "allow-all": {
|
|
120
|
-
return { ...base, bwrapEnabled: false, network: true };
|
|
121
|
-
}
|
|
122
|
-
case "workspace-write": {
|
|
123
|
-
return { ...base, bwrapEnabled: true, network: false };
|
|
124
|
-
}
|
|
125
|
-
case "allow-net": {
|
|
126
|
-
return { ...base, bwrapEnabled: true, network: true };
|
|
127
|
-
}
|
|
128
|
-
case "net-allowlist": {
|
|
129
|
-
return { ...base, bwrapEnabled: true, network: true };
|
|
130
|
-
}
|
|
131
|
-
case "readonly": {
|
|
132
|
-
return { ...base, bwrapEnabled: true, network: false, writablePaths: [] };
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
152
|
}
|
|
136
153
|
|
|
137
154
|
/**
|
|
@@ -143,17 +160,25 @@ export function completeBwrapConfig(file: BwrapConfigFile): BwrapConfig {
|
|
|
143
160
|
return Value.Parse(bwrapConfigSchema, deepMerge(DEFAULT_CONFIG, file));
|
|
144
161
|
}
|
|
145
162
|
|
|
146
|
-
function deepMerge(base: BwrapConfig, overrides:
|
|
163
|
+
function deepMerge(base: BwrapConfig, overrides: BwrapConfigFile): BwrapConfig {
|
|
147
164
|
return {
|
|
148
|
-
|
|
165
|
+
fs: {
|
|
166
|
+
mode: overrides.fs?.mode ?? base.fs.mode,
|
|
167
|
+
writablePaths: overrides.fs?.writablePaths ?? base.fs.writablePaths,
|
|
168
|
+
extraWritablePaths: [
|
|
169
|
+
...base.fs.extraWritablePaths,
|
|
170
|
+
...(overrides.fs?.extraWritablePaths ?? []),
|
|
171
|
+
],
|
|
172
|
+
denyPaths: overrides.fs?.denyPaths ?? base.fs.denyPaths,
|
|
173
|
+
},
|
|
174
|
+
network: {
|
|
175
|
+
mode: overrides.network?.mode ?? base.network.mode,
|
|
176
|
+
allowlist: overrides.network?.allowlist ?? base.network.allowlist,
|
|
177
|
+
mihomoPath: overrides.network?.mihomoPath ?? base.network.mihomoPath,
|
|
178
|
+
slirp4netnsPath: overrides.network?.slirp4netnsPath ?? base.network.slirp4netnsPath,
|
|
179
|
+
},
|
|
149
180
|
bwrapPath: overrides.bwrapPath ?? base.bwrapPath,
|
|
150
|
-
writablePaths: overrides.writablePaths ?? base.writablePaths,
|
|
151
|
-
extraWritablePaths: [...base.extraWritablePaths, ...(overrides.extraWritablePaths ?? [])],
|
|
152
|
-
denyPaths: overrides.denyPaths ?? base.denyPaths,
|
|
153
181
|
extraArgs: overrides.extraArgs ?? base.extraArgs,
|
|
154
|
-
networkAllowlist: overrides.networkAllowlist ?? base.networkAllowlist,
|
|
155
|
-
mihomoPath: overrides.mihomoPath ?? base.mihomoPath,
|
|
156
|
-
slirp4netnsPath: overrides.slirp4netnsPath ?? base.slirp4netnsPath,
|
|
157
182
|
approvalRules: [...(base.approvalRules ?? []), ...(overrides.approvalRules ?? [])],
|
|
158
183
|
};
|
|
159
184
|
}
|
|
@@ -180,8 +205,8 @@ export interface BwrapConfigPaths {
|
|
|
180
205
|
|
|
181
206
|
export function getBwrapConfigPaths(cwd: string): BwrapConfigPaths {
|
|
182
207
|
return {
|
|
183
|
-
global: join(getAgentDir(), "
|
|
184
|
-
project: join(cwd, ".pi", "
|
|
208
|
+
global: join(getAgentDir(), "sandbox.json"),
|
|
209
|
+
project: join(cwd, ".pi", "sandbox.json"),
|
|
185
210
|
};
|
|
186
211
|
}
|
|
187
212
|
|
|
@@ -353,7 +378,7 @@ async function realpathOrSelf(path: string): Promise<string> {
|
|
|
353
378
|
export async function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): Promise<string[]> {
|
|
354
379
|
const args = ["--new-session", "--die-with-parent", "--unshare-user", "--unshare-pid"];
|
|
355
380
|
// 沙箱进程以调用方(pi)的 uid/gid 运行,而不是 userns 里的 0。
|
|
356
|
-
//
|
|
381
|
+
// network limited 模式下命令先经 nsenter 进入 holder 的 userns(unshare -r 把 pi 的 uid 映射成 0),
|
|
357
382
|
// bwrap 默认继承该 uid 会让沙箱内 id/stat 自称 root、与宿主视角不一致;
|
|
358
383
|
// 直接模式(无 holder)下这两个值本就等于 bwrap 的 real uid,等价于默认行为。
|
|
359
384
|
const uid = process.getuid?.();
|
|
@@ -379,26 +404,29 @@ export async function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): Prom
|
|
|
379
404
|
args.push("--ro-bind-try", "/dev/null", target);
|
|
380
405
|
}
|
|
381
406
|
}
|
|
382
|
-
if (
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
407
|
+
if (resolved.network === "block") args.push("--unshare-net");
|
|
408
|
+
// fs allow-all:整棵根可写,不做保护绑定(.pi/.agent/.git 的只读覆盖与「完整可写」矛盾)。
|
|
409
|
+
if (resolved.fs !== "allow-all") {
|
|
410
|
+
// --ro-bind-try:目录不存在(或已被删除)时自动忽略
|
|
411
|
+
for (const name of PROTECTED_DIRS) {
|
|
412
|
+
const absolutePath = await realpathOrSelf(join(cwd, name));
|
|
413
|
+
args.push("--ro-bind-try", absolutePath, absolutePath);
|
|
414
|
+
}
|
|
415
|
+
// 工作区下所有 .git 一律只读:可写 bind 之上的覆盖绑定,防止命令篡改仓库元数据。
|
|
416
|
+
// 根目录本身是 git 仓库时只保护根 .git(递归扫描有成本,绝大多数情况根即唯一仓库);
|
|
417
|
+
// 根不是 git 仓库时才递归扫描嵌套仓库(如 monorepo 子仓库)。
|
|
418
|
+
const rootGit = await realpathOrSelf(join(cwd, ".git"));
|
|
419
|
+
let gitDirs: string[];
|
|
420
|
+
try {
|
|
421
|
+
await stat(rootGit);
|
|
422
|
+
gitDirs = [rootGit];
|
|
423
|
+
} catch {
|
|
424
|
+
gitDirs = await findGitDirs(cwd);
|
|
425
|
+
}
|
|
426
|
+
for (const gitDir of gitDirs) {
|
|
427
|
+
const realGitDir = await realpathOrSelf(gitDir);
|
|
428
|
+
args.push("--ro-bind-try", realGitDir, realGitDir);
|
|
429
|
+
}
|
|
402
430
|
}
|
|
403
431
|
args.push(...resolved.extraArgs);
|
|
404
432
|
return args;
|
|
@@ -418,12 +446,12 @@ export interface NetworkStackLog {
|
|
|
418
446
|
holder?: (chunk: string) => void;
|
|
419
447
|
}
|
|
420
448
|
|
|
421
|
-
/** 为
|
|
449
|
+
/** 为 network limited 模式创建网络栈;其余模式返回 undefined。每次命令现建现停。 */
|
|
422
450
|
export async function createNetworkStack(
|
|
423
451
|
resolved: ResolvedBwrap,
|
|
424
452
|
log?: NetworkStackLog,
|
|
425
453
|
): Promise<NetworkStack | undefined> {
|
|
426
|
-
if (
|
|
454
|
+
if (resolved.network !== "limited") return undefined;
|
|
427
455
|
return startNetworkStack({
|
|
428
456
|
allowlist: resolved.networkAllowlist,
|
|
429
457
|
dnsServers: await resolveDnsServers(),
|
|
@@ -447,7 +475,7 @@ export interface BwrapInvocation {
|
|
|
447
475
|
cwd: string;
|
|
448
476
|
/** 沙箱内环境(不继承父进程) */
|
|
449
477
|
env: Record<string, string>;
|
|
450
|
-
/**
|
|
478
|
+
/** network limited 模式:命令需先经 nsenter 进入 holder 的 netns。 */
|
|
451
479
|
needsNetworkStack: boolean;
|
|
452
480
|
}
|
|
453
481
|
|
|
@@ -490,7 +518,7 @@ export async function buildBwrapInvocation(
|
|
|
490
518
|
// 基础 PATH:profile 加载阶段(设置 PATH 前)需要系统命令(如 id),由 profile 随后覆盖;不含 sbin
|
|
491
519
|
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
492
520
|
},
|
|
493
|
-
needsNetworkStack: resolved.network
|
|
521
|
+
needsNetworkStack: resolved.network === "limited",
|
|
494
522
|
};
|
|
495
523
|
}
|
|
496
524
|
|
|
@@ -520,7 +548,7 @@ export function createBwrapBashOperations(
|
|
|
520
548
|
|
|
521
549
|
if (invocation.needsNetworkStack) {
|
|
522
550
|
if (!networkStack) {
|
|
523
|
-
throw new Error("Network stack is not initialized for
|
|
551
|
+
throw new Error("Network stack is not initialized for network limited mode");
|
|
524
552
|
}
|
|
525
553
|
return networkStack.exec({
|
|
526
554
|
command,
|