@m2canvas/connect 0.7.1 → 0.7.2

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.
@@ -13,20 +13,41 @@
13
13
  * session/list 必须带 cwd(不带时 Claude / Codex 会列出全机所有项目的会话标题)。
14
14
  * - 白名单内的任何请求:params 里出现 `cwd` 必须等于会话目录;`mcpServers` 只能是空数组或不带;不允许 `_meta`、
15
15
  * 不允许非空 `additionalDirectories`。
16
+ * - 无人值守模式(change bridge-reject-unattended-modes):`session/set_mode` 的 `modeId`、`session/set_config_option` 的
17
+ * `value` 落在 `BRIDGE_UNATTENDED_MODE_VALUES` 的并集里就拒收(D1,按取值、不看 configId —— 这里分不出哪个 configId
18
+ * 是模式);页面对权限请求的回答选了会切到无人值守模式的选项(`UNATTENDED_PERMISSION_OPTION_IDS`)也拒收(D6)。
16
19
  * - 形状:不是 JSON 对象的行一律拒收(含 JSON-RPC 批量数组 —— ACP SDK 会把数组逐条入队);有 `method` 键但不是字符串的拒收
17
- * (不能当成响应放行);消息或 params 自带 `__proto__` / `constructor` / `prototype` 键的拒收。
20
+ * (不能当成响应放行);消息、params、回答的 result / outcome 自带 `__proto__` / `constructor` / `prototype` 键的拒收。
18
21
  *
19
22
  * 边界(不要把它当成安全的全部):这是纵深防御,不改变「配对 = 授权该页面以你的身份驱动本机 agent」——
20
- * 页面仍可以用提示词让 agent 执行命令、替用户回答审批、切换到不审批的工作模式。真正的门槛是 security.ts / pairing.ts 的准入。
23
+ * 页面仍可以用提示词让 agent 执行命令(包括改它自己的配置文件)、替用户回答审批(含「本会话 / 永久允许」,FU-2026-10-08-009);
24
+ * 这里只拒收**已知的**切到无人值守模式的请求与回答(两份清单随适配器 / CLI 版本重核)。Cursor、OpenCode 没有无人值守模式;
25
+ * OpenCode 默认的 build 模式在工作区内执行命令本来就不请示。真正的门槛是 security.ts / pairing.ts 的准入。
21
26
  * 前提:agent 解析 JSON 的结果与这里一致(例如重复键都取最后一个)。2026-10-03 核实:两个适配器经 ACP SDK 1.5.1 的
22
27
  * stream.js 用 JSON.parse;Cursor 是 node bundle;OpenCode、Qoder 是 Bun 单文件可执行(证据见 change 的 verification §13)。
23
- * 哪家换了解析器,要重新核这一条。
28
+ * 哪家换了解析器,要重新核这一条。两份清单依赖各家的模式名与 optionId 字面量:升级适配器或 CLI 后要重核,改名只会漏拦、不会误伤。
24
29
  */
25
- import { BRIDGE_PAGE_ACP_METHODS } from './protocol.js';
30
+ import { BRIDGE_PAGE_ACP_METHODS, BRIDGE_UNATTENDED_MODE_VALUES } from './protocol.js';
26
31
  /** 页面可以发给 agent 的方法:change 3 实际用到的方法 + JSON-RPC 层的取消请求(定义在 protocol.ts,前端也能 import) */
27
32
  export const PAGE_ACP_METHODS = BRIDGE_PAGE_ACP_METHODS;
28
33
  /** cwd 由 bridge 决定的会话类方法(必须带 cwd) */
29
34
  export const SESSION_SCOPE_METHODS = ['session/new', 'session/load', 'session/resume', 'session/list'];
