aims-workstation 0.1.2 → 0.1.3

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 CHANGED
@@ -9,8 +9,9 @@
9
9
  - **工作台**:每个助手一个专属操作面;区块自由布局,**对话式开发面板插件**(描述需求 → 二开助手生成 → 草稿审阅 → 发布)
10
10
  - **工作区**:助手 1:N 工作区(文件现场隔离),`exec` 锚定工作区目录,数据集按工作区分库
11
11
  - **分级自主**:L0 只读 → L2 确认执行(写操作挂起人工确认)→ L3 自主,危险命令永久拒绝;全程审计
12
+ - **守护栏可维护**:危险命令清单(正则)与只读白名单在「系统设置·守护栏」增删,保存即热生效;`sudo -u <用户>` 降权形式可配置为 L2 确认执行;删减危险规则需二次确认并写审计
12
13
  - **并行会话**:多助手并行、后台运行进度跟踪、确认卡跨切换恢复
13
- - **系统设置**:ARM 源 / LLM(任意 OpenAI 兼容端点)/ 全局场景画像,界面配置即时生效
14
+ - **系统设置**:ARM 源 / LLM(任意 OpenAI 兼容端点)/ 全局场景画像 / 守护栏策略,界面配置即时生效
14
15
 
15
16
  ## 快速开始
16
17
 
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "@earendil-works/pi-ai": "^1.2.0"
14
14
  },
15
15
  "packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1",
16
- "version": "0.1.2",
16
+ "version": "0.1.3",
17
17
  "description": "AIMS 工作站:本地智能体工作站——ARM 助手本地化(下载/离线/升级)、工作台对话式定制、多工作区隔离、分级自主与审计",
18
18
  "bin": {
19
19
  "aims-workstation": "bin/aims-workstation.js"
@@ -31,4 +31,4 @@
31
31
  ".env.example",
32
32
  "README.md"
33
33
  ]
34
- }
34
+ }
package/server.js CHANGED
@@ -2,6 +2,9 @@
2
2
  // 链路: ARM员工库 → 下载物化本地 → 平台实例化(pi-ai + 工具注入 + 分级自主) → 工作台(DSL) → 经验回流ARM
3
3
  // Agent 层基于 @earendil-works/pi-ai(统一 LLM API),前端为 web/ 预构建的 shadcn/ui 应用(web/dist)
4
4
  'use strict';
5
+ // TLS 证书校验关闭(用户决策:内网私有部署自签证书常见,能访问优先)
6
+ // 覆盖所有出站 fetch(ARM REST/MCP、LLM、面板代理)。本工具面向内网/本机使用。
7
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
5
8
  const http = require('http');
6
9
  const fs = require('fs');
7
10
  const path = require('path');
@@ -74,8 +77,24 @@ const ARM_MCP_URL = process.env.ARM_MCP_URL || env.ARM_MCP_URL;
74
77
  // ── 运行时配置(ARM 源与 LLM 均可私有化部署:在「系统设置」页切换,存 AIMS_HOME/config/platform.json,优先级 运行时 > 环境变量 > .env)──
75
78
  const CFG_FILE = path.join(AIMS_HOME, 'config/platform.json');
76
79
  const cfg = { arm_api_base: '', arm_mcp_url: '', arm_token: '', llm_base_url: '', llm_api_key: '', llm_model: '', scene_profile: '', access_token: '' };
