larkin 0.2.77 → 0.2.79

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.
@@ -7,7 +7,10 @@ import path from "node:path";
7
7
  import { readProcessState } from "../platform/process-state.mjs";
8
8
  import { currentProcessMetadata } from "../platform/process-inspect.cjs";
9
9
  import { processCommandToken } from "./internal-command.mjs";
10
- import { isWindows, notGroupOrWorldAccessible } from "../platform/secure-metadata.mjs";
10
+ import { isWindows, notGroupOrWorldAccessible, secureWindowsDirectoryAcl } from "../platform/secure-metadata.mjs";
11
+ // Windows 上 socket 无法 lstat(EACCES),用全数字占位 binding 使 authority 校验通过;
12
+ // 该平台的安全边界是 socket root 目录的 ACL,而非 inode 身份比对。
13
+ const WINDOWS_SOCKET_BINDING = { device: "0", inode: "0", owner: "0", changeTimeNs: "0" };
11
14
  const AGENT_ID = /^cli_[A-Za-z0-9]+$/;
12
15
  const OPERATION_ID = /^[A-Za-z0-9_-]{8,128}$/;
13
16
  const AUTHORIZATION = /^[A-Za-z0-9_-]{43,128}$/;
@@ -32,6 +35,12 @@ function controlSocketRoot(larkinHome) {
32
35
  throw new Error("无法生成满足 Unix socket 长度限制的 control root");
33
36
  }