35
+ /** 所有 agent 的无人值守模式取并集:守卫按行检查、不知道对面是哪家,且 Qoder 认 Claude 的取值 */
36
+ const UNATTENDED_MODES = Object.values(BRIDGE_UNATTENDED_MODE_VALUES).flat();
37
+ /**
38
+ * 权限回答里会把会话切到无人值守模式的 optionId(change bridge-reject-unattended-modes D6)= 全部无人值守模式取值 ∪
39
+ * Claude 适配器 0.84.0 退出计划的 `exit-plan-bypass` / `exit-plan-clear-bypass`(dist/permissions/options/shared.js,切到 bypassPermissions)。
40
+ * 必须包含全部模式取值:qodercli 1.1.65 处理退出计划回答时,把 optionId 交给与切模式同一个别名映射后直接切模式,
41
+ * 不核对它是不是给出的选项 —— 回答 `yolo` 或 Claude 的 `bypassPermissions` 都会切到 yolo(review r1 B1)。
42
+ * Claude 适配器会核对回答是否为给出的选项。这些都是 `allow_always` 或根本不在选项里,页面只发 `allow_once` / `reject_once`,
43
+ * 正常不会发它们。只有 bridge 用,页面按 kind 挑选、不需要这份。
44
+ */
45
+ const UNATTENDED_PERMISSION_OPTION_IDS = [...UNATTENDED_MODES, 'exit-plan-bypass', 'exit-plan-clear-bypass'];
46
+ /** 取值可能是模式的字段:`session/set_mode` 的 modeId、`session/set_config_option` 的 value */
47
+ const MODE_VALUE_FIELDS = {
48
+ 'session/set_mode': 'modeId',
49
+ 'session/set_config_option': 'value',
50
+ };
30
51
  /** JSON.parse 会把这些键建成普通自有属性;agent 侧的对象合并 / 校验可能对它们另有处理,一律不放行 */
31
52
  const FORBIDDEN_KEYS = ['__proto__', 'constructor', 'prototype'];
