@trim21/personal-pi-extensions 0.1.589 → 0.1.591

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.589",
3
+ "version": "0.1.591",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -1,12 +1,12 @@
1
1
  # bwrap 沙箱与网络栈架构
2
2
 
3
- 本文档描述 `net-allowlist` 模式下的进程模型、网络路径与生命周期管理。
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
- `net-allowlist` 模式下,一个沙箱 session 的常驻进程树(宿主侧视角,共 4 个):
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。allowlist 域名进 `fake-ip-filter`(真实解析),DNS 层
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
- export const BWRAP_MODES = [
26
- "allow-all",
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 BwrapMode = (typeof BWRAP_MODES)[number];
28
+ export type FsMode = (typeof FS_MODES)[number];
34
29
 
35
- const bwrapConfigProperties = {
36
- mode: StringEnum(BWRAP_MODES),
37
- bwrapPath: Type.Optional(Type.String()),
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
- extraArgs: Type.Array(Type.String()),
47
- networkAllowlist: Type.Array(
48
- Type.String({ description: "允许直连的域名 / IP / CIDR,可带 :port" }),
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
- approvalRules: Type.Optional(
53
- Type.Array(
54
- Type.Object(
55
- {
56
- action: StringEnum(["allow", "deny"] as const),
57
- pattern: Type.String({ description: '命令模式,如 "git push *"、"npm install *"' }),
58
- },
59
- { additionalProperties: true },
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
- export const bwrapConfigFileSchema = Type.Partial(bwrapConfigSchema, {
72
- additionalProperties: true,
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
- mode: BwrapMode;
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
- /** 允许直连的域名 / IP / IP:port 白名单(非空 = 启用 mihomo 网络过滤)。 */
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
- mode: "workspace-write",
98
- writablePaths: [".", "/tmp"],
99
- extraWritablePaths: [],
100
- denyPaths: [],
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
- const base = {
107
- mode: config.mode,
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
- writablePaths: config.writablePaths,
110
- extraWritablePaths: config.extraWritablePaths,
111
- denyPaths: config.denyPaths,
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.networkAllowlist,
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: Partial<BwrapConfig>): BwrapConfig {
163
+ function deepMerge(base: BwrapConfig, overrides: BwrapConfigFile): BwrapConfig {
147
164
  return {
148
- mode: overrides.mode ?? base.mode,
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
  }
@@ -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
- // net-allowlist 模式下命令先经 nsenter 进入 holder 的 userns(unshare -r 把 pi 的 uid 映射成 0),
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 (!resolved.network) args.push("--unshare-net");
383
- // --ro-bind-try:目录不存在(或已被删除)时自动忽略
384
- for (const name of PROTECTED_DIRS) {
385
- const absolutePath = await realpathOrSelf(join(cwd, name));
386
- args.push("--ro-bind-try", absolutePath, absolutePath);
387
- }
388
- // 工作区下所有 .git 一律只读:可写 bind 之上的覆盖绑定,防止命令篡改仓库元数据。
389
- // 根目录本身是 git 仓库时只保护根 .git(递归扫描有成本,绝大多数情况根即唯一仓库);
390
- // 根不是 git 仓库时才递归扫描嵌套仓库(如 monorepo 子仓库)。
391
- const rootGit = await realpathOrSelf(join(cwd, ".git"));
392
- let gitDirs: string[];
393
- try {
394
- await stat(rootGit);
395
- gitDirs = [rootGit];
396
- } catch {
397
- gitDirs = await findGitDirs(cwd);
398
- }
399
- for (const gitDir of gitDirs) {
400
- const realGitDir = await realpathOrSelf(gitDir);
401
- args.push("--ro-bind-try", realGitDir, realGitDir);
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
- /** 为 net-allowlist 模式创建网络栈;非该模式返回 undefined。每次命令现建现停。 */
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 (!resolved.network || resolved.networkAllowlist.length === 0) return undefined;
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
- /** net-allowlist 模式:命令需先经 nsenter 进入 holder 的 netns。 */
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 && resolved.networkAllowlist.length > 0,
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 net-allowlist mode");
551
+ throw new Error("Network stack is not initialized for network limited mode");
524
552
  }
525
553
  return networkStack.exec({
526
554
  command,