77
- function loadCfg() { try { Object.assign(cfg, JSON.parse(fs.readFileSync(CFG_FILE, 'utf8'))); } catch { } }
80
+ // 守护栏默认策略(可在「系统设置·守护栏」维护,存 platform.json,保存即热生效):deny=正则源串(命中即永久拒绝,任何级别不豁免);
81
+ // ro=只读白名单命令名(免确认直行);sudo_u_l2=true 时 `sudo -u <用户>` 降权形式不再永久拒绝、按写命令走 L2 人工确认(裸 sudo 仍拒绝)
82
+ const GUARD_DEFAULT = {
83
+ deny: ['\\bsudo\\b', '\\b(shutdown|reboot|halt|poweroff|mkfs)\\b', '\\bdd\\s+if=', 'rm\\s+-[a-z]*[rf][a-z]*\\s+/(\\s|$)', ':\\(\\)\\s*\\{.*\\};:', '\\bkill\\s+-9\\s+1\\b'],
84
+ ro: ['ls', 'cat', 'head', 'tail', 'wc', 'grep', 'find', 'file', 'stat', 'whoami', 'date', 'uname', 'uptime', 'df', 'du', 'ps', 'curl', 'ping', 'nc', 'netstat', 'dig', 'nslookup', 'sqlite3', 'echo', 'which', 'env'],
85
+ sudo_u_l2: false,
86
+ };
87
+ function normalizeGuard(g) {
88
+ g = g && typeof g === 'object' ? g : {};
89
+ return {
90
+ deny: Array.isArray(g.deny) ? [...new Set(g.deny.map((s) => String(s).trim()).filter(Boolean))] : GUARD_DEFAULT.deny.slice(),
91
+ ro: Array.isArray(g.ro) ? [...new Set(g.ro.map((s) => String(s).trim()).filter(Boolean))] : GUARD_DEFAULT.ro.slice(),
92
+ sudo_u_l2: !!g.sudo_u_l2,
93
+ };
94
+ }
95
+ function loadCfg() { try { Object.assign(cfg, JSON.parse(fs.readFileSync(CFG_FILE, 'utf8'))); } catch { } cfg.guard = normalizeGuard(cfg.guard); }
78
96
  loadCfg();
97
+ const guardNow = () => cfg.guard;
79
98
  function saveCfg() { fs.writeFileSync(CFG_FILE, JSON.stringify(cfg, null, 2)); }
80
99
  // 站点地址:运行时配置 > ARM_API_BASE(env) > 从 MCP URL 推导;MCP 端点:显式配置 > 跟随站点地址 > env
81
100
  const armApiBase = () => cfg.arm_api_base || process.env.ARM_API_BASE || env.ARM_API_BASE || String(env.ARM_MCP_URL || '').replace(/\/api\/mcp$/, '');
@@ -197,6 +216,16 @@ async function armRest(pathname, init = {}, timeout_ms = 20000) {
197
216
  const data = await res.json();
198
217
  if (!res.ok || data.ok === false) throw new Error(`ARM REST ${pathname.split('?')[0]}: ${data.msg || res.status}`);
199
218
  return data.data;
219
+ } catch (e) {
220
+ if (e.message === 'fetch failed') { // undici 把网络层错误笼统包装,展开真实原因(DNS/连接/TLS)
221
+ const c = e.cause || {};
222
+ const reason = c.code || c.message || '未知网络错误';
223
+ const hint = /LEAF|CERT|SSL|TLS/i.test(String(reason)) ? '——HTTPS 证书校验失败(自签证书?内网解析应直连 HTTP)'
224
+ : /ENOTFOUND|EAI_AGAIN/.test(String(reason)) ? '——域名解析失败(当前 DNS 无此内网记录)'
225
+ : /ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH/.test(String(reason)) ? '——目标不可达(连接被拒/超时)' : '';
226
+ throw new Error(`ARM 请求失败: ${reason}${hint}`);
227
+ }
228
+ throw e;
200
229
  } finally { clearTimeout(timer); }
201
230
  }
202
231
  const vVer = (v) => (String(v || '').startsWith('v') ? String(v) : `v${v || '0.0.0'}`); // REST 无 v 前缀,统一内部表示