32
53
  function isObject(value) {
@@ -60,6 +81,37 @@ function scopeViolation(params, cwd, sessionScoped) {
60
81
  return '_meta is not allowed';
61
82
  return null;
62
83
  }
84
+ /**
85
+ * 切模式的目标值是无人值守模式时返回原因。只看字符串:有无人值守模式的三家(Claude / Codex / Qoder)ACP 层都要求
86
+ * 字符串取值,非字符串切不过去;Claude 有合法的 boolean 配置项,拒收非字符串会误伤。
87
+ */
88
+ function modeViolation(method, params) {
89
+ const field = MODE_VALUE_FIELDS[method];
90
+ if (field === undefined)
91
+ return null;
92
+ const value = params[field];
93
+ return typeof value === 'string' && UNATTENDED_MODES.includes(value) ? `mode ${value} is not allowed from the page` : null;
94
+ }
95
+ /**
96
+ * 页面对 agent 请求的回答(没有 method 键):选了会切到无人值守模式的权限选项时返回原因。
97
+ * 不论 `outcome.outcome` 是什么都查 optionId(页面回 cancelled 时从不带 optionId,从严不会误伤)。
98
+ */
99
+ function answerViolation(message) {
100
+ const result = message.result;
101
+ if (!isObject(result))
102
+ return null;
103
+ if (hasForbiddenKey(result))
104
+ return 'result must not contain __proto__ / constructor / prototype';
105
+ const outcome = result.outcome;
106
+ if (!isObject(outcome))
107
+ return null;
108
+ if (hasForbiddenKey(outcome))
109
+ return 'outcome must not contain __proto__ / constructor / prototype';
110
+ const optionId = outcome.optionId;
111
+ return typeof optionId === 'string' && UNATTENDED_PERMISSION_OPTION_IDS.includes(optionId)
112
+ ? `permission option ${optionId} is not allowed from the page`
113
+ : null;
114
+ }
63
115
  export function checkPageAcpLine(line, cwd) {
64
116
  let message;
65
117
  try {
@@ -74,9 +126,12 @@ export function checkPageAcpLine(line, cwd) {
74
126
  const reject = (reason) => ({ ok: false, reason, method, rpcId: rpcIdOf(message) });
75
127
  if (hasForbiddenKey(message))
76
128
  return reject('message must not contain __proto__ / constructor / prototype');
77
- // 没有 method 键的是页面对 agent 请求的响应(例如审批结果),不涉及方法与会话范围
78
- if (!hasOwn(message, 'method'))
79
- return { ok: true };
129
+ // 没有 method 键的是页面对 agent 请求的响应(例如审批结果),不涉及方法与会话范围,只查 D6。
130
+ // 被拒时 method 为 null:页面不会为它合成错误响应(rpcId 是 agent 编的号,会误结束页面自己同号的请求)
131
+ if (!hasOwn(message, 'method')) {
132
+ const violation = answerViolation(message);
133
+ return violation ? reject(violation) : { ok: true };
134
+ }
80
135
  if (method === null)
81
136
  return reject('method must be a string');
82
137
  if (!PAGE_ACP_METHODS.includes(method))
@@ -87,6 +142,6 @@ export function checkPageAcpLine(line, cwd) {
87
142
  return { ok: true };
88
143
  if (!isObject(params))
89
144
  return reject('params must be an object');
90
- const violation = scopeViolation(params, cwd, sessionScoped);
145
+ const violation = scopeViolation(params, cwd, sessionScoped) ?? modeViolation(method, params);
91
146
  return violation ? reject(violation) : { ok: true };
92
147
  }
@@ -9,7 +9,7 @@
9
9
  * 所以只能有类型和常量:不 import 任何模块、不用 enum、不用 Node 或 DOM 专有类型。
10
10
  * - 同一协议版本内只做加法(新方法、新事件、新可选字段、新枚举值)。删除字段或改变语义要升 BRIDGE_PROTOCOL_VERSION,
11
11
  * bridge 至少同时支持前一个版本。页面必须忽略不认识的字段 / 事件,把不认识的 state 当成 unknown。
12
- * - bridge 不翻译 ACP:agent → 页面的 `acp` 行原样转发(不保证是 JSON);页面 → agent 的行只做方法白名单与会话范围检查(acp-guard.ts),
12
+ * - bridge 不翻译 ACP:agent → 页面的 `acp` 行原样转发(不保证是 JSON);页面 → agent 的行只做方法白名单、会话范围与无人值守模式检查(acp-guard.ts),
13
13
  * 翻译全部在页面做。
14
14
  * - 配对 = 授权该页面以用户的身份驱动本机 agent(包括让它执行命令);页面代码与 token 的保管要按这个级别对待。
15
15
  */
@@ -46,6 +46,23 @@ export const BRIDGE_PAGE_ACP_METHODS = [
46
46
  'session/close',
47
47
  '$/cancel_request',
48
48
  ];
49
+ /**
50
+ * 不经批准执行一切的工作模式(change bridge-reject-unattended-modes D2,页面与 bridge 共用这一份):
51
+ * Claude `bypassPermissions`「Accepts all permissions」;Codex `agent-full-access`「Unrestricted access to the internet and any
52
+ * file on your computer」;Qoder `yolo`「Auto-approves all tools」。Qoder `dontAsk` 是「不询问、直接拒绝」,不在此列;
53
+ * Claude `auto` 由分类器代为决定,用户 2026-10-03 拍板时未列入。
54
+ * - 页面:模式下拉不提供、`setLocalMode` 拒绝(用户自己的配置已是这种模式时照实显示)。
55
+ * - bridge:`session/set_mode` / `session/set_config_option` 的取值落在**所有 agent 的并集**里就拒收(acp-guard.ts)——
56
+ * Qoder 会把 Claude 的 `bypassPermissions` 映射成自己的 `yolo`,只查本家挡不住。
57
+ * 某家新增同类模式、或升级适配器 / CLI 时,按本 change evidence/mode-paths.md 的复核方法重核并加在这里(bridge 要发版)。
58
+ */
59
+ export const BRIDGE_UNATTENDED_MODE_VALUES = {
60
+ claude: ['bypassPermissions'],
61
+ codex: ['agent-full-access'],
62
+ cursor: [],
63
+ opencode: [],
64
+ qoder: ['yolo'],
65
+ };
49
66
  export const BRIDGE_METHODS = [
50
67
  'pair',
51
68
  'agents/list',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m2canvas/connect",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Connect your local agent clients (Claude Code, Codex, Cursor, OpenCode, Qoder) to M2 Canvas over MCP in one command, and run the local bridge that lets M2 drive them over ACP.",
5
5
  "type": "module",
6
6
  "bin": {