34
37
  function assertSecureSocketDirectory(root) {
38
+ if (isWindows) {
39
+ // Windows:socket 本身无法 lstat(EACCES),安全边界是目录 ACL。
40
+ // 每次启动重新收紧为「当前用户 + SYSTEM」并回读校验。
41
+ secureWindowsDirectoryAcl(root, { label: "control socket root" });
42
+ return root;
43
+ }
35
44
  const stat = fs.lstatSync(root);
36
45
  if (!stat.isDirectory() || stat.isSymbolicLink()
37
46
  || (typeof process.getuid === "function" && stat.uid !== process.getuid())
@@ -223,6 +232,15 @@ function assertSupervisorAuthority(larkinHome, expectedToken) {
223
232
  return authority;
224
233
  }
225
234
  function prepareSocket(socket) {
235
+ if (isWindows) {
236
+ // Windows:socket 路径无法 lstat(EACCES);尽力清除陈旧占用,失败即忽略,
237
+ // 由 listen 的独占语义兜底(见 listenPrivate 的 win32 分支)。
238
+ try {
239
+ fs.unlinkSync(socket);
240
+ }
241
+ catch { /* ignore */ }
242
+ return;
243
+ }
226
244
  try {
227
245
  const stat = fs.lstatSync(socket);
228
246
  if (!stat.isSocket() || stat.isSymbolicLink()
@@ -246,6 +264,14 @@ function cleanupSocketRoot(root) {
246
264
  }
247
265
  }
248
266
  async function closePrivateServer(server, socket, identity) {
267
+ if (isWindows) {
268
+ // Windows:socket 无 lstat 身份可比对,直接关服并清理目录(残留由 ENOTEMPTY 忽略)。
269
+ if (server.listening) {
270
+ await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
271
+ }
272
+ cleanupSocketRoot(path.dirname(socket));
273
+ return;
274
+ }
249
275
  let shield = null;
250
276
  let closeError;
251
277
  try {
@@ -301,6 +327,26 @@ async function closePrivateServer(server, socket, identity) {
301
327
  throw closeError;
302
328
  }
303
329
  async function listenPrivate(server, socket) {
330
+ if (isWindows) {
331
+ // Windows:bun 的 unix socket 可正常 listen/connect/chmod/close,但 lstatSync(socket)
332
+ // 抛 EACCES(socket 不是普通文件系统节点)。因此跳过 lstat 身份比对,安全边界
333
+ // 由 assertSecureSocketDirectory 收紧的目录 ACL 承担。
334
+ try {
335
+ await new Promise((resolve, reject) => {
336
+ server.once("error", reject);
337
+ server.listen(socket, () => { server.off("error", reject); resolve(); });
338
+ });
339
+ try {
340
+ fs.chmodSync(socket, 0o600);
341
+ }
342
+ catch { /* Windows chmod best-effort */ }
343
+ return WINDOWS_SOCKET_BINDING;
344
+ }
345
+ catch (error) {
346
+ await closePrivateServer(server, socket, WINDOWS_SOCKET_BINDING);
347
+ throw error;
348
+ }
349
+ }
304
350
  let identity = null;
305
351
  try {
306
352
  await new Promise((resolve, reject) => {
@@ -711,6 +711,9 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
711
711
  domain: agent.feishuDomain || "https://open.feishu.cn",
712
712
  source: "larkin",
713
713
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
714
+ // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
715
+ // 保证 messageId/threadId/发送者与内容、提及永远同源(见 #66)。
716
+ batch: { text: { delayMs: 0 } },
714
717
  handshakeTimeoutMs: 15_000,
715
718
  keepalive: {
716
719
  enabled: true,
@@ -823,6 +826,9 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
823
826
  domain: agent.feishuDomain || "https://open.feishu.cn",
824
827
  source: "larkin",
825
828
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
829
+ // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
830
+ // 保证 messageId/threadId/发送者与内容、提及永远同源(见 #66)。
831
+ batch: { text: { delayMs: 0 } },
826
832
  handshakeTimeoutMs: 15_000,
827
833
  keepalive: { enabled: true, intervalMs: 15_000 },
828
834
  includeRawEvent: true,
@@ -671,6 +671,9 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
671
671
  domain: agent.feishuDomain || "https://open.feishu.cn",
672
672
  source: "larkin",
673
673
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
674
+ // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
675
+ // 保证 messageId/threadId/发送者与内容、提及永远同源(见 #66)。
676
+ batch: { text: { delayMs: 0 } },
674
677
  handshakeTimeoutMs: 15_000,
675
678
  keepalive: {
676
679
  enabled: true,
@@ -783,6 +786,9 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
783
786
  domain: agent.feishuDomain || "https://open.feishu.cn",
784
787
  source: "larkin",
785
788
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
789
+ // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
790
+ // 保证 messageId/threadId/发送者与内容、提及永远同源(见 #66)。
791
+ batch: { text: { delayMs: 0 } },
786
792
  handshakeTimeoutMs: 15_000,
787
793
  keepalive: { enabled: true, intervalMs: 15_000 },
788
794
  includeRawEvent: true,
@@ -8,8 +8,12 @@ exports.exactMode = exactMode;
8
8
  exports.notGroupOrWorldAccessible = notGroupOrWorldAccessible;
9
9
  exports.fsyncDirectory = fsyncDirectory;
10
10
  exports.fsyncDirectoryOf = fsyncDirectoryOf;
11
+ exports.runWindowsDirectoryAcl = runWindowsDirectoryAcl;
12
+ exports.secureWindowsDirectoryAcl = secureWindowsDirectoryAcl;
11
13
  const node_fs_1 = __importDefault(require("node:fs"));
14
+ const node_os_1 = __importDefault(require("node:os"));
12
15
  const node_path_1 = __importDefault(require("node:path"));
16
+ const node_child_process_1 = require("node:child_process");
13
17
  /**
14
18
  * Cross-platform secure-metadata helpers. POSIX platforms enforce ownership and
15
19
  * mode bits (fail-closed). Windows has no POSIX mode bits, so mode-bit assertions
@@ -46,3 +50,66 @@ function fsyncDirectory(directory) {
46
50
  function fsyncDirectoryOf(file) {
47
51
  fsyncDirectory(node_path_1.default.dirname(file));
48
52
  }
53
+ const SYSTEM_SID = "*S-1-5-18";
54
+ const ACE_LINE = /^\s*(.+?)\s*:(\((?:[A-Za-z]+)\))+\s*$/;
55
+ function allowedAclPrincipal(principal, username) {
56
+ const candidate = principal.trim().toLocaleLowerCase("en-US");
57
+ const user = username.toLocaleLowerCase("en-US");
58
+ if (!candidate)
59
+ return false;
60
+ if (candidate === user)
61
+ return true;
62
+ const suffix = candidate.split("\\").at(-1) ?? candidate;
63
+ if (suffix === user)
64
+ return true;
65
+ if (candidate === "*s-1-5-18" || candidate === "system" || candidate === "nt authority\\system")
66
+ return true;
67
+ return false;
68
+ }
69
+ /**
70
+ * 执行 Windows ACL 收紧序列(无平台守卫,供测试直接驱动与 win32 分支调用):
71
+ * 1) /inheritance:r 去除继承;2) 只授予当前用户 + SYSTEM 完全控制;
72
+ * 3) 显式移除 Users/Authenticated Users/Everyone;4) 回读 ACL 校验,
73
+ * 任何残留第三方 ACE 一律 fail-closed。icacls 为 Windows 内置工具。
74
+ */
75
+ function runWindowsDirectoryAcl(directory, options = {}) {
76
+ const label = options.label ?? directory;
77
+ const username = String(options.username ?? node_os_1.default.userInfo().username ?? "").trim();
78
+ if (!username)
79
+ throw new Error(`Windows ACL 收紧失败(${label}):无法取得当前用户名`);
80
+ const run = (args) => {
81
+ const result = (options.spawn ?? node_child_process_1.spawnSync)("icacls", [directory, ...args], {
82
+ encoding: "utf8", windowsHide: true, maxBuffer: 1024 * 1024,
83
+ });
84
+ if (result.error || result.status !== 0) {
85
+ throw new Error(`Windows ACL 收紧失败(${label}):icacls ${args[0] ?? "查询"} 退出 ${result.status ?? "无"}${result.error ? `(${result.error.message})` : ""}`);
86
+ }
87
+ return String(result.stdout || "");
88
+ };
89
+ run(["/inheritance:r"]);
90
+ run(["/grant:r", `${username}:(OI)(CI)F`, "/grant:r", `${SYSTEM_SID}:(OI)(CI)F`]);
91
+ run(["/remove:g", "Users", "/remove:g", "Authenticated Users", "/remove:g", "Everyone"]);
92
+ const listing = run([]);
93
+ // icacls 首行是「<路径> <首条 ACE>」同行,其余 ACE 缩进另起一行;先剥掉已知目录前缀再解析。
94
+ const pathPrefix = directory.toLocaleLowerCase("en-US");
95
+ const offenders = listing.split(/\r?\n/).map((line) => {
96
+ let body = line.trimStart();
97
+ if (body.toLocaleLowerCase("en-US").startsWith(`${pathPrefix} `))
98
+ body = body.slice(directory.length + 1).trimStart();
99
+ return body;
100
+ }).filter((line) => ACE_LINE.test(line))
101
+ .map((line) => (line.match(ACE_LINE) ?? ["", ""])[1])
102
+ .filter((principal) => !allowedAclPrincipal(principal, username));
103
+ if (offenders.length > 0) {
104
+ throw new Error(`Windows ACL 校验失败(${label}):存在未授权访问者 ${offenders.join(", ")}`);
105
+ }
106
+ }
107
+ /**
108
+ * Windows 没有 POSIX 权限位(mode 恒为 0o666、getuid 为 undefined):
109
+ * 用 runWindowsDirectoryAcl 收紧目录 ACL 并回读校验。非 win32 平台 no-op。
110
+ */
111
+ function secureWindowsDirectoryAcl(directory, options = {}) {
112
+ if (!exports.isWindows)
113
+ return;
114
+ runWindowsDirectoryAcl(directory, options);
115
+ }
@@ -1,5 +1,7 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
2
3
  import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
3
5
  /**
4
6
  * Cross-platform secure-metadata helpers. POSIX platforms enforce ownership and
5
7
  * mode bits (fail-closed). Windows has no POSIX mode bits, so mode-bit assertions
@@ -36,3 +38,66 @@ export function fsyncDirectory(directory) {
36
38
  export function fsyncDirectoryOf(file) {
37
39
  fsyncDirectory(path.dirname(file));
38
40
  }
41
+ const SYSTEM_SID = "*S-1-5-18";
42
+ const ACE_LINE = /^\s*(.+?)\s*:(\((?:[A-Za-z]+)\))+\s*$/;
43
+ function allowedAclPrincipal(principal, username) {
44
+ const candidate = principal.trim().toLocaleLowerCase("en-US");
45
+ const user = username.toLocaleLowerCase("en-US");
46
+ if (!candidate)
47
+ return false;
48
+ if (candidate === user)
49
+ return true;
50
+ const suffix = candidate.split("\\").at(-1) ?? candidate;
51
+ if (suffix === user)
52
+ return true;
53
+ if (candidate === "*s-1-5-18" || candidate === "system" || candidate === "nt authority\\system")
54
+ return true;
55
+ return false;
56
+ }
57
+ /**
58
+ * 执行 Windows ACL 收紧序列(无平台守卫,供测试直接驱动与 win32 分支调用):
59
+ * 1) /inheritance:r 去除继承;2) 只授予当前用户 + SYSTEM 完全控制;
60
+ * 3) 显式移除 Users/Authenticated Users/Everyone;4) 回读 ACL 校验,
61
+ * 任何残留第三方 ACE 一律 fail-closed。icacls 为 Windows 内置工具。
62
+ */
63
+ export function runWindowsDirectoryAcl(directory, options = {}) {
64
+ const label = options.label ?? directory;
65
+ const username = String(options.username ?? os.userInfo().username ?? "").trim();
66
+ if (!username)
67
+ throw new Error(`Windows ACL 收紧失败(${label}):无法取得当前用户名`);
68
+ const run = (args) => {
69
+ const result = (options.spawn ?? spawnSync)("icacls", [directory, ...args], {
70
+ encoding: "utf8", windowsHide: true, maxBuffer: 1024 * 1024,
71
+ });
72
+ if (result.error || result.status !== 0) {
73
+ throw new Error(`Windows ACL 收紧失败(${label}):icacls ${args[0] ?? "查询"} 退出 ${result.status ?? "无"}${result.error ? `(${result.error.message})` : ""}`);
74
+ }
75
+ return String(result.stdout || "");
76
+ };
77
+ run(["/inheritance:r"]);
78
+ run(["/grant:r", `${username}:(OI)(CI)F`, "/grant:r", `${SYSTEM_SID}:(OI)(CI)F`]);
79
+ run(["/remove:g", "Users", "/remove:g", "Authenticated Users", "/remove:g", "Everyone"]);
80
+ const listing = run([]);
81
+ // icacls 首行是「<路径> <首条 ACE>」同行,其余 ACE 缩进另起一行;先剥掉已知目录前缀再解析。
82
+ const pathPrefix = directory.toLocaleLowerCase("en-US");
83
+ const offenders = listing.split(/\r?\n/).map((line) => {
84
+ let body = line.trimStart();
85
+ if (body.toLocaleLowerCase("en-US").startsWith(`${pathPrefix} `))
86
+ body = body.slice(directory.length + 1).trimStart();
87
+ return body;
88
+ }).filter((line) => ACE_LINE.test(line))
89
+ .map((line) => (line.match(ACE_LINE) ?? ["", ""])[1])
90
+ .filter((principal) => !allowedAclPrincipal(principal, username));
91
+ if (offenders.length > 0) {
92
+ throw new Error(`Windows ACL 校验失败(${label}):存在未授权访问者 ${offenders.join(", ")}`);
93
+ }
94
+ }
95
+ /**
96
+ * Windows 没有 POSIX 权限位(mode 恒为 0o666、getuid 为 undefined):
97
+ * 用 runWindowsDirectoryAcl 收紧目录 ACL 并回读校验。非 win32 平台 no-op。
98
+ */
99
+ export function secureWindowsDirectoryAcl(directory, options = {}) {
100
+ if (!isWindows)
101
+ return;
102
+ runWindowsDirectoryAcl(directory, options);
103
+ }
@@ -5,6 +5,7 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  import { registerApp as channelRegisterApp } from "@larksuite/channel";
8
+ import { isWindows, secureWindowsDirectoryAcl } from "../platform/secure-metadata.mjs";
8
9
  import { internalCommandSpec } from "../app/internal-command.mjs";
9
10
  import * as larkinConfig from "../platform/config.mjs";
10
11
  import { createAgentStateStore } from "../agent/agent-state-store.mjs";
@@ -230,6 +231,13 @@ function ensureSecureBotsDir() {
230
231
  if (error?.code !== "EEXIST")
231
232
  throw error;
232
233
  }
234
+ if (isWindows) {
235
+ // Windows 无 POSIX 权限位(mode 恒 0o666):改用 icacls 收紧 CFG_DIR 与 bots 目录
236
+ // 的 ACL 为「当前用户 + SYSTEM」并回读校验,fail-closed。
237
+ secureWindowsDirectoryAcl(CFG_DIR, { label: "Larkin 配置目录" });
238
+ secureWindowsDirectoryAcl(directory, { label: "bots 凭证目录" });
239
+ return directory;
240
+ }
233
241
  const stat = fs.lstatSync(directory);
234
242
  if (!stat.isDirectory() || stat.isSymbolicLink()
235
243
  || (typeof process.getuid === "function" && stat.uid !== process.getuid())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.2.77",
3
+ "version": "0.2.79",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [