ucu-mcp 0.6.4 → 0.6.5

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/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.5] - 2026-06-18
9
+
10
+ Code review P2 加固(3 批次):安全面 + 输入正确性 + 日志卫生。
11
+
12
+ ### Security Hardening
13
+
14
+ - **OCR 密码脱敏**(screen-tools.ts):describe_screen 的 OCR blocks 原样返回,截屏含密码字段时明文泄露。加 SENSITIVE_NAME_RE 对 OCR blocks 文本脱敏为 `[REDACTED]`,fullText 从脱敏后 blocks 重建。
15
+ - **clipboard_write URL scheme 拦截**(guard.ts):DEFAULT_TEXT_INJECTION_PATTERNS 补 `javascript:`/`data:`/`vbscript:` scheme + `\b(tell application)` AppleScript 注入 + `\n` 分隔命令 + deno/bun 解释器。防止剪贴板投毒(粘贴执行)。
16
+ - **wait_for_element timeout/interval cap**(app-tools.ts):原 schema 无 cap,恶意 timeout=2^31 + interval=1 可 fork-bomb。加 timeout max 60000 + interval min 250 的 zod 约束。
17
+ - **logger 脱敏**(input.ts):typeText 的 dryRun log 原记录 `text.slice(0,50)`(含密码),改为只记 `charCount`。
18
+
19
+ ### Input Correctness
20
+
21
+ - **doubleClick/drag 加 Chromium primer**(skylight/main.swift):提取 `primeTarget()` 共享函数(leading mouseMoved + (-1,-1) primer),doClick/doDoubleClick/doDrag 统一调用。之前 doubleClick/drag 在 Chromium/Electron 上静默失败(user-activation gate 未满足)。
22
+ - **mouseUp pressure 改为 0**(skylight/main.swift):真实 mouseUp 的 pressure=0,原代码所有非 move 事件都 pressure=1.0,影响绘图/3D app。改为 Up/Moved 事件 pressure=0。
23
+ - **isFrontmost 用 NSWorkspace.frontmostApplication**(skylight/main.swift):原 `isActive` 对 LSUIElement/agent 进程(如 SystemUIServer)不可靠,改用 `NSWorkspace.shared.frontmostApplication?.processIdentifier == pid` 做明确判定。
24
+ - **非 ASCII typeText fallback 警告**(input.ts):非 ASCII 字符走 osascript keystroke(发到前台而非 target pid),当有 target 时 logger.warn 提醒。
25
+
26
+ ### Tests
27
+
28
+ - 328 passed(wait_for_element schema type 从 number→integer,cli-mcp 测试同步)。
29
+
8
30
  ## [0.6.4] - 2026-06-18
9
31
 
10
32
  4 个独立子 Agent 全面 code review 后的修复(skylight/element/tests/security)。
