@mrweicodes/dsh-permgate 1.5.0 → 1.5.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.
Files changed (3) hide show
  1. package/client.js +70 -20
  2. package/index.js +81 -21
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -7,6 +7,9 @@ window.__ModuleLoader__.load({
7
7
  let React = require("react");
8
8
 
9
9
  const CSS = ".pg-modal { position: fixed; top: 16px; right: 16px; z-index: 9999; width: 420px; max-width: 92vw; max-height: 82vh; overflow-y: auto; background: var(--dsw-alias-bg-overlay, #ffffff); color: var(--dsw-alias-label-primary, #24292f); border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.4)); border-radius: 10px; box-shadow: 0 10px 36px rgba(0,0,0,0.32); padding: 14px 16px; font-family: system-ui, -apple-system, \"Segoe UI\", \"PingFang SC\", \"Microsoft YaHei\", sans-serif; } .pg-modal-title { font-size: 14px; font-weight: 600; margin-bottom: 6px; } .pg-modal-req { font-size: 12px; margin-bottom: 4px; } .pg-modal-body { font-size: 12px; color: var(--dsw-alias-label-secondary, #57606a); margin-bottom: 8px; } .pg-intent { font-size: 12px; background: var(--dsw-alias-bg-layer-1, #f6f8fa); border: 1px solid var(--dsw-alias-border-l1, #d0d7de); border-left: 3px solid var(--dsw-alias-brand-primary, #1f6feb); border-radius: 6px; padding: 6px 8px; color: var(--dsw-alias-label-secondary, #57606a); margin-bottom: 6px; line-height: 1.5; word-break: break-word; } .pg-intent-tag { display: inline-block; font-weight: 700; color: var(--dsw-alias-brand-primary, #1f6feb); margin-right: 6px; font-size: 11px; } .pg-args { font-size: 11px; background: var(--dsw-alias-bg-layer-1, #f6f8fa); border: 1px solid var(--dsw-alias-border-l1, #d0d7de); border-left: 3px solid rgba(128,128,128,0.65); border-radius: 6px; padding: 5px 8px; color: var(--dsw-alias-label-secondary, #57606a); margin-bottom: 8px; line-height: 1.5; word-break: break-all; font-family: ui-monospace, Consolas, monospace; } .pg-args-tag { display: inline-block; font-weight: 700; color: rgba(128,128,128,0.95); margin-right: 6px; font-size: 11px; font-family: system-ui, -apple-system, \"Segoe UI\", \"PingFang SC\", \"Microsoft YaHei\", sans-serif; } .pg-args-row { display: flex; gap: 6px; padding: 1px 0; } .pg-args-label { font-weight: 600; flex: 0 0 auto; } .pg-args-val { word-break: break-all; flex: 1; } .pg-cand-head { font-size: 12px; font-weight: 600; margin: 6px 0 4px; } .pg-cand-hint { font-size: 11px; color: var(--dsw-alias-label-secondary, #8c959f); margin-bottom: 4px; } .pg-cand { display: flex; align-items: center; gap: 6px; padding: 4px 0; border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.2)); } .pg-cand-label { flex: 1; font-family: ui-monospace, Consolas, monospace; font-size: 11px; word-break: break-all; } .pg-radio { display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.4)); background: transparent; color: var(--dsw-alias-label-secondary, #57606a); font-size: 11px; font-family: inherit; cursor: pointer; transition: all 0.15s ease; } .pg-radio:hover { border-color: var(--dsw-alias-brand-primary, #1f6feb); } .pg-radio-allow { border-color: var(--dsw-alias-state-success-primary, #1a7f37); color: var(--dsw-alias-state-success-primary, #1a7f37); background: rgba(26, 127, 55, 0.12); font-weight: 600; } .pg-radio-deny { border-color: var(--dsw-alias-state-error-primary, #cf222e); color: var(--dsw-alias-state-error-primary, #cf222e); background: rgba(207, 34, 46, 0.12); font-weight: 600; } .pg-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } .pg-action { display: inline-flex; align-items: center; padding: 5px 16px; border-radius: 6px; border: 1px solid; font-size: 12px; font-family: inherit; font-weight: 600; cursor: pointer; transition: filter 0.15s ease, box-shadow 0.15s ease, transform 0.05s ease; } .pg-action:hover { filter: brightness(1.07); box-shadow: 0 2px 8px rgba(0,0,0,0.18); } .pg-action:active { transform: translateY(1px); box-shadow: none; } .pg-action:disabled { opacity: 0.5; cursor: not-allowed; filter: none; box-shadow: none; } .pg-action-allow { border-color: rgba(26, 127, 55, 0.45); color: #1a7f37; background: rgba(26, 127, 55, 0.12); } @supports (color: color-mix(in srgb, red 10%, blue)) { .pg-action-allow { border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary, #1a7f37) 45%, transparent); color: var(--dsw-alias-state-success-primary, #1a7f37); background: color-mix(in srgb, var(--dsw-alias-state-success-primary, #1a7f37) 12%, transparent); } } .pg-action-deny { border-color: rgba(207, 34, 46, 0.45); color: #cf222e; background: rgba(207, 34, 46, 0.12); } @supports (color: color-mix(in srgb, red 10%, blue)) { .pg-action-deny { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary, #cf222e) 45%, transparent); color: var(--dsw-alias-state-error-primary, #cf222e); background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #cf222e) 12%, transparent); } } .pg-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.4)); background: transparent; color: var(--dsw-alias-label-primary, #24292f); cursor: pointer; font-size: 13px; font-family: inherit; transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease, transform 0.05s ease; } .pg-btn:hover { border-color: var(--dsw-alias-brand-primary, #1f6feb); background: rgba(31, 111, 235, 0.08); } .pg-btn:active { transform: translateY(1px); } .pg-btn:disabled { opacity: 0.5; cursor: not-allowed; } .pg-btn-danger:hover { border-color: var(--dsw-alias-state-error-primary, #cf222e); color: var(--dsw-alias-state-error-primary, #cf222e); background: rgba(207, 34, 46, 0.08); } .pg-btn-on { border-color: var(--dsw-alias-state-success-primary, #1a7f37); color: var(--dsw-alias-state-success-primary, #1a7f37); background: rgba(26, 127, 55, 0.12); font-weight: 600; } .pg-btn-on:hover { border-color: var(--dsw-alias-state-success-primary, #1a7f37); color: var(--dsw-alias-state-success-primary, #1a7f37); background: rgba(26, 127, 55, 0.2); } .pg-btn-off { border-color: var(--dsw-alias-state-error-primary, #cf222e); color: var(--dsw-alias-state-error-primary, #cf222e); background: rgba(207, 34, 46, 0.1); font-weight: 600; } .pg-btn-off:hover { border-color: var(--dsw-alias-state-error-primary, #cf222e); color: var(--dsw-alias-state-error-primary, #cf222e); background: rgba(207, 34, 46, 0.16); } .pg-btn-confirm { border-color: var(--dsw-alias-state-error-primary, #cf222e); color: #ffffff; background: var(--dsw-alias-state-error-primary, #cf222e); } .pg-btn-confirm:hover { border-color: var(--dsw-alias-state-error-primary, #cf222e); color: #ffffff; background: var(--dsw-alias-state-error-primary, #cf222e); filter: brightness(1.12); } .pg-field { padding: 4px 8px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.4)); background: var(--dsw-alias-bg-layer-1, #f6f8fa); color: var(--dsw-alias-label-primary, #24292f); color-scheme: light dark; font-size: 13px; font-family: inherit; transition: border-color 0.15s ease, box-shadow 0.15s ease; } .pg-field:hover { border-color: var(--dsw-alias-brand-primary, #1f6feb); } .pg-field:focus { outline: none; border-color: var(--dsw-alias-brand-primary, #1f6feb); box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.22); } .pg-field:disabled { opacity: 0.5; cursor: not-allowed; } .pg-field option { color: var(--dsw-alias-label-primary, #24292f); background: var(--dsw-alias-bg-overlay, #ffffff); } .pg-tab { padding: 6px 16px; border-radius: 6px 6px 0 0; border: none; border-bottom: 2px solid transparent; background: transparent; color: var(--dsw-alias-label-primary, #24292f); cursor: pointer; font-size: 13px; font-family: inherit; margin-right: 4px; transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; } .pg-tab:hover { background: rgba(128, 128, 128, 0.12); } .pg-tab-on { border-bottom-color: var(--dsw-alias-brand-primary, #1f6feb); color: var(--dsw-alias-brand-primary, #1f6feb); font-weight: 600; }";
10
+ // radio 禁用态的视觉提示:主 CSS 里 .pg-radio 没有 :disabled 规则,
11
+ // 原先靠内联 style 兜底,去掉后禁用按钮看起来仍可点。
12
+ const RADIO_DISABLED_CSS = '.pg-radio:disabled { opacity: 0.5; cursor: not-allowed; }';
10
13
 
11
14
  const DIFF_CSS = '.pg-link { display: inline-flex; align-items: center; gap: 4px; background: var(--dsw-alias-bg-layer-1, #f0f2f5); color: var(--dsw-alias-label-primary, #24292f); border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.45)); border-radius: 6px; padding: 3px 12px; cursor: pointer; font-size: 12px; font-weight: 500; margin-bottom: 6px; } .pg-link:hover { border-color: #1f6feb; color: #1f6feb; }';
12
15
 
@@ -695,7 +698,7 @@ window.__ModuleLoader__.load({
695
698
  'app.intent': '意图说明',
696
699
  'app.args': '参数',
697
700
  'app.cand.head': '添加项目规则(当前项目)',
698
- 'app.cand.hint': '点亮条目后点击「允许 / 拒绝」,选中的条目自动加入当前项目例外,之后不再询问。',
701
+ 'app.cand.hint': '点亮条目后点「允许 / 拒绝」即写入当前项目例外。',
699
702
  'app.allow': '允许',
700
703
  'app.deny': '拒绝',
701
704
  'app.allowOne': '√ 允许',
@@ -839,6 +842,19 @@ window.__ModuleLoader__.load({
839
842
  'quick.cordis_inspect_list': '检视能力清单',
840
843
  'quick.cordis_inspect_query': '执行只读检视',
841
844
  'quick.cordis_inspect_self': '检视本会话插件',
845
+ 'quick.perm_status': '查看权限状态',
846
+ 'quick.perm_set_category': '改分类默认动作',
847
+ 'quick.perm_set_fallback': '改兜底策略',
848
+ 'quick.perm_set_editor_kernel': '改编辑器内核判别',
849
+ 'quick.perm_add_exception': '添加例外',
850
+ 'quick.perm_remove_exception': '删除例外',
851
+ 'quick.perm_set_quick': '改快捷工具默认',
852
+ 'quick.perm_add_rule': '添加自定义规则',
853
+ 'quick.perm_remove_rule': '删除自定义规则',
854
+ 'quick.perm_reload': '重载配置',
855
+ 'quick.cordis_run': '运行动态插件',
856
+ 'quick.cordis_stop': '停用动态插件',
857
+ 'quick.cordis_undefine': '移除动态插件',
842
858
  'panel.excExpand': '展开',
843
859
  'panel.excCollapse': '折叠',
844
860
  'panel.excListLink': '例外列表',
@@ -860,7 +876,7 @@ window.__ModuleLoader__.load({
860
876
  'app.intent': 'Intent',
861
877
  'app.args': 'Arguments',
862
878
  'app.cand.head': 'Add project rule (current project)',
863
- 'app.cand.hint': 'Toggle items, then click Allow / Deny to add them to the current project exceptions permanently.',
879
+ 'app.cand.hint': 'Toggle items then click Allow / Deny to write them into the current project exceptions.',
864
880
  'app.allow': 'Allow',
865
881
  'app.deny': 'Deny',
866
882
  'app.allowOne': '√ Allow',
@@ -1001,6 +1017,19 @@ window.__ModuleLoader__.load({
1001
1017
  'quick.cordis_inspect_list': 'list inspect capabilities',
1002
1018
  'quick.cordis_inspect_query': 'run a read-only inspect query',
1003
1019
  'quick.cordis_inspect_self': 'inspect session plugins',
1020
+ 'quick.perm_status': 'view permission status',
1021
+ 'quick.perm_set_category': 'set a category default',
1022
+ 'quick.perm_set_fallback': 'set the fallback policy',
1023
+ 'quick.perm_set_editor_kernel': 'set editor-kernel detection',
1024
+ 'quick.perm_add_exception': 'add an exception',
1025
+ 'quick.perm_remove_exception': 'remove an exception',
1026
+ 'quick.perm_set_quick': 'set a quick-tool default',
1027
+ 'quick.perm_add_rule': 'add a custom rule',
1028
+ 'quick.perm_remove_rule': 'remove a custom rule',
1029
+ 'quick.perm_reload': 'reload config',
1030
+ 'quick.cordis_run': 'run a dynamic plugin',
1031
+ 'quick.cordis_stop': 'stop a dynamic plugin',
1032
+ 'quick.cordis_undefine': 'remove a dynamic plugin',
1004
1033
  'panel.excExpand': 'Expand',
1005
1034
  'panel.excCollapse': 'Collapse',
1006
1035
  'panel.excListLink': 'Exception list',
@@ -1060,6 +1089,8 @@ window.__ModuleLoader__.load({
1060
1089
  const [openArgs, setOpenArgs] = React.useState({});
1061
1090
  const [denyText, setDenyText] = React.useState({});
1062
1091
  const [denyMode, setDenyMode] = React.useState({});
1092
+ // 进入拒绝态时被临时收起的「允许此项」勾选(按审批 id 存),点「取消」时原样恢复
1093
+ const [denySaved, setDenySaved] = React.useState({});
1063
1094
  const [, setTick] = React.useState(0);
1064
1095
  useLocaleTick();
1065
1096
  const poll = () => {
@@ -1081,11 +1112,9 @@ window.__ModuleLoader__.load({
1081
1112
  for (const key of diffFetching) if (!aliveIds.has(key)) diffFetching.delete(key);
1082
1113
  for (const p of pending) {
1083
1114
  if (!p.hasDiff || diffCache.has(p.id) || diffFetching.has(p.id)) continue
1084
- const userOpened = openDetail[p.id] === true
1085
- const open = openDetail[p.id] === undefined ? true : userOpened
1115
+ // 详情默认展开(含图片:读图时用户最想看的就是那张图),展开即预取
1116
+ const open = openDetail[p.id] === undefined ? true : openDetail[p.id] === true
1086
1117
  if (!open) continue
1087
- // 图片详情是整图 data URL(体积大):不做自动预取,只有用户主动展开该条时才拉取
1088
- if (p.imagePreview && !userOpened) continue
1089
1118
  diffFetching.add(p.id)
1090
1119
  schedulePrismIdle()
1091
1120
  call('permgate:file-diff', { id: p.id }).then((r) => {
@@ -1120,26 +1149,43 @@ window.__ModuleLoader__.load({
1120
1149
  const confirmDeny = (p) => {
1121
1150
  const reason = (denyText[p.id] || '').trim();
1122
1151
  setDenyMode(Object.assign({}, denyMode, { [p.id]: false }));
1152
+ const rest = Object.assign({}, denySaved);
1153
+ delete rest[p.id];
1154
+ setDenySaved(rest);
1123
1155
  decide(p.id, 'deny', pickedDenyRules(p), reason || undefined);
1124
1156
  };
1125
- const cancelDeny = (p) => setDenyMode(Object.assign({}, denyMode, { [p.id]: false }));
1126
- // 进入拒绝态:只切状态,不动用户已勾选的内容。拒绝语义下「允许此项」由 radio 置灰,
1127
- // 确认拒绝时也只提交 deny 方向(pickedDenyRules),因此点「取消」能原样回到进入前的勾选。
1128
- const enterDeny = (p) => setDenyMode(Object.assign({}, denyMode, { [p.id]: true }));
1157
+ // 取消拒绝:把进入前收起的「允许此项」勾选原样放回去
1158
+ const cancelDeny = (p) => {
1159
+ const snap = denySaved[p.id];
1160
+ if (snap && Object.keys(snap).length) setSel(Object.assign({}, sel, snap));
1161
+ const rest = Object.assign({}, denySaved);
1162
+ delete rest[p.id];
1163
+ setDenySaved(rest);
1164
+ setDenyMode(Object.assign({}, denyMode, { [p.id]: false }));
1165
+ };
1166
+ // 进入拒绝态:立刻把「允许此项」的勾选收起来(视觉上就是取消了),并记下原选择供「取消」恢复。
1167
+ // 之前是「保留勾选 + 置灰变浅」,看起来仍像选中了允许,容易误读成「一边选允许一边提交拒绝」。
1168
+ const enterDeny = (p) => {
1169
+ const snap = {};
1170
+ for (const c of (p.candidates || [])) if (sel[c.id] === 'allow') snap[c.id] = 'allow';
1171
+ const next = Object.assign({}, sel);
1172
+ for (const id of Object.keys(snap)) delete next[id];
1173
+ setDenySaved(Object.assign({}, denySaved, { [p.id]: snap }));
1174
+ setSel(next);
1175
+ setDenyMode(Object.assign({}, denyMode, { [p.id]: true }));
1176
+ };
1129
1177
  const radio = (p, c, v, label, cls) => {
1130
- // 拒绝态下「允许此项」不生效(confirmDeny 只提交 deny 方向):置灰禁用而不是清除勾选,
1131
- // 这样点「取消」保留进入前选择,点「确认拒绝」也不会把 allow 方向写进配置。
1132
- const muted = v === 'allow' && !!denyMode[p.id];
1178
+ // 拒绝态下「允许此项」已被收起,这里只做禁止再点(避免又勾回来和拒绝态自相矛盾)
1179
+ const blocked = v === 'allow' && !!denyMode[p.id];
1133
1180
  return React.createElement('button', {
1134
1181
  className: 'pg-radio' + (sel[c.id] === v ? ' ' + cls : ''),
1135
- disabled: busyId === p.id || muted,
1136
- style: muted ? { opacity: 0.45, cursor: 'not-allowed' } : undefined,
1182
+ disabled: busyId === p.id || blocked,
1137
1183
  onClick: () => pick(c.id, v),
1138
1184
  }, label);
1139
1185
  };
1140
1186
  // 编辑/写入审批(有 diff):详情默认展开、参数默认收起;无 diff 时参数照常显示
1141
- // 图片详情是整图 data URL(体积大):默认收起,用户点开该条时才拉取
1142
- const detailOpen = (p) => (openDetail[p.id] === undefined ? (!!p.hasDiff && !p.imagePreview) : openDetail[p.id]);
1187
+ // diff 的审批(含图片)详情默认展开:读图时那张缩略图就是用户最想看的东西
1188
+ const detailOpen = (p) => (openDetail[p.id] === undefined ? !!p.hasDiff : openDetail[p.id]);
1143
1189
  const argsOpen = (p) => (openArgs[p.id] === undefined ? !p.hasDiff : openArgs[p.id]);
1144
1190
  const toggle = (map, setMap, p, v) => setMap(Object.assign({}, map, { [p.id]: v }));
1145
1191
  // 点击文件名 → 打开右侧对比抽屉(父组件 OverlayRoot 持有 pin 状态)
@@ -1187,8 +1233,12 @@ window.__ModuleLoader__.load({
1187
1233
  (p.candidates || []).length ? React.createElement('div', null,
1188
1234
  React.createElement('div', { className: 'pg-cand-head' }, T('app.cand.head')),
1189
1235
  React.createElement('div', { className: 'pg-cand-hint' }, T('app.cand.hint')),
1190
- (p.candidates || []).map((c) => React.createElement('div', { key: c.id, className: 'pg-cand' },
1191
- React.createElement('span', { className: 'pg-cand-label' }, c.label),
1236
+ (p.candidates || []).map((c) => React.createElement('div', { key: c.id, className: 'pg-cand', style: { alignItems: 'flex-start' } },
1237
+ // 主行是路径本身,范围说明降级成下面的灰色小字(按钮与主行对齐)
1238
+ React.createElement('div', { style: { flex: 1, minWidth: 0 } },
1239
+ React.createElement('div', { className: 'pg-cand-label' }, c.label),
1240
+ c.hint ? React.createElement('div', { style: { fontSize: 11, color: 'rgba(128,128,128,0.85)', marginTop: 2, lineHeight: 1.4 } }, c.hint) : null,
1241
+ ),
1192
1242
  radio(p, c, 'allow', T('app.allowOne'), 'pg-radio-allow'),
1193
1243
  radio(p, c, 'deny', T('app.denyOne'), 'pg-radio-deny'),
1194
1244
  )),
@@ -2008,7 +2058,7 @@ window.__ModuleLoader__.load({
2008
2058
  const tag = document.createElement('style');
2009
2059
  tag.dataset.plugin = 'dsh-permgate';
2010
2060
  tag.dataset.pluginCss = 'dsh-permgate';
2011
- tag.textContent = CSS;
2061
+ tag.textContent = CSS + RADIO_DISABLED_CSS;
2012
2062
  document.head.appendChild(tag);
2013
2063
  ctx.effect(() => () => { try { tag.remove(); } catch (e) {} });
2014
2064
  }
package/index.js CHANGED
@@ -16,7 +16,10 @@ const ALL_MODES = ['ask', 'allow', 'deny', 'inherit']
16
16
  const MAX_DECISIONS = 30
17
17
  // 快捷工具预设:无文件/命令语义、只能按工具名设默认动作的清单(设置页据此展示,新配置按 QUICK_DEFAULTS 落默认)
18
18
  // 低风险观测/会话类工具默认放行,避免每次都弹窗;
19
- // cordis_run(宿主进程内执行代码)、cordis_stop/undefine(管理动态插件)与 mcp__*(外装 MCP)不在此列,走兜底策略(默认询问)
19
+ // 其余工具默认询问(ask):mcp__*(外装 MCP)、改权限配置的 perm_*(只读的 perm_status 除外)、
20
+ // 以及管理动态插件的 cordis_run/stop/undefine —— perm_* 曾经被 decide 无条件放行,等于
21
+ // 「AI 可自我提权、且全程无弹窗」;纳入本清单后统一按 ask 裁决,只有用户在设置页显式改成 allow
22
+ // 才会静默放行(预设默认优先于兜底策略,改兜底也不会漏)。
20
23
  const QUICK_DEFAULTS = {
21
24
  web_search: 'ask', skill: 'allow', grep: 'allow', glob: 'allow', web_fetch: 'ask',
22
25
  ask_user_question: 'allow', todo_write: 'allow', list_agents: 'allow',
@@ -25,6 +28,12 @@ const QUICK_DEFAULTS = {
25
28
  send_message: 'allow', interrupt_agent: 'allow',
26
29
  present: 'allow', exit_plan_mode: 'allow',
27
30
  cordis_define: 'allow', cordis_inspect_list: 'allow', cordis_inspect_query: 'allow', cordis_inspect_self: 'allow',
31
+ // 改权限配置 = 元操作,一律先问;只有 perm_status 是只读查询,默认放行(想看随时能看)
32
+ perm_status: 'allow', perm_set_category: 'ask', perm_set_fallback: 'ask', perm_set_editor_kernel: 'ask',
33
+ perm_add_exception: 'ask', perm_remove_exception: 'ask', perm_set_quick: 'ask',
34
+ perm_add_rule: 'ask', perm_remove_rule: 'ask', perm_reload: 'ask',
35
+ // cordis_run 在宿主进程里执行代码、stop/undefine 管理(可移除)动态插件 —— 同属元操作,一律先问
36
+ cordis_run: 'ask', cordis_stop: 'ask', cordis_undefine: 'ask',
28
37
  }
29
38
  // 单一来源:预设清单由 QUICK_DEFAULTS 的键派生(顺序即键的插入顺序),
30
39
  // 避免「清单」与「默认值」两份定义在新增工具时漂移(设置页展示与 locked 迁移共用这一份)
@@ -235,6 +244,30 @@ export default {
235
244
  return String(p).replace(/\\/g, '/').replace(/\/+$/, '')
236
245
  }
237
246
 
247
+ // 路径规范化单点:相对路径先按 root 绝对化,再折叠 .. 与重复分隔符。
248
+ // glob/norm/globToRegExp 都不折叠 ..,仅规范化写入侧会让例外永不命中参数原文。
249
+ function normAbsPath(p) {
250
+ const s = norm(p)
251
+ if (!s) return s
252
+ // glob 不是文件路径:绝对化会改变匹配范围(`**/*.env` 会被拼成 `G:/MCP/**/*.env`,
253
+ // 从「任意目录」缩成「仅工作区内」),故只做斜杠归一,不绝对化也不折叠 ..。
254
+ // 与 hasGlobMeta 同口径:只认 * 与 ?([ ] 在 globToRegExp 里是字面量,不是通配符)。
255
+ if (/[*?]/.test(s)) return s
256
+ // file:// 等 URL 形态不是文件系统路径,原样返回(与 resolveArgPath 同口径)
257
+ if (s.indexOf('://') !== -1) return s
258
+ const isAbs = s.indexOf('/') === 0 || /^[a-zA-Z]:/.test(s)
259
+ const abs = isAbs ? s : (root ? norm(root + '/' + s) : '')
260
+ if (!abs) return s
261
+ return norm(pathResolve(/^[a-zA-Z]:$/.test(abs) ? abs + '/' : abs))
262
+ }
263
+
264
+ // 路径同一性键:判重、写入去重、匹配三处共用同一口径,保证「同一条例外」在三处同答案。
265
+ // 必须基于 normAbsPath(绝对化 + 折叠 ..)而非裸 normPathKey,否则候选写入的规范值
266
+ // 与面板/工具入口写入的相对路径或含 .. 原文会被当成两条不同例外。
267
+ function pathKey(p) {
268
+ return normPathKey(normAbsPath(p))
269
+ }
270
+
238
271
  function safeJson(v) {
239
272
  try { return JSON.stringify(v) } catch (e) { return '' }
240
273
  }
@@ -838,7 +871,9 @@ export default {
838
871
  }
839
872
 
840
873
  function matchException(r, value, kind) {
841
- if (kind === 'path') return matchGlob(r.path, value)
874
+ // 路径两侧统一走 normAbsPath:写入值可能来自候选(规范绝对路径),而 value
875
+ // 工具参数原文(可能是相对路径或含 ..)——只规范化写入侧会让例外永不命中。
876
+ if (kind === 'path') return matchGlob(normAbsPath(r.path), normAbsPath(value))
842
877
  return matchCommand(r.match, value)
843
878
  }
844
879
 
@@ -2127,9 +2162,10 @@ export default {
2127
2162
  if (d && d.ruleId) return ' (exception ' + d.ruleId + ')'
2128
2163
  return ''
2129
2164
  }
2130
- if (typeof name === 'string' && name.indexOf('perm_') === 0) {
2131
- return { action: 'allow', reason: bi('permgate 自身管理工具,始终放行', 'permgate management tool, always allowed'), cat: null, value: null, kind: null }
2132
- }
2165
+ // 注意:perm_* 曾在此被无条件放行,等于给 AI 留了一条「自我提权且无弹窗」的后门
2166
+ // (perm_set_category / perm_set_fallback / perm_add_exception 改动即生效)。
2167
+ // 现在不再特判,统一走下面的正常判定链(自定义规则 → 分类 → 快捷工具 → 兜底),
2168
+ // 其默认动作由 QUICK_DEFAULTS 钉成 ask;设置面板走 /permgate/* HTTP 路由、不经这里,不受影响。
2133
2169
  if (sessionPresetName(exec) !== 'custom-review') {
2134
2170
  return { action: 'allow', reason: bi('会话未选择「自定义审查」,由 DSH 权限预设处理', 'Session has not selected "Custom Review"; handled by DSH permission presets'), cat: null, value: null, kind: null }
2135
2171
  }
@@ -2261,7 +2297,8 @@ export default {
2261
2297
  }
2262
2298
  if (kind === 'path' && catKey && EXC_CATS.indexOf(catKey) !== -1) {
2263
2299
  const cat = proj[catKey]
2264
- return !!(cat && Array.isArray(cat.exceptions) && cat.exceptions.some((r) => r.path === value))
2300
+ // pathKey 同口径:相对路径、含 .. 、斜杠与大小写写法都归一到同一条
2301
+ return !!(cat && Array.isArray(cat.exceptions) && cat.exceptions.some((r) => pathKey(r.path) === pathKey(value)))
2265
2302
  }
2266
2303
  if (kind === 'tool') {
2267
2304
  return !!(Array.isArray(proj.custom) && proj.custom.some((r) => r.tool === value))
@@ -2383,7 +2420,9 @@ export default {
2383
2420
 
2384
2421
  function buildCandidates(entry) {
2385
2422
  const out = []
2386
- const push = (label, value, kind, writes) => out.push({ id: 'c' + Math.random().toString(36).slice(2, 8), label, value, kind, writes: Array.isArray(writes) ? writes : [{ cat: entry.cat, kind, value }] })
2423
+ // hint:候选行下方的灰色小字(说明这条会放开什么),与 label(主文案,通常就是路径)分开下发,
2424
+ // 由客户端排版成「路径 + 小字 + 按钮」,避免把说明塞进 label 里挤成一大段
2425
+ const push = (label, value, kind, writes, hint) => out.push({ id: 'c' + Math.random().toString(36).slice(2, 8), label, value, kind, writes: Array.isArray(writes) ? writes : [{ cat: entry.cat, kind, value }], ...(hint ? { hint } : {}) })
2387
2426
  const t = (zh, en) => (uiLang === 'en' ? en : zh)
2388
2427
  if (entry.kind === 'command' && entry.value) {
2389
2428
  const parts = splitCommandSegments(String(entry.value))
@@ -2410,25 +2449,44 @@ export default {
2410
2449
  const catKey = entry.toolCat && EXC_CATS.indexOf(entry.toolCat) !== -1 ? entry.toolCat : entry.cat
2411
2450
  const outsideHere = !!(catKey && catKey !== 'directory' && isOutside(entry.value, root))
2412
2451
  // 候选文案用的分类名:说明这条候选会放开「哪一类操作」,避免文案与落盘的例外分类不符
2413
- const KIND_LABEL = { read: ['读取文件', 'file reads'], image: ['读取图片', 'image reads'], edit: ['写入/编辑', 'write/edits'], undo: ['撤销操作', 'undo actions'] }
2452
+ // 标签与设置面板的分类名保持一致(edit 就叫「编辑文件」,不再写「写入/编辑」)
2453
+ const KIND_LABEL = { read: ['读取文件', 'file reads'], image: ['读取图片', 'image reads'], edit: ['编辑文件', 'file edits'], undo: ['撤销操作', 'undo actions'] }
2414
2454
  const kindLabel = KIND_LABEL[catKey] || ['此类操作', 'this kind of operation']
2415
2455
  if (outsideHere) {
2416
2456
  // 工作区外路径:两道闸各给一条候选。「整个目录」写 directory 与该分类的目录 glob ——
2417
2457
  // 点一次后该类操作在该目录下都不再询问;「仅此文件」写自身分类例外 + directory 的精确路径例外。
2418
- // 目录 glob 只对普通路径生成:路径自身含通配符或 .. 段时,拼出的 glob 会匹配到该目录之外
2419
- // 的路径(dirGlob / norm / globToRegExp 都不折叠 ..),授权面会超过「整个目录」的文案。
2458
+ // 路径先规范化为绝对路径(折叠 .. 与重复分隔符):判重与落盘值统一基于它。
2459
+ // 含通配符的原文不做折叠——pathResolve 会把 `*` 当普通目录名、被其后的 .. 吃掉
2460
+ // (实测 C:/x/*/../y.txt → C:/x/y.txt),使通配路径被误当成精确路径生成候选。
2461
+ const absVal = hasGlobMeta(entry.value) ? norm(entry.value) : normAbsPath(entry.value)
2462
+ // 守卫按**原始值**判定:absVal 已折叠 ..,对它判 hasParentSeg 恒为 false,等于没有守卫。
2463
+ // 含 .. 的原文不给目录 glob——dirGlob 取父目录,`..` 可拼出覆盖整个盘根的 `G:/*`。
2420
2464
  const globSafe = !hasGlobMeta(entry.value) && !hasParentSeg(entry.value)
2421
- const glob = globSafe ? dirGlob(entry.value) : ''
2465
+ const glob = globSafe ? dirGlob(absVal) : ''
2422
2466
  const hasDirGlob = globSafe && alreadyInProject(glob, 'path', 'directory')
2423
2467
  const hasKindGlob = globSafe && alreadyInProject(glob, 'path', catKey)
2424
2468
  if (globSafe && (!hasDirGlob || !hasKindGlob)) {
2425
- push(t('整个目录:' + kindLabel[0] + '(允许时同时写入「' + kindLabel[0] + '」与「目录访问」两条例外;拒绝时只写入「' + kindLabel[0] + '」,不连带封禁该目录的其它类型操作):', 'Whole directory: ' + kindLabel[1] + ' (an allow writes both the ' + kindLabel[1] + ' rule and the directory-access rule; a deny writes only the ' + kindLabel[1] + ' rule and does not block other kinds of operations in that directory): ') + glob, glob, 'path', [{ cat: 'directory', kind: 'path', value: glob }, { cat: catKey, kind: 'path', value: glob }])
2469
+ // label 就是路径本身(弹窗里最该被看清的东西),范围说明走 hint 小字
2470
+ push(glob, glob, 'path', [{ cat: 'directory', kind: 'path', value: glob }, { cat: catKey, kind: 'path', value: glob }], t('工作区外访问目录 + ' + kindLabel[0] + '权限', 'Outside workspace · directory + ' + kindLabel[1]))
2426
2471
  }
2427
- if (!hasGlobMeta(entry.value) && !alreadyInProject(entry.value, 'path', catKey)) {
2428
- push(t('仅此文件:' + kindLabel[0] + '(允许时同时写入「' + kindLabel[0] + '」与「目录访问」的精确路径例外;拒绝时只写入「' + kindLabel[0] + '」,不连带封禁该路径的其它类型操作):', 'Only this file: ' + kindLabel[1] + ' (an allow writes exact-path rules for both the ' + kindLabel[1] + ' and directory access; a deny writes only the ' + kindLabel[1] + ' rule and does not block other kinds of operations on that path): ') + entry.value, entry.value, 'path', [{ cat: catKey, kind: 'path', value: entry.value }, { cat: 'directory', kind: 'path', value: entry.value }])
2472
+ const fileVal = absVal
2473
+ // 判重按该候选实际要写的**全部分类**判定:它同时写 catKey directory 两条例外,
2474
+ // 只要其中一道闸还没有等价例外就得给出候选——否则用户补不上那道闸,只能改选
2475
+ // 「整个目录」,精确授权被迫放大为目录级授权。
2476
+ const fileWrites = [{ cat: catKey, kind: 'path', value: fileVal }, { cat: 'directory', kind: 'path', value: fileVal }]
2477
+ // 按候选实际要写的两个分类(catKey + directory)各自判重:任一道闸缺等价例外即给候选。
2478
+ // 注:能走到这里就说明至少一道闸没命中等价例外(两道都命中时 resolveCategory 直接
2479
+ // 返回 allow/deny、不会弹窗),因此 covered 当前恒为 false——保留它是防御性冗余,
2480
+ // 用来表达「两条写入目标各自判重」的语义,避免未来弹窗流程变更后候选被误隐藏。
2481
+ const covered = fileWrites.every((w) => alreadyInProject(w.value, 'path', w.cat))
2482
+ // 文件候选只要求原文无通配符:含 .. 的原文已被规范化成精确绝对路径,可以安全给候选
2483
+ if (!hasGlobMeta(entry.value) && !covered) {
2484
+ push(fileVal, fileVal, 'path', fileWrites, t('工作区外访问文件 + ' + kindLabel[0] + '权限', 'Outside workspace · file + ' + kindLabel[1]))
2429
2485
  }
2430
2486
  } else if (!hasGlobMeta(entry.value)) {
2431
- if (!alreadyInProject(entry.value, 'path', entry.cat)) push(entry.value, entry.value, 'path')
2487
+ // 工作区内同样走规范化单点,避免同一份配置里两种路径写法并存
2488
+ const absVal = normAbsPath(entry.value)
2489
+ if (!alreadyInProject(absVal, 'path', entry.cat)) push(absVal, absVal, 'path')
2432
2490
  }
2433
2491
  }
2434
2492
  // 其余分类无「例外」候选:快捷工具(web_search/skill 等)走 quickTools 设置;
@@ -2458,10 +2516,9 @@ export default {
2458
2516
  // 审批发起时的项目根:root 是跨会话共享的闭包变量,随后可能被其他会话覆盖,
2459
2517
  // 打相对路径/对比/打开文件必须用发起会话自己的根
2460
2518
  projRoot: root || null,
2461
- // 编辑/写入,或带文件路径的读取 → 弹窗「详情」默认展开、按需取数据
2462
- // (写类=diff,读类=窗口化内容;图片是整图 data URL,改由客户端默认收起、点开才拉取)
2519
+ // 编辑/写入,或带文件路径的读取 → 弹窗「详情」默认展开并自动取数据
2520
+ // (写类=diff,读类=窗口化内容;图片是整图 data URL,受 IMAGE_MAX_BYTES/像素上限约束)
2463
2521
  hasDiff: isPreviewableFileTool(exec.name, exec.arguments) && !!pathArg(exec.arguments),
2464
- imagePreview: isFileImage(exec.name) === true,
2465
2522
  toolCat: pathToolCat(exec.name, exec.arguments),
2466
2523
  // str_replace_editor 的内核在审批发起时定下(insert 的 insert_line 语义随内核相反),
2467
2524
  // 详情预览按发起时的实际内核解释,避免中途判别漂移
@@ -2508,7 +2565,9 @@ export default {
2508
2565
  if (kind === 'path' && cat && cat !== 'command' && EXC_CATS.indexOf(cat) !== -1) {
2509
2566
  const c = block[cat] || freshCategory(cat, target === 'project')
2510
2567
  if (!c.exceptions) c.exceptions = []
2511
- const idx = c.exceptions.findIndex((r) => r.path === value && r.action === decision)
2568
+ // 去重与 alreadyInProject / matchException 同口径(pathKey:绝对化 + 折叠 .. + 大小写/斜杠归一):
2569
+ // 否则候选写规范绝对路径、面板写相对或含 .. 原文时会各存一条指向同一路径的例外。
2570
+ const idx = c.exceptions.findIndex((r) => pathKey(r.path) === pathKey(value) && r.action === decision)
2512
2571
  if (idx !== -1) {
2513
2572
  // 命中既有同向条目:提到数组头部,否则它会被前面的反向旧条目遮蔽(resolveCategory 只取首个匹配),
2514
2573
  // 用户的决定等于被静默丢弃;带新理由时一并回写(reason 已在入口按 deny + trim + 200 规范化)。
@@ -2518,7 +2577,8 @@ export default {
2518
2577
  block[cat] = c
2519
2578
  return hit
2520
2579
  }
2521
- const item = build({ path: value })
2580
+ // 落盘统一存规范化路径,与判重/匹配口径一致
2581
+ const item = build({ path: normAbsPath(value) })
2522
2582
  c.exceptions.unshift(item)
2523
2583
  block[cat] = c
2524
2584
  return item
@@ -2983,7 +3043,7 @@ export default {
2983
3043
  const argsPreview = e.argsJson && e.argsJson.length > 160 ? e.argsJson.slice(0, 160) + '…' : (e.argsJson || '')
2984
3044
  const reason = typeof e.reason === 'string' ? e.reason : L(e.reason, lang)
2985
3045
  const intent = typeof e.intent === 'string' ? e.intent : L(e.intent, lang)
2986
- out.push({ id: e.id, tool: e.tool, reason, ts: e.ts, args: argsPreview, intent, candidates: e.candidates || [], argLines: e.argLines || [], hasDiff: e.hasDiff === true, imagePreview: e.imagePreview === true })
3046
+ out.push({ id: e.id, tool: e.tool, reason, ts: e.ts, args: argsPreview, intent, candidates: e.candidates || [], argLines: e.argLines || [], hasDiff: e.hasDiff === true })
2987
3047
  }
2988
3048
  return json(res, out)
2989
3049
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mrweicodes/dsh-permgate",
3
3
  "description": "Permission gateway for DSH (DeepSeek Harness): reviews tool calls per category (directory/command/read/image/edit/undo/subagent/doom loop) with global/project config, exceptions, quick-tool defaults, custom rules, a fallback policy for unmatched calls, bilingual approval modal and sandbox-upgrade flow. Config persisted at $DSH_HOME/dsh-permgate/config.json.",
4
- "version": "1.5.0",
4
+ "version": "1.5.2",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/MrWeiCodes/dsh-permgate.git"