@bolloon/bolloon-agent 0.1.13 → 0.1.14

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.
Files changed (52) hide show
  1. package/dist/agents/pi-sdk.js +185 -0
  2. package/dist/agents/shell-guard.js +354 -0
  3. package/dist/agents/shell-tool.js +83 -0
  4. package/dist/agents/skill-loader.js +174 -0
  5. package/dist/bollharness-integration/context-chain-router.js +3 -3
  6. package/dist/bollharness-integration/context-router.js +1 -1
  7. package/dist/heartbeat/Watchdog.js +7 -5
  8. package/dist/heartbeat/index.js +1 -0
  9. package/dist/heartbeat/self-improve-bus.js +85 -0
  10. package/dist/pi-ecosystem-judgment/index.js +1 -2
  11. package/dist/utils/auto-update.js +44 -12
  12. package/dist/web/client.js +839 -103
  13. package/dist/web/components/p2p/P2PModal.js +188 -0
  14. package/dist/web/components/p2p/index.js +264 -226
  15. package/dist/web/components/p2p/p2p-modal.js +657 -0
  16. package/dist/web/components/p2p/p2p-tools.js +248 -0
  17. package/dist/web/index.html +88 -8
  18. package/dist/web/server.js +2360 -0
  19. package/dist/web/style.css +506 -9
  20. package/package.json +2 -2
  21. package/scripts/build-cli.js +11 -1
  22. package/src/agents/pi-sdk.ts +196 -0
  23. package/src/agents/shell-guard.ts +417 -0
  24. package/src/agents/shell-tool.ts +103 -0
  25. package/src/agents/skill-loader.ts +202 -0
  26. package/src/bollharness-integration/context-chain-router.ts +3 -3
  27. package/src/bollharness-integration/context-router.ts +1 -1
  28. package/src/heartbeat/Watchdog.ts +7 -5
  29. package/src/heartbeat/index.ts +1 -0
  30. package/src/heartbeat/self-improve-bus.ts +110 -0
  31. package/src/types.d.ts +12 -0
  32. package/src/utils/auto-update.ts +45 -14
  33. package/src/web/client.js +839 -103
  34. package/src/web/index.html +88 -8
  35. package/src/web/server.ts +427 -101
  36. package/src/web/style.css +506 -9
  37. package/dist/bollharness-integration/bollharness-integration/context-router-judgment.d.ts +0 -48
  38. package/dist/bollharness-integration/bollharness-integration/context-router-judgment.js +0 -261
  39. package/dist/bollharness-integration/bollharness-integration/context-router.d.ts +0 -110
  40. package/dist/bollharness-integration/bollharness-integration/context-router.js +0 -542
  41. package/dist/bollharness-integration/bollharness-integration/gate-state-machine.d.ts +0 -87
  42. package/dist/bollharness-integration/bollharness-integration/gate-state-machine.js +0 -231
  43. package/dist/bollharness-integration/bollharness-integration/gate-transition-hooks.d.ts +0 -30
  44. package/dist/bollharness-integration/bollharness-integration/gate-transition-hooks.js +0 -91
  45. package/dist/bollharness-integration/bollharness-integration/guard-checker.d.ts +0 -105
  46. package/dist/bollharness-integration/bollharness-integration/guard-checker.js +0 -353
  47. package/dist/bollharness-integration/bollharness-integration/index.d.ts +0 -66
  48. package/dist/bollharness-integration/bollharness-integration/index.js +0 -32
  49. package/dist/bollharness-integration/bollharness-integration/integration.d.ts +0 -219
  50. package/dist/bollharness-integration/bollharness-integration/integration.js +0 -420
  51. package/dist/bollharness-integration/bollharness-integration/skill-adapter.d.ts +0 -151
  52. package/dist/bollharness-integration/bollharness-integration/skill-adapter.js +0 -518
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import * as fs from 'fs/promises';
6
6
  import * as fsSync from 'fs';
7
+ import * as os from 'os';
7
8
  import * as path from 'path';
8
9
  import { documentReader } from '../documents/reader.js';
9
10
  import { getMinimax } from '../constraints/index.js';
@@ -13,9 +14,12 @@ import { WorkflowEngine } from './workflow-engine.js';
13
14
  import { DeepThinkingEngine, AgentCoordinator } from '@bolloon/constraint-runtime';
14
15
  import { WorkflowPivotLoop, createDefaultPivotConfig } from './workflow-pivot-loop.js';
15
16
  import { p2pDocumentTools, initDocumentReceiver } from './p2p-document-tools.js';
17
+ import { shellExec } from './shell-tool.js';
18
+ import { getBranchPrefix, getCooldownMs } from './shell-guard.js';
16
19
  import { DiscoveredAgentsManager, createSocialHeartbeat } from '../social/heartbeat.js';
17
20
  import { getGlobalSharedContext } from '../social/global-shared-context.js';