@@ -503,11 +532,15 @@ function deleteWorkspace(agentId, wsId, purge) {
503
532
 
504
533
  // ── 通用计算原语与平台数据服务(pi-core 哲学:少量原语 + 组合,而非功能堆叠)──
505
534
  const { exec: cpExec } = require('node:child_process');
506
- const EXEC_DENY = [/\bsudo\b/, /\b(shutdown|reboot|halt|poweroff|mkfs)\b/, /\bdd\s+if=/, /rm\s+-[a-z]*[rf][a-z]*\s+\/(\s|$)/, /:\(\)\s*\{.*\};:/, /\bkill\s+-9\s+1\b/];
507
- const EXEC_RO = ['ls', 'cat', 'head', 'tail', 'wc', 'grep', 'find', 'file', 'stat', 'whoami', 'date', 'uname', 'uptime', 'df', 'du', 'ps', 'curl', 'ping', 'nc', 'netstat', 'dig', 'nslookup', 'sqlite3', 'echo', 'which', 'env'];
508
535
  function classifyExec(command, autonomy) {
509
536
  const cmd = String(command || '').trim();
510
- if (EXEC_DENY.some((re) => re.test(cmd))) return { status: 'blocked', reason: '【守护栏】命令命中危险清单(sudo/毁灭性操作等)' };
537
+ const g = guardNow();
538
+ // sudo 降权特例(可配置):开启后剥掉 `sudo -u <用户>` 片段再匹配危险清单(嵌套的裸 sudo/毁灭性命令仍会命中),剩余部分按普通命令分级
539
+ const denyCmd = g.sudo_u_l2 ? cmd.replace(/\bsudo\s+(?:-\w+\s+)*-u\s+\S+/g, '') : cmd;
540
+ const denyRes = [];
541
+ for (const src of g.deny) { try { denyRes.push(new RegExp(src)); } catch { /* 坏正则跳过(PUT 时已校验,历史数据兜底) */ } }
542
+ if (denyRes.some((re) => re.test(denyCmd))) return { status: 'blocked', reason: '【守护栏】命令命中危险清单(清单可在 系统设置·守护栏 维护)' };
543
+ const EXEC_RO = g.ro;
511
544
  const noRedirect = cmd.replace(/2>&1/g, ''); // 重定向/tee 使白名单命令具备写能力,不再视为只读
512
545
  const SQL_WRITE = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|REPLACE)\b/i;
513
546
  // 复合命令逐段判定:每个由 ; && || | 换行 分隔的段都须只读(控制关键字与 for 头部放行)
@@ -522,7 +555,7 @@ function classifyExec(command, autonomy) {
522
555
  const ro = segments.length > 0 && segments.every(segOk) && !/(>>|>)|\btee\b/.test(noRedirect);
523
556
  if (ro) return { status: 'ok' }; // 只读白名单:任何级别免确认(取证不应有摩擦)
524
557
  if (autonomy >= 2) return { status: 'pending' }; // 写命令:L2 可发起,执行前需人工确认
525
- return { status: 'blocked', reason: `【守护栏】L${autonomy} 仅放行只读命令(ls/cat/grep/curl/ping/nc/sqlite3只读等)。写操作需 L2 并人工确认` };
558
+ return { status: 'blocked', reason: `【守护栏】L${autonomy} 仅放行只读命令(${EXEC_RO.slice(0, 8).join('/')}等,完整清单见 系统设置·守护栏)。写操作需 L2 并人工确认` };
526
559
  }
527
560
  const PRIMITIVE_TOOLS = {
528
561
  exec: {
@@ -1232,7 +1265,7 @@ function systemPrompt(armAgent, deployment, autonomy, ws) {
1232
1265
  '- 连通性实测:exec `nc -vz -w 3 <host> <port>`("能否连接"必须实测)',
1233
1266
  '- 平台数据:先 data_describe 看字典,再 data_query 查询(服务端按助手隔离)',
1234
1267
  '- 现场若有已接入的数据面板,以工作台展示为准;没有就说没有,不猜测',
1235
- 'exec 只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝。',
1268
+ `exec 只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝${guardNow().sudo_u_l2 ? '(例外:sudo 降权形式 sudo -u <用户> 按写命令处理,L2 确认后可执行;裸 sudo 仍拒绝)' : ''}。`,
1236
1269
  '',
1237
1270
  '【事实与推断分离(硬规则)】',
1238
1271
  '- 连通性结论只能来自实测(nc/curl);记录存在≠可达,设备状态在线≠可达',
@@ -1377,10 +1410,27 @@ const server = http.createServer(async (req, res) => {
1377
1410
  },
1378
1411
  scene: { profile: sceneProfile(), length: sceneProfile().length },
1379
1412
  auth: { enabled: !!accessToken(), token_masked: accessToken() ? `${accessToken().slice(0, 4)}…${accessToken().slice(-2)}` : '' },
1413
+ guard: { ...guardNow(), default_deny: GUARD_DEFAULT.deny, default_ro: GUARD_DEFAULT.ro },
1380
1414
  });
1381
1415
  }
1382
1416
  if (req.method === 'PUT' && pathname === '/api/settings') {
1383
- const { arm_api_base, arm_mcp_url, arm_token, llm_base_url, llm_api_key, llm_model, scene_profile, access_token } = await body(req);
1417
+ const { arm_api_base, arm_mcp_url, arm_token, llm_base_url, llm_api_key, llm_model, scene_profile, access_token, guard } = await body(req);
1418
+ // 守护栏维护:deny 正则可编译 / ro 须为单命令名;保存即热生效(classifyExec 每次调用现读配置);删减危险规则记入审计
1419
+ let guardAudit = null;
1420
+ if (guard !== undefined) {
1421
+ if (!guard || typeof guard !== 'object') return json(res, 400, { error: 'guard 须为对象 { deny, ro, sudo_u_l2 }' });
1422
+ const next = normalizeGuard(guard);
1423
+ for (const src of next.deny) { try { new RegExp(src); } catch (e) { return json(res, 400, { error: `危险清单正则不合法:${src}(${e.message})` }); } }
1424
+ for (const c of next.ro) { if (!/^[A-Za-z0-9_.-]{1,32}$/.test(c)) return json(res, 400, { error: `只读白名单项须为单个命令名(字母数字._-):${c}` }); }
1425
+ const cur = guardNow();
1426
+ guardAudit = {
1427
+ removed_deny: cur.deny.filter((d) => !next.deny.includes(d)),
1428
+ added_deny: next.deny.filter((d) => !cur.deny.includes(d)),
1429
+ removed_ro: cur.ro.filter((c) => !next.ro.includes(c)),
1430
+ sudo_u_l2: next.sudo_u_l2,
1431
+ };
1432
+ cfg.guard = next;
1433
+ }
1384
1434
  let authChanged = false;
1385
1435
  if (access_token !== undefined) { const t = String(access_token || '').trim(); authChanged = t !== accessToken(); cfg.access_token = t; } // 置空=关闭认证
1386
1436
  const normUrl = (u) => { const s = String(u || '').trim().replace(/\/+$/, ''); return s; };
@@ -1425,7 +1475,7 @@ const server = http.createServer(async (req, res) => {
1425
1475
  try { llm_probe = await llmProbe(); } catch (e) { llm_probe = { online: false, error: String(e.message).slice(0, 150) }; }
1426
1476
  }
1427
1477
  saveCfg();
1428
- audit({ actor: 'settings', action: 'save', arm_api_base: cfg.arm_api_base, mcp_explicit: !!cfg.arm_mcp_url, llm_model: cfg.llm_model, llm_base_changed: llm_base_url !== undefined, token_changed: arm_token !== undefined || llm_api_key !== undefined, scene_profile_changed: sceneChanged, scene_profile_len: sceneProfile().length, auth_changed: authChanged });
1478
+ audit({ actor: 'settings', action: 'save', arm_api_base: cfg.arm_api_base, mcp_explicit: !!cfg.arm_mcp_url, llm_model: cfg.llm_model, llm_base_changed: llm_base_url !== undefined, token_changed: arm_token !== undefined || llm_api_key !== undefined, scene_profile_changed: sceneChanged, scene_profile_len: sceneProfile().length, auth_changed: authChanged, guard_changed: guardAudit });
1429
1479
  // ARM 保存后即时探测(失败不回滚:允许先配置后启动 ARM)
1430
1480
  let probe = { online: false, error: '' };
1431
1481
  if ([arm_api_base, arm_mcp_url, arm_token].some((v) => v !== undefined)) {
@@ -2065,6 +2115,14 @@ if (process.argv.includes('--test-guard')) {
2065
2115
  ['exec', 3, { command: 'echo hi > /tmp/a.txt' }, 'ok'],
2066
2116
  ['exec', 3, { command: 'sudo reboot' }, 'blocked'],
2067
2117
  ];
2118
+ // 守护栏可配置(系统设置·守护栏):sudo_u_l2 开启后,`sudo -u <用户>` 降权按写命令走 L2 确认;裸 sudo 与嵌套危险命令仍拒绝
2119
+ cfg.guard = normalizeGuard({ ...guardNow(), sudo_u_l2: true });
2120
+ cases.push(
2121
+ ['exec', 2, { command: "sshpass -p x ssh -o StrictHostKeyChecking=no root@192.0.2.1 'sudo -u aims bash -c \"npm install -g aims-workstation\"' 2>&1 | tail -40" }, 'pending'],
2122
+ ['exec', 0, { command: 'sudo -u aims npm install -g x' }, 'blocked'],
2123
+ ['exec', 2, { command: "sudo -u aims bash -c 'sudo reboot'" }, 'blocked'],
2124
+ ['exec', 2, { command: 'sudo apt install -y x' }, 'blocked'],
2125
+ );
2068
2126
  let pass = true;
2069
2127
  for (const [tool, lv, args, want] of cases) {
2070
2128
  const g = guardCheck(tool, lv, null, args); const ok = g.status === want; if (!ok) pass = false;