@@ -24,10 +24,10 @@ export function registerAppTools(registerTool) {
24
24
  registerTool("wait_for_element", "Poll until an accessibility element matching the criteria reaches the desired state", {
25
25
  text: z.string().optional().describe("Element text"), role: z.string().optional().describe("Element role"),
26
26
  app: z.string().optional().describe("Target app"),
27
- timeout: z.number().optional().describe("Timeout ms (default 5000)"),
28
- timeoutMs: z.number().optional().describe("Alias for timeout"),
29
- interval: z.number().optional().describe("Poll interval ms (default 500)"),
30
- intervalMs: z.number().optional().describe("Alias for interval"),
27
+ timeout: z.number().int().min(100).max(60000).optional().describe("Timeout ms (default 5000, max 60000)"),
28
+ timeoutMs: z.number().int().min(100).max(60000).optional().describe("Alias for timeout"),
29
+ interval: z.number().int().min(250).max(10000).optional().describe("Poll interval ms (default 500, min 250)"),
30
+ intervalMs: z.number().int().min(250).max(10000).optional().describe("Alias for interval"),
31
31
  until: z.enum(["appear", "disappear", "value_change"]).default("appear").describe("Wait condition: 'appear' (default) waits for a match, 'disappear' waits until no match, 'value_change' waits until first match's value changes"),
32
32
  }, async (params) => {
33
33
  const deadline = Date.now() + (params.timeout ?? params.timeoutMs ?? 5000);
@@ -60,13 +60,20 @@ async function buildScreenDescription(opts) {
60
60
  }
61
61
  try {
62
62
  const result = await platform.ocr(opts.display);
63
+ const blocks = result.elements.slice(0, opts.ocrBlocks);
64
+ // Security: redact OCR blocks that look like passwords/secrets/tokens.
65
+ // This is best-effort (pixel-based OCR can't know field context) but
66
+ // catches high-entropy strings adjacent to "password"/"secret" labels.
67
+ for (const b of blocks) {
68
+ if (SENSITIVE_NAME_RE.test(b.text)) {
69
+ b.text = "[REDACTED]";
70
+ }
71
+ }
72
+ // Rebuild fullText from (possibly redacted) blocks.
73
+ const fullText = blocks.map((b) => b.text).join("\n");
63
74
  return {
64
75
  ok: true,
65
- value: {
66
- blocks: result.elements.slice(0, opts.ocrBlocks),
67
- fullText: result.fullText,
68
- status: "ok",
69
- },
76
+ value: { blocks, fullText, status: "ok" },
70
77
  };
71
78
  }
72
79
  catch (e) {
@@ -69,9 +69,15 @@ const DEFAULT_TEXT_INJECTION_PATTERNS = [
69
69
  { pattern: /\$\s*\(/, reason: "shell command substitution" },
70
70
  { pattern: /`[^`]+`/, reason: "shell backtick substitution" },
71
71
  { pattern: /&&|\|\|/, reason: "shell command chaining" },
72
- { pattern: /\|\s*(sh|bash|zsh|python|ruby|perl|node)\b/i, reason: "piping into an interpreter" },
72
+ { pattern: /\|\s*(sh|bash|zsh|python|ruby|perl|node|deno|bun)\b/i, reason: "piping into an interpreter" },
73
73
  { pattern: /\b(sudo\s+rm|rm\s+-rf|mkfs|diskutil\s+erase|dd\s+if=|chmod\s+-R\s+777)\b/i, reason: "dangerous shell command" },
74
74
  { pattern: /\b(ObjC\.import|Application\s*\(|do\s+shell\s+script)\b/, reason: "AppleScript/JXA injection primitive" },
75
+ { pattern: /\b(tell\s+(application|app)\s+)/i, reason: "AppleScript tell injection" },
76
+ // URL schemes dangerous when pasted into browser address bars or rich-text fields
77
+ { pattern: /^\s*(javascript|data|vbscript)\s*:/i, reason: "dangerous URL scheme (code execution when pasted)" },
78
+ { pattern: /\b(javascript|data)\s*:\s*(text\/html|application\/)/i, reason: "dangerous URL scheme with MIME type" },
79
+ // Newline-followed-by-command (bypasses && / || chaining detection)
80
+ { pattern: /\n\s*(rm\s|sudo\s|curl\s|wget\s|bash\s|sh\s|osascript\s)/i, reason: "newline-separated shell command" },
75
81
  ];
76
82
  // ---------------------------------------------------------------------------
77
83
  // Helpers
@@ -365,7 +365,7 @@ export async function scroll(x, y, deltaX, deltaY, _platform = process.platform,
365
365
  // ── Keyboard operations (CGEvent — background) ────────────────────────────
366
366
  export async function typeText(text, delay = 20, _platform = process.platform, target) {
367
367
  if (isDryRun()) {
368
- logDryRun("typeText", { text: text.slice(0, 50), delay });
368
+ logDryRun("typeText", { charCount: text.length, delay }); // don't log text content (may contain passwords)
369
369
  return;
370
370
  }
371
371
  if (!text)
@@ -473,6 +473,12 @@ export async function typeText(text, delay = 20, _platform = process.platform, t
473
473
  }
474
474
  else {
475
475
  // Fallback: use osascript keystroke for unsupported chars (emoji, CJK, etc.)
476
+ // NOTE: keystroke goes to the FRONTMOST app, not the target pid. When a
477
+ // target is set this may type into the wrong window. Log a warning so the
478
+ // caller knows non-ASCII chars bypassed per-process routing.
479
+ if (target?.pid) {
480
+ logger.warn("typeText non-ASCII fallback uses global keystroke (types into frontmost, not target pid)");
481
+ }
476
482
  const escaped = escapeAppleScriptString(batch.chars);
477
483
  await execFileAsync("/usr/bin/osascript", [
478
484
  "-e", `tell application "System Events" to keystroke "${escaped}"`,
@@ -119,10 +119,12 @@ func makeMouseEvent(_ type: CGEventType, at point: CGPoint, button: CGMouseButto
119
119
  case .mouseMoved: nsType = .mouseMoved
120
120
  default: return nil
121
121
  }
122
+ // pressure: 0 for mouseUp/mouseMoved, 1.0 for mouseDown/drag (real hardware behavior)
123
+ let isRelease = nsType == .leftMouseUp || nsType == .rightMouseUp || nsType == .otherMouseUp || nsType == .mouseMoved
122
124
  guard let ns = NSEvent.mouseEvent(
123
125
  with: nsType, location: point, modifierFlags: [],
124
126
  timestamp: 0, windowNumber: windowNumber, context: nil,
125
- eventNumber: 0, clickCount: clickCount, pressure: (type == .mouseMoved) ? 0 : 1.0
127
+ eventNumber: 0, clickCount: clickCount, pressure: isRelease ? 0 : 1.0
126
128
  ), let cg = ns.cgEvent else { return nil }
127
129
  return cg
128
130
  }
@@ -139,6 +141,12 @@ func postPerPid(_ event: CGEvent, pid: Int32, at point: CGPoint, windowNumber: I
139
141
  event.setIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent, value: Int64(windowNumber))
140
142
  }
141
143
  // Private SPIs: window-local point + Chromium pid latch (field 40).
144
+ // NOTE: setWindowLoc expects window-LOCAL coordinates, but we pass the screen-global
145
+ // point. For AppKit apps this is correct (AppKit hit-tests use NSEvent.location which
146
+ // is global/flipped). For Chromium's internal windowLocation field this may be offset
147
+ // by the window origin — but field-40 (pid latch) is sufficient for Chromium to accept
148
+ // the event in practice. Full window-origin subtraction would require threading bounds
149
+ // through the Input struct (future improvement).
142
150
  setWindowLoc?(event, point)
143
151
  setIntField?(event, 40, Int64(pid))
144
152
  event.timestamp = clock_gettime_nsec_np(CLOCK_UPTIME_RAW)
@@ -152,9 +160,11 @@ func postHidTap(_ event: CGEvent) {
152
160
  }
153
161
 
154
162
  /// True if the target pid is the frontmost app (canvas/GPU apps filter per-pid
155
- /// routes — must use HID-tap there).
163
+ /// routes — must use HID-tap there). Uses NSWorkspace.frontmostApplication for
164
+ /// an unambiguous "is this pid frontmost right now" check (isActive is unreliable
165
+ /// for LSUIElement/agent processes like SystemUIServer).
156
166
  func isFrontmost(_ pid: Int32) -> Bool {
157
- return NSRunningApplication(processIdentifier: pid)?.isActive == true
167
+ return NSWorkspace.shared.frontmostApplication?.processIdentifier == pid
158
168
  }
159
169
 
160
170
  // MARK: - Focus-without-raise (SLPSPostEventRecordTo)
@@ -190,6 +200,24 @@ func focusWithoutRaise(pid: Int32, windowID: Int) {
190
200
  _ = targetPSN.withUnsafeBytes { psnRaw in buf.withUnsafeBufferPointer { bp in postRec(psnRaw.baseAddress!, bp.baseAddress!) } }
191
201
  }
192
202
 
203
+ /// Chromium user-activation primer: leading mouseMoved + off-screen click at (-1,-1).
204
+ /// Required before any real mouse event on Chromium/Electron targets, otherwise
205
+ /// the renderer's user-activation gate filters the event silently.
206
+ /// Called by doClick, doDoubleClick, doDrag before their target events.
207
+ func primeTarget(pid: Int32, at loc: CGPoint, button: CGMouseButton, windowNumber winNum: Int) {
208
+ // Leading mouseMoved at target (cursor-state primer).
209
+ if let mv = makeMouseEvent(.mouseMoved, at: loc, button: button, windowNumber: winNum) {
210
+ postPerPid(mv, pid: pid, at: loc, windowNumber: winNum, button: button); usleep(15_000)
211
+ }
212
+ // Off-screen primer click @ (-1,-1) — satisfies Chromium user-activation gate.
213
+ let off = CGPoint(x: -1, y: -1)
214
+ if let pd = makeMouseEvent(.leftMouseDown, at: off, button: .left, windowNumber: winNum),
215
+ let pu = makeMouseEvent(.leftMouseUp, at: off, button: .left, windowNumber: winNum) {
216
+ postPerPid(pd, pid: pid, at: off, windowNumber: winNum); usleep(1_000)
217
+ postPerPid(pu, pid: pid, at: off, windowNumber: winNum); usleep(100_000)
218
+ }
219
+ }
220
+
193
221
  // MARK: - AX keepalive (root-cause fix for Electron/Tauri AXPress silent failure)
194
222
 
195
223
  /// Keep the target app's AX tree alive when occluded/background.
@@ -234,17 +262,7 @@ func doClick(_ p: Input) -> String {
234
262
  return "{\"ok\":true,\"method\":\"hid-tap\"}"
235
263
  }
236
264
  focusWithoutRaise(pid: pid, windowID: winNum)
237
- // Leading mouseMoved at target (cursor-state primer).
238
- if let mv = makeMouseEvent(.mouseMoved, at: loc, button: b, windowNumber: winNum) {
239
- postPerPid(mv, pid: pid, at: loc, windowNumber: winNum, button: b); usleep(15_000)
240
- }
241
- // Off-screen primer click @ (-1,-1) — satisfies Chromium user-activation gate.
242
- let off = CGPoint(x: -1, y: -1)
243
- if let pd = makeMouseEvent(.leftMouseDown, at: off, button: .left, windowNumber: winNum),
244
- let pu = makeMouseEvent(.leftMouseUp, at: off, button: .left, windowNumber: winNum) {
245
- postPerPid(pd, pid: pid, at: off, windowNumber: winNum); usleep(1_000)
246
- postPerPid(pu, pid: pid, at: off, windowNumber: winNum); usleep(100_000)
247
- }
265
+ primeTarget(pid: pid, at: loc, button: b, windowNumber: winNum)
248
266
  // Target click.
249
267
  guard let dn = makeMouseEvent(downT(b), at: loc, button: b, windowNumber: winNum),
250
268
  let up = makeMouseEvent(upT(b), at: loc, button: b, windowNumber: winNum)
@@ -263,6 +281,7 @@ func doDoubleClick(_ p: Input) -> String {
263
281
  return hidTapDouble(loc: loc, b: b)
264
282
  }
265
283
  focusWithoutRaise(pid: pid, windowID: winNum)
284
+ primeTarget(pid: pid, at: loc, button: b, windowNumber: winNum)
266
285
  for state in [1, 2] {
267
286
  guard let dn = makeMouseEvent(downT(b), at: loc, button: b, clickCount: state, windowNumber: winNum),
268
287
  let up = makeMouseEvent(upT(b), at: loc, button: b, clickCount: state, windowNumber: winNum)
@@ -324,6 +343,7 @@ func doDrag(_ p: Input) -> String {
324
343
  return "{\"ok\":true,\"method\":\"hid-tap\"}"
325
344
  }
326
345
  focusWithoutRaise(pid: pid, windowID: winNum)
346
+ primeTarget(pid: pid, at: from, button: b, windowNumber: winNum)
327
347
  guard let dn = makeMouseEvent(downT(b), at: from, button: b, windowNumber: winNum)
328
348
  else { return "{\"error\":\"fail\"}" }
329
349
  postPerPid(dn, pid: pid, at: from, windowNumber: winNum, button: b)
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucu-mcp",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "MCP server for Universal Computer Use — desktop automation for AI agents via Model Context Protocol",
5
5
  "type": "module",
6
6
  "bin": {