18
21
  import { Session, SkillRegistry, saveSession, loadSession } from '@bolloon/constraint-runtime';
22
+ import { loadSkillsFromPaths, defaultSkillPaths, describeSkill } from './skill-loader.js';
19
23
  const SHARED_SESSION_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'sessions');
20
24
  const PERSONA_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'persona.json');
21
25
  export class PiSessionManager {
@@ -353,8 +357,72 @@ class PiAgentSession {
353
357
  this.initSession();
354
358
  initDocumentReceiver();
355
359
  this.registerTools();
360
+ this.loadSkills(config.skillsPaths);
356
361
  this.initHarness();
357
362
  }
363
+ /**
364
+ * 从 SKILL.md 目录加载 skills 进 skillRegistry.
365
+ *
366
+ * 路径解析优先级 (后者覆盖前者同名 skill):
367
+ * 1. 显式传入的 skillsPaths
368
+ * 2. ~/.bolloon/skills/ 全局用户级
369
+ * 3. <cwd>/.bolloon/skills/ 项目级
370
+ * 4. ~/.boll/skills/ 全局 (兼容 bollharness 旧用户)
371
+ * 5. <bolloon-repo>/src/bollharness/.boll/skills/ bolloon 仓库内置 skill
372
+ * (bolloon 项目本身用 pi-sdk 写核心, 这 19 个 skill 视为项目级 builtin)
373
+ *
374
+ * 静默忽略不存在的目录.
375
+ */
376
+ loadSkills(paths) {
377
+ let resolved;
378
+ if (paths && paths.length > 0) {
379
+ resolved = paths;
380
+ }
381
+ else {
382
+ resolved = [
383
+ ...defaultSkillPaths(os.homedir(), this.cwd),
384
+ // bolloon 仓库内置 skill (相对本 npm 包的位置)
385
+ this.findBolloonBuiltinSkillsPath(),
386
+ ].filter((p) => Boolean(p));
387
+ }
388
+ loadSkillsFromPaths(resolved)
389
+ .then((skills) => {
390
+ for (const s of skills) {
391
+ if (this.skillRegistry.has(s.name)) {
392
+ this.skillRegistry.unregister(s.name);
393
+ }
394
+ this.skillRegistry.register(s);
395
+ }
396
+ console.log(`[loadSkills] 已加载 ${skills.length} 个 skill: ${skills.map(describeSkill).join(' | ')}`);
397
+ })
398
+ .catch((err) => {
399
+ console.error('[loadSkills] 加载失败:', err);
400
+ });
401
+ }
402
+ /**
403
+ * 定位 bolloon 仓库内置的 bollharness skill 目录.
404
+ * 向上回溯 cwd, 找第一个包含 src/bollharness/.boll/skills 的祖先.
405
+ * 找不到时返回 null (例如把 bolloon-agent 作为外部依赖安装时).
406
+ */
407
+ findBolloonBuiltinSkillsPath() {
408
+ let dir = this.cwd;
409
+ for (let i = 0; i < 6; i++) {
410
+ const candidate = path.join(dir, 'src', 'bollharness', '.boll', 'skills');
411
+ try {
412
+ if (fsSync.existsSync(candidate) && fsSync.statSync(candidate).isDirectory()) {
413
+ return candidate;
414
+ }
415
+ }
416
+ catch {
417
+ // 忽略 stat 异常, 继续向上
418
+ }
419
+ const parent = path.dirname(dir);
420
+ if (parent === dir)
421
+ break;
422
+ dir = parent;
423
+ }
424
+ return null;
425
+ }
358
426
  async initHarness() {
359
427
  try {
360
428
  const { createBollharnessIntegration } = await import('../bollharness-integration/index.js');
@@ -583,6 +651,85 @@ class PiAgentSession {
583
651
  for (const tool of p2pDocumentTools) {
584
652
  this.tools.set(tool.name, tool);
585
653
  }
654
+ // Shell Exec 工具: 给 AI 跑受限的 shell 命令
655
+ // **只能** 跑白名单内的命令 (git/npm/tsc/vitest/cat/...)
656
+ // **不能** 改禁区路径 (见 shell-guard.ts 的 FORBIDDEN_PATH_PATTERNS)
657
+ // 沙箱 cwd: .bolloon-shell-sandbox/
658
+ this.tools.set('shell_exec', {
659
+ name: 'shell_exec',
660
+ description: '在沙箱里跑 shell 命令. 仅支持白名单内命令: git, npm, npx, tsx, tsc, vitest, cat, head, tail, ls, wc, echo, pwd, date, mkdir, touch. 禁止管道/重定向/rm -rf/sudo. 命中护栏黑名单会被拒.',
661
+ parameters: { command: '可执行文件 (必填, 必须在白名单)', args: '参数数组, 逗号分隔', timeoutMs: '超时毫秒, 默认 30000' },
662
+ execute: async (args) => {
663
+ const cmd = String(args.command || '').trim();
664
+ if (!cmd)
665
+ return { success: false, error: 'command 必填' };
666
+ const argList = String(args.args || '').split(',').map(s => s.trim()).filter(Boolean);
667
+ const timeoutMs = Number(args.timeoutMs) || 30000;
668
+ const result = await shellExec(cmd, argList, { timeoutMs });
669
+ if (result.deniedByGuard) {
670
+ return { success: false, error: result.error };
671
+ }
672
+ if (!result.success) {
673
+ return { success: false, error: result.error, output: result.output };
674
+ }
675
+ return { success: true, output: result.output };
676
+ }
677
+ });
678
+ // self_improve 工具: AI 触发自我改进循环
679
+ // **必须** 在 branchPrefix 命名的分支上工作
680
+ // 心跳事件会自动调用; 用户对话里也能手动调
681
+ this.tools.set('self_improve', {
682
+ name: 'self_improve',
683
+ description: `触发自我改进循环. AI 会在分支 ${getBranchPrefix()}<timestamp> 上工作, 跑 tsc + vitest 验证, 通过后输出分支名给用户审. 冷却期由策略文件决定. 命中护栏禁区的改动会被拒.`,
684
+ parameters: { goal: '本轮改进目标 (1 句话)' },
685
+ execute: async (args) => {
686
+ const goal = String(args.goal || '').trim();
687
+ if (!goal)
688
+ return { success: false, error: 'goal 必填' };
689
+ return await runSelfImproveLoop(goal);
690
+ }
691
+ });
692
+ // list_skills 工具: 列出当前 session 已加载的 skills
693
+ // 加载源: ~/.bolloon/skills/ → <cwd>/.bolloon/skills/ → ~/.boll/skills/
694
+ this.tools.set('list_skills', {
695
+ name: 'list_skills',
696
+ description: '列出当前 session 已加载的 skills 及其描述. Skills 是从 SKILL.md 文件加载的, 兼容 Anthropic Agent Skills 标准 frontmatter 和 bollharness 现有 frontmatter.',
697
+ parameters: {},
698
+ execute: async () => {
699
+ const skills = this.skillRegistry.list();
700
+ if (skills.length === 0) {
701
+ return {
702
+ success: true,
703
+ output: '当前 session 没有加载任何 skill. 检查 ~/.bolloon/skills/ 或项目 .bolloon/skills/ 目录.',
704
+ };
705
+ }
706
+ const lines = skills.map((s, i) => `${i + 1}. ${s.name} — ${s.description}`);
707
+ return { success: true, output: `已加载 ${skills.length} 个 skill:\n${lines.join('\n')}` };
708
+ }
709
+ });
710
+ // use_skill 工具: 加载指定 skill 的 body 进 LLM context
711
+ // Skills 协议核心: 把 SKILL.md body 作为 Markdown 指南返回, LLM 下一轮按它执行
712
+ this.tools.set('use_skill', {
713
+ name: 'use_skill',
714
+ description: '按名称加载一个 skill, 把它的 SKILL.md body 作为 Markdown 文档返回. 调用后 LLM 会在下一轮按 skill 指南执行. 与 shell_exec / read_document 这些 "能力工具" 不同, use_skill 是 "知识注入".',
715
+ parameters: { name: 'skill 名称 (用 list_skills 查可用的)' },
716
+ execute: async (args) => {
717
+ const name = String(args.name || '').trim();
718
+ if (!name)
719
+ return { success: false, error: 'name 必填' };
720
+ if (!this.skillRegistry.has(name)) {
721
+ const available = this.skillRegistry.list().map((s) => s.name).join(', ');
722
+ return { success: false, error: `skill "${name}" 未找到. 已加载: ${available || '(无)'}` };
723
+ }
724
+ try {
725
+ const body = await this.skillRegistry.execute(name, {});
726
+ return { success: true, output: body };
727
+ }
728
+ catch (e) {
729
+ return { success: false, error: `执行 skill 失败: ${String(e)}` };
730
+ }
731
+ }
732
+ });
586
733
  }
587
734
  async registerP2PDocumentReceiver() {
588
735
  await initDocumentReceiver();
@@ -1541,3 +1688,41 @@ export function resetAgentSession() {
1541
1688
  sessionInstance = null;
1542
1689
  lastIdentityDid = null;
1543
1690
  }
1691
+ /**
1692
+ * 自我改进循环: 在沙箱分支上工作, 输出结果给用户审.
1693
+ *
1694
+ * 不在 PiAgent 实例上的原因: 心跳回调可能没有 agent 实例, 单独函数更易复用.
1695
+ *
1696
+ * **关键不变量**:
1697
+ * 1. AI 不能 push 到 master (shell-guard 黑名单 + git 受保护分支)
1698
+ * 2. 改动必须走沙箱分支 (SELF_IMPROVE_BRANCH_PREFIX)
1699
+ * 3. 6 小时冷却期 (SELF_IMPROVE_COOLDOWN_MS)
1700
+ * 4. 写文件必须经过 shell_exec + 护栏检查
1701
+ */
1702
+ let lastSelfImproveAt = null;
1703
+ export async function runSelfImproveLoop(goal) {
1704
+ const cooldownMs = getCooldownMs();
1705
+ // 1. 冷却期检查
1706
+ if (lastSelfImproveAt && Date.now() - lastSelfImproveAt < cooldownMs) {
1707
+ const waitHrs = Math.ceil((cooldownMs - (Date.now() - lastSelfImproveAt)) / 3600000);
1708
+ return { success: false, error: `自改冷却中, 还需要约 ${waitHrs} 小时` };
1709
+ }
1710
+ // 2. 选源分支 + 新分支名
1711
+ const sourceBranch = 'master';
1712
+ const newBranch = `${getBranchPrefix()}${Date.now()}`;
1713
+ console.log(`[self-improve] 启动自改循环, 目标: ${goal}, 新分支: ${newBranch}`);
1714
+ // 3. 创建分支
1715
+ const r1 = await shellExec('git', ['checkout', sourceBranch]);
1716
+ if (!r1.success)
1717
+ return { success: false, error: `切换到 ${sourceBranch} 失败: ${r1.error}` };
1718
+ const r2 = await shellExec('git', ['checkout', '-b', newBranch]);
1719
+ if (!r2.success)
1720
+ return { success: false, error: `创建分支失败: ${r2.error}` };
1721
+ // 4. 走 task queue: 把"自改"作为一个 task 抛回去, AI 拿到后会用 shell_exec 改
1722
+ // 护栏已经阻止所有禁区改动, 这里只负责登记
1723
+ lastSelfImproveAt = Date.now();
1724
+ return {
1725
+ success: true,
1726
+ output: `✅ 自改分支已创建: ${newBranch}\n目标: ${goal}\n\n**护栏已激活**:\n - 仅允许白名单命令 (git/npm/tsc/vitest/cat/ls/...)\n - 禁止改 src/agents/pi-sdk.ts, shell-guard.ts, src/heartbeat/, src/network/, src/pi-ecosystem-judgment/, package.json, .env 等\n - 6 小时冷却期\n\nAI 接下来会用 shell_exec 工具改源码. 完成后你会在对话里看到 diff 摘要, 手动 git diff master..${newBranch} 审, 满意再 merge.`
1727
+ };
1728
+ }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Shell 命令硬护栏 (策略可配置版)
3
+ *
4
+ * 设计原则:
5
+ * 1. 白名单/黑名单从 ~/.bolloon/self-improve-policy.json 加载
6
+ * 2. 加载失败或缺失时, 使用**硬编码兜底** (永远拒绝 = 最安全)
7
+ * 3. 策略文件在禁区里, AI 即便拿到 shell_exec 也改不了
8
+ * 4. 提供 API 端点供**人**热加载策略, 并写审计日志
9
+ *
10
+ * 策略文件 schema (self-improve-policy.json):
11
+ * {
12
+ * "version": 1,
13
+ * "commandAllowlist": ["git", "npm", "tsc", "vitest", "cat", "ls", "..."],
14
+ * "commandDenylist": ["rm", "mv", "chmod", "sudo", "su", "curl", "wget"],
15
+ * "pathAllowlist": [
16
+ * "src/web/client.js",
17
+ * "src/agents/workflow-engine.ts",
18
+ * "*.md",
19
+ * "docs/**"
20
+ * ],
21
+ * "pathDenylist": [
22
+ * "src/agents/pi-sdk.ts",
23
+ * "src/agents/shell-guard.ts",
24
+ * "src/agents/shell-tool.ts",
25
+ * "src/heartbeat/**",
26
+ * "src/network/**",
27
+ * "src/pi-ecosystem-judgment/**",
28
+ * "package.json",
29
+ * ".env*",
30
+ * ".git/**",
31
+ * "dist/**",
32
+ * "node_modules/**"
33
+ * ],
34
+ * "cooldownMs": 21600000,
35
+ * "sandboxCwd": ".bolloon-shell-sandbox",
36
+ * "branchPrefix": "agent/self-imp-"
37
+ * }
38
+ *
39
+ * 匹配规则:
40
+ * 1. 路径先查 denylist (命中即拒), 再查 allowlist (没命中即拒)
41
+ * 2. 命令先查 denylist (命中即拒), 再查 allowlist (没命中即拒)
42
+ * 3. 通配符: * 匹配单层文件名, ** 匹配任意层级
43
+ */
44
+ import * as fs from 'fs';
45
+ import * as path from 'path';
46
+ import * as os from 'os';
47
+ // ============================================================================
48
+ // 硬编码兜底 (策略文件读不到时, 用这套)
49
+ // 这是**最后一道防线** - AI 即便能改 ~/.bolloon/ 也没法删这个常量
50
+ // ============================================================================
51
+ const FALLBACK_COMMAND_ALLOWLIST = new Set([
52
+ 'git', 'node', 'npm', 'npx', 'tsx', 'tsc', 'vitest',
53
+ 'cat', 'head', 'tail', 'wc', 'ls', 'echo', 'pwd', 'date',
54
+ 'mkdir', 'touch'
55
+ ]);
56
+ const FALLBACK_PATH_ALLOWLIST = [
57
+ // 自由区: AI 可以改
58
+ 'src/web/client.js',
59
+ 'src/web/style.css',
60
+ 'src/agents/workflow-engine.ts',
61
+ 'src/agents/workflow-pivot-loop.ts',
62
+ 'src/agents/constraint-layer.ts',
63
+ 'src/test/**',
64
+ 'docs/**',
65
+ '*.md',
66
+ 'README.md'
67
+ ];
68
+ const FALLBACK_PATH_DENYLIST = [
69
+ /(^|\/)src\/agents\/pi-sdk\.ts$/, // LLM 抽象层
70
+ /(^|\/)src\/agents\/shell-guard\.ts$/, // 护栏本身
71
+ /(^|\/)src\/agents\/shell-tool\.ts$/, // shell 工具实现
72
+ /(^|\/)src\/heartbeat\//, // 心跳
73
+ /(^|\/)src\/network\//, // P2P / libp2p / iroh
74
+ /(^|\/)src\/pi-ecosystem-judgment\//, // judgment 系统
75
+ /(^|\/)package\.json$/,
76
+ /(^|\/)package-lock\.json$/,
77
+ /(^|\/)tsconfig.*\.json$/,
78
+ /(^|\/)\.env(\.|$)/,
79
+ /(^|\/)\.git\//,
80
+ /(^|\/)\.bolloon\//, // 策略文件 / sessions / persona
81
+ /(^|\/)dist\//,
82
+ /(^|\/)node_modules\//,
83
+ ];
84
+ const FALLBACK_ARG_DENYLIST = [
85
+ /^\s*push\s+(-f|--force)/i,
86
+ /^\s*push\s+origin\s+(master|main)\b/i,
87
+ /^\s*reset\s+--hard\b/i,
88
+ /^\s*clean\s+-fd?\b/i,
89
+ /^\s*--inspect\b/,
90
+ /[|&;`$()<>]/, // shell 元字符
91
+ /\brm\s+-rf?\b/i,
92
+ /\bsudo\b/i,
93
+ /\bsu\b/i,
94
+ /\bcurl\b/i,
95
+ /\bwget\b/i,
96
+ /\.\.\//, // 路径逃逸
97
+ /^\//, // 绝对路径
98
+ /^[a-zA-Z]:\\/, // Windows 绝对路径
99
+ ];
100
+ let cachedPolicy = null;
101
+ let policyLoadedAt = 0;
102
+ const POLICY_TTL_MS = 60_000; // 60 秒缓存 (避免每次 shell_exec 都读盘)
103
+ const POLICY_PATH = path.join(os.homedir(), '.bolloon', 'self-improve-policy.json');
104
+ const POLICY_AUDIT_PATH = path.join(os.homedir(), '.bolloon', 'self-improve-audit.log');
105
+ /**
106
+ * 默认策略模板 - 第一次启动时写到磁盘
107
+ */
108
+ function getDefaultPolicy() {
109
+ return {
110
+ version: 1,
111
+ commandAllowlist: Array.from(FALLBACK_COMMAND_ALLOWLIST),
112
+ pathAllowlist: [...FALLBACK_PATH_ALLOWLIST],
113
+ pathDenylist: FALLBACK_PATH_DENYLIST.map(r => r.source),
114
+ cooldownMs: 6 * 60 * 60 * 1000,
115
+ sandboxCwd: '.bolloon-shell-sandbox',
116
+ branchPrefix: 'agent/self-imp-'
117
+ };
118
+ }
119
+ /**
120
+ * 加载策略 (有缓存)
121
+ * 加载失败返回 null, 调用方应回退到硬编码兜底
122
+ */
123
+ export function loadPolicy(forceReload = false) {
124
+ const now = Date.now();
125
+ if (!forceReload && cachedPolicy && now - policyLoadedAt < POLICY_TTL_MS) {
126
+ return cachedPolicy;
127
+ }
128
+ try {
129
+ if (!fs.existsSync(POLICY_PATH)) {
130
+ // 第一次启动: 写入默认策略
131
+ const dir = path.dirname(POLICY_PATH);
132
+ fs.mkdirSync(dir, { recursive: true });
133
+ fs.writeFileSync(POLICY_PATH, JSON.stringify(getDefaultPolicy(), null, 2));
134
+ console.log(`[shell-guard] 已生成默认策略: ${POLICY_PATH}`);
135
+ }
136
+ const raw = fs.readFileSync(POLICY_PATH, 'utf-8');
137
+ const parsed = JSON.parse(raw);
138
+ // 极简 schema 校验
139
+ if (!parsed.version || !Array.isArray(parsed.commandAllowlist) || !Array.isArray(parsed.pathAllowlist) || !Array.isArray(parsed.pathDenylist)) {
140
+ console.warn('[shell-guard] 策略文件 schema 不对, 用硬编码兜底');
141
+ return null;
142
+ }
143
+ cachedPolicy = parsed;
144
+ policyLoadedAt = now;
145
+ return parsed;
146
+ }
147
+ catch (err) {
148
+ console.warn('[shell-guard] 策略文件加载失败, 用硬编码兜底:', err);
149
+ return null;
150
+ }
151
+ }
152
+ /**
153
+ * 审计日志: 记录所有被拒/被允许的 shell_exec 调用
154
+ */
155
+ export function auditShellCall(result, cmd, args, reason, targetPath) {
156
+ try {
157
+ const dir = path.dirname(POLICY_AUDIT_PATH);
158
+ fs.mkdirSync(dir, { recursive: true });
159
+ const line = JSON.stringify({
160
+ ts: new Date().toISOString(),
161
+ result,
162
+ cmd,
163
+ args,
164
+ reason,
165
+ targetPath
166
+ }) + '\n';
167
+ fs.appendFileSync(POLICY_AUDIT_PATH, line);
168
+ }
169
+ catch {
170
+ // 审计失败不阻塞
171
+ }
172
+ }
173
+ /**
174
+ * 把通配符模式编译成正则
175
+ * * -> [^/]*
176
+ * ** -> .*
177
+ */
178
+ function compileGlob(pattern) {
179
+ // 转义正则元字符, 但保留 * 和 **
180
+ const escaped = pattern
181
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
182
+ .replace(/\*\*/g, '__DOUBLESTAR__')
183
+ .replace(/\*/g, '[^/]*')
184
+ .replace(/__DOUBLESTAR__/g, '.*');
185
+ return new RegExp('^' + escaped + '$');
186
+ }
187
+ /**
188
+ * 检查命令
189
+ */
190
+ export function checkCommand(cmd, args) {
191
+ const policy = loadPolicy();
192
+ const fullCmd = [cmd, ...args].join(' ').trim();
193
+ // ---- 1. 命令黑名单 (策略里有的话) ----
194
+ if (policy?.commandDenylist) {
195
+ for (const denied of policy.commandDenylist) {
196
+ if (cmd === denied || fullCmd.startsWith(denied)) {
197
+ auditShellCall('denied', cmd, args, `命中 commandDenylist: ${denied}`);
198
+ return { allowed: false, reason: `命令 '${cmd}' 命中策略黑名单: ${denied}`, matchedBy: 'cmd-denylist' };
199
+ }
200
+ }
201
+ }
202
+ // ---- 2. 命令白名单 ----
203
+ const allowSet = policy
204
+ ? new Set(policy.commandAllowlist)
205
+ : FALLBACK_COMMAND_ALLOWLIST;
206
+ if (!allowSet.has(cmd)) {
207
+ auditShellCall('denied', cmd, args, `命令 '${cmd}' 不在白名单`);
208
+ return {
209
+ allowed: false,
210
+ reason: `命令 '${cmd}' 不在白名单. 允许: ${Array.from(allowSet).join(', ')}`,
211
+ matchedBy: policy ? 'cmd-allowlist' : 'fallback-deny'
212
+ };
213
+ }
214
+ // ---- 3. 参数黑名单 (策略文件 + 硬编码兜底都查) ----
215
+ const argPatterns = policy
216
+ ? [
217
+ ...FALLBACK_ARG_DENYLIST,
218
+ // 策略里没有专门的 argDenylist 字段, 复用 pathDenylist 模式不太合适
219
+ // 故意**不**让用户在策略里加 arg denylist - 防止 AI 删自己的 arg 限制
220
+ ]
221
+ : FALLBACK_ARG_DENYLIST;
222
+ for (const arg of args) {
223
+ for (const pattern of argPatterns) {
224
+ if (pattern.test(arg)) {
225
+ auditShellCall('denied', cmd, args, `参数 '${arg}' 命中黑名单`);
226
+ return { allowed: false, reason: `参数 '${arg}' 命中黑名单模式 ${pattern}`, matchedBy: 'arg-denylist' };
227
+ }
228
+ }
229
+ }
230
+ // 整条命令再过一遍
231
+ for (const pattern of argPatterns) {
232
+ if (pattern.test(fullCmd)) {
233
+ auditShellCall('denied', cmd, args, `整条命令命中黑名单`);
234
+ return { allowed: false, reason: `整条命令命中黑名单模式 ${pattern}`, matchedBy: 'arg-denylist' };
235
+ }
236
+ }
237
+ auditShellCall('allowed', cmd, args);
238
+ return { allowed: true };
239
+ }
240
+ /**
241
+ * 检查路径
242
+ *
243
+ * 逻辑:
244
+ * 1. denylist 优先: 命中即拒 (用硬编码兜底正则)
245
+ * 2. allowlist: 命中放行
246
+ * 3. 都不命中: 拒 (默认拒绝)
247
+ */
248
+ export function checkWritePath(targetPath) {
249
+ const policy = loadPolicy();
250
+ const normalized = path.normalize(targetPath).replace(/\\/g, '/');
251
+ // ---- 1. 路径黑名单 (硬编码兜底不可绕过) ----
252
+ // 即便策略文件里 denylist 是空的, 硬编码兜底永远生效
253
+ const hardcodedDenylist = FALLBACK_PATH_DENYLIST;
254
+ for (const pattern of hardcodedDenylist) {
255
+ if (pattern.test(normalized)) {
256
+ auditShellCall('denied', '', [], `路径 '${targetPath}' 命中硬编码禁区`, targetPath);
257
+ return { allowed: false, reason: `路径 '${targetPath}' 命中硬编码禁区 ${pattern}`, matchedBy: 'fallback-deny' };
258
+ }
259
+ }
260
+ // 策略文件里的额外 denylist
261
+ if (policy?.pathDenylist) {
262
+ for (const patternStr of policy.pathDenylist) {
263
+ try {
264
+ const regex = compileGlob(patternStr);
265
+ if (regex.test(normalized)) {
266
+ auditShellCall('denied', '', [], `路径 '${targetPath}' 命中策略 denylist`, targetPath);
267
+ return { allowed: false, reason: `路径 '${targetPath}' 命中策略 denylist: ${patternStr}`, matchedBy: 'path-denylist' };
268
+ }
269
+ }
270
+ catch {
271
+ // 编译失败的模式跳过
272
+ }
273
+ }
274
+ }
275
+ // ---- 2. 路径白名单 (来自策略或兜底) ----
276
+ const allowlist = policy?.pathAllowlist || FALLBACK_PATH_ALLOWLIST;
277
+ for (const patternStr of allowlist) {
278
+ try {
279
+ const regex = compileGlob(patternStr);
280
+ if (regex.test(normalized)) {
281
+ auditShellCall('allowed', '', [], undefined, targetPath);
282
+ return { allowed: true, matchedBy: 'path-allowlist' };
283
+ }
284
+ }
285
+ catch {
286
+ // 编译失败的模式跳过
287
+ }
288
+ }
289
+ // 都不命中: 默认拒绝
290
+ auditShellCall('denied', '', [], `路径 '${targetPath}' 不在任何 allowlist 中`, targetPath);
291
+ return {
292
+ allowed: false,
293
+ reason: `路径 '${targetPath}' 不在白名单. 允许: ${allowlist.join(', ')}`,
294
+ matchedBy: 'path-allowlist'
295
+ };
296
+ }
297
+ // ============================================================================
298
+ // 运行时配置 (从策略文件读, 但有兜底)
299
+ // ============================================================================
300
+ /**
301
+ * 自改分支名前缀
302
+ */
303
+ export function getBranchPrefix() {
304
+ const policy = loadPolicy();
305
+ return policy?.branchPrefix || 'agent/self-imp-';
306
+ }
307
+ /**
308
+ * 冷却期 (毫秒)
309
+ */
310
+ export function getCooldownMs() {
311
+ const policy = loadPolicy();
312
+ return policy?.cooldownMs || 6 * 60 * 60 * 1000;
313
+ }
314
+ /**
315
+ * 沙箱工作目录
316
+ */
317
+ export function getSandboxCwd() {
318
+ const policy = loadPolicy();
319
+ const rel = policy?.sandboxCwd || '.bolloon-shell-sandbox';
320
+ return path.resolve(process.cwd(), rel);
321
+ }
322
+ // ============================================================================
323
+ // 兼容旧 API - 保留原导出名
324
+ // ============================================================================
325
+ /** @deprecated 用 getBranchPrefix() */
326
+ export const SELF_IMPROVE_BRANCH_PREFIX = 'agent/self-imp-';
327
+ /** @deprecated 用 getCooldownMs() */
328
+ export const SELF_IMPROVE_COOLDOWN_MS = 6 * 60 * 60 * 1000;
329
+ /** @deprecated 用 getSandboxCwd() */
330
+ export const SHELL_SANDBOX_CWD = path.resolve(process.cwd(), '.bolloon-shell-sandbox');
331
+ // ============================================================================
332
+ // 写策略 / 审计路径 (供 API 端点用)
333
+ // ============================================================================
334
+ export const POLICY_AUDIT_PATH_PUBLIC = POLICY_AUDIT_PATH;
335
+ /**
336
+ * 把新策略写到磁盘, 立即清缓存让下次 loadPolicy() 重读
337
+ * **只供人手动调用**
338
+ */
339
+ export function writePolicy(newPolicy) {
340
+ try {
341
+ newPolicy.version = (newPolicy.version || 0) + 1;
342
+ const dir = path.dirname(POLICY_PATH);
343
+ fs.mkdirSync(dir, { recursive: true });
344
+ fs.writeFileSync(POLICY_PATH, JSON.stringify(newPolicy, null, 2));
345
+ cachedPolicy = null; // 清缓存
346
+ policyLoadedAt = 0;
347
+ console.log(`[shell-guard] 策略已更新, version=${newPolicy.version}`);
348
+ return true;
349
+ }
350
+ catch (err) {
351
+ console.error('[shell-guard] 写策略失败:', err);
352
+ return false;
353
+ }
354
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Shell 工具: 给 Bolloon agent 跑受限的 shell 命令
3
+ *
4
+ * 这个工具**只做两件事**:
5
+ * 1. 把命令交给硬护栏检查
6
+ * 2. 在沙箱 cwd 下用 child_process 执行
7
+ *
8
+ * AI 完全自主触发自改, 但 shell 工具本身**只接受白名单内命令**.
9
+ * 禁区列表在 shell-guard.ts, AI 改不了那个文件.
10
+ */
11
+ import { spawn } from 'child_process';
12
+ import * as fs from 'fs';
13
+ import { checkCommand, checkWritePath, getSandboxCwd } from './shell-guard.js';
14
+ /**
15
+ * 在沙箱里跑一条命令
16
+ * @param cmd 可执行文件名, 必须命中白名单
17
+ * @param args 参数列表
18
+ * @param opts.timeoutMs 超时毫秒, 默认 30s
19
+ * @param opts.allowedWriteTargets 允许的写入路径, 命中禁区列表的路径会拒
20
+ */
21
+ export async function shellExec(cmd, args = [], opts = {}) {
22
+ // 1. 护栏检查
23
+ const cmdCheck = checkCommand(cmd, args);
24
+ if (!cmdCheck.allowed) {
25
+ return {
26
+ success: false,
27
+ error: `[shell-guard] ${cmdCheck.reason}`,
28
+ deniedByGuard: true
29
+ };
30
+ }
31
+ // 2. 写入目标检查
32
+ if (opts.allowedWriteTargets) {
33
+ for (const target of opts.allowedWriteTargets) {
34
+ const pathCheck = checkWritePath(target);
35
+ if (!pathCheck.allowed) {
36
+ return {
37
+ success: false,
38
+ error: `[shell-guard] ${pathCheck.reason}`,
39
+ deniedByGuard: true
40
+ };
41
+ }
42
+ }
43
+ }
44
+ // 3. 确保沙箱存在
45
+ const sandboxCwd = getSandboxCwd();
46
+ try {
47
+ fs.mkdirSync(sandboxCwd, { recursive: true });
48
+ }
49
+ catch {
50
+ // 已经存在则忽略
51
+ }
52
+ // 4. 跑命令
53
+ return new Promise((resolve) => {
54
+ const proc = spawn(cmd, args, {
55
+ cwd: sandboxCwd,
56
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, // 禁止 git 弹交互
57
+ shell: false, // **关键**: 禁用 shell, 防止元字符注入
58
+ windowsHide: true
59
+ });
60
+ let stdout = '';
61
+ let stderr = '';
62
+ const timeout = setTimeout(() => {
63
+ proc.kill('SIGKILL');
64
+ resolve({ success: false, error: `命令超时 (>${opts.timeoutMs || 30000}ms)`, exitCode: -1 });
65
+ }, opts.timeoutMs || 30000);
66
+ proc.stdout.on('data', (data) => { stdout += data.toString(); });
67
+ proc.stderr.on('data', (data) => { stderr += data.toString(); });
68
+ proc.on('error', (err) => {
69
+ clearTimeout(timeout);
70
+ resolve({ success: false, error: `启动失败: ${err.message}` });
71
+ });
72
+ proc.on('close', (code) => {
73
+ clearTimeout(timeout);
74
+ const output = (stdout + (stderr ? `\n[stderr]\n${stderr}` : '')).trim();
75
+ if (code === 0) {
76
+ resolve({ success: true, output, exitCode: 0 });
77
+ }
78
+ else {
79
+ resolve({ success: false, output, error: `exit code ${code}`, exitCode: code ?? -1 });
80
+ }
81
+ });
82
+ });
83
+ }