dsh-code-security 0.4.1 → 0.5.1

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
@@ -4,6 +4,16 @@
4
4
 
5
5
  [历史英文记录](docs/CHANGELOG.en.md)
6
6
 
7
+ ## 0.5.1(2026-10-20)
8
+
9
+ - 修复审查外来仓库时可能执行对方 `.git/config` 命令的问题:所有 git 调用加上 `-c core.fsmonitor=false`,`secure_diff` 与 `secure_scan` 的 `includeUntracked` 都不再触发被扫仓库的 fsmonitor 命令
10
+ - 修复 `filter.<驱动>.clean` 能把新增的危险行从 diff 里删掉、让检查误判「通过」的问题:diff 改用 `--attr-source=<空树>`(Git 2.40+),工作区 `.gitattributes` 不再生效,clean 过滤器既不执行也不参与内容比较;更老的 Git 退回只关闭 fsmonitor
11
+ - 新增 4 项回归测试:fsmonitor 不执行、clean 过滤器不执行且藏不掉 `eval(input)`、`--attr-source` 参数、`includeUntracked` 的 `ls-files` 路径;同步更新既有的 argv 断言
12
+
13
+ ## 0.5.0(2026-10-17)
14
+
15
+ - 设置面板顶部新增「首次使用」分步引导:一次只讲一步,支持上一步/下一步/跳过,看完记在本机不再出现。
16
+ - 引导内容与该插件的实际用法一致;可在开发者工具里清掉 `dsh-first-run:dsh-code-security:v1` 重新查看。
7
17
  ## 0.4.1 (2026-10-15)
8
18
 
9
19
  - 修复同一行同时命中高熵密钥和其他安全规则时,其他规则的证据仍带出原始字符串的问题;所有证据统一使用同一套高熵脱敏判断,也覆盖截断的字符串。
package/docs/USAGE.en.md CHANGED
@@ -85,7 +85,7 @@ Policy file: `.code-security.json`.
85
85
  {"version":1,"exclude":["vendor/**","generated/**"],"ignore":[{"ruleId":"SEC-206","file":"test/**","reason":"Not security-sensitive"}],"failOn":"high"}
86
86
  ```
87
87
 
88
- Git runs with external diff/textconv disabled. Paths are relative to the session workspace. Staged scans read the index and only added-line findings are reported. Invalid arguments, Git refs and incomplete hunks preserve previous state.
88
+ Git runs with external diff/textconv disabled, and the reviewed repository's own executable config is neutralised too: every git call passes `-c core.fsmonitor=false`, and diff runs with `--attr-source=<empty tree>` so the worktree `.gitattributes` no longer applies (a `filter.<driver>.clean` is neither executed nor able to rewrite the diff). `--attr-source` needs Git 2.40+; older versions fall back to disabling fsmonitor only. Paths are relative to the session workspace. Staged scans read the index and only added-line findings are reported. Invalid arguments, Git refs and incomplete hunks preserve previous state.
89
89
 
90
90
  ## Development and licenses
91
91
 
package/docs/USAGE.md CHANGED
@@ -88,7 +88,7 @@ OSV 2.7.0 当前实现使用 `缓存目录/osv-scalibr/npm/all.zip`(Python 使
88
88
  }
89
89
  ```
90
90
 
91
- Git 输出禁用外部 diff/textconv,路径相对当前会话工作区。暂存检查读取 Git index;新增行才计入差异发现。错写参数、无效 Git ref 或不完整 diff 不会覆盖已有记录。
91
+ Git 输出禁用外部 diff/textconv,并额外关掉被扫仓库自带的可执行配置:所有 git 调用都带 `-c core.fsmonitor=false`,diff 还用 `--attr-source=<空树>` 让 `.gitattributes` 不生效(`filter.<驱动>.clean` 既不执行、也无法改写 diff 内容)。`--attr-source` 需要 Git 2.40+,更老的版本会退回只关闭 fsmonitor 的调用。路径相对当前会话工作区。暂存检查读取 Git index;新增行才计入差异发现。错写参数、无效 Git ref 或不完整 diff 不会覆盖已有记录。
92
92
 
93
93
  ## 开发与许可
94
94
 
package/lib/audit.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { policyIgnores } from './policy.js';
4
- import { parseDiff, runGitDiff, scanDiff } from './diff.js';
4
+ import { GIT_SAFE_CONFIG_ARGS, parseDiff, runGitDiff, scanDiff } from './diff.js';
5
5
  import { isExcludedPath, scanPath } from './scanner.js';
6
6
  import { mergeFindings, scanExternal } from './engines.js';
7
7
  export function sourceEngines(value, defaults) {
@@ -39,7 +39,7 @@ export async function runSourceCheck(scope, cfg, cwd, runner, engines, policy, s
39
39
  throw new Error('增量文件超出当前工作区');
40
40
  let text;
41
41
  if (scope.staged) {
42
- const shown = await runner.run(['git', 'show', ':./' + file.file], { cwd, timeoutMs: 30000, signal });
42
+ const shown = await runner.run(['git', ...GIT_SAFE_CONFIG_ARGS, 'show', ':./' + file.file], { cwd, timeoutMs: 30000, signal });
43
43
  if (shown.exitCode !== 0)
44
44
  throw new Error('无法读取暂存区文件,请刷新后重试');
45
45
  text = shown.stdout;
@@ -67,7 +67,7 @@ export async function runSourceCheck(scope, cfg, cwd, runner, engines, policy, s
67
67
  throw new Error('扫描期间 Git 改动已变化,请重试');
68
68
  }
69
69
  if (scope.includeUntracked && !scope.staged) {
70
- const untracked = await runner.run(['git', 'ls-files', '--others', '--exclude-standard', '-z', '--', scope.target ?? '.'], { cwd, signal, timeoutMs: 30000 });
70
+ const untracked = await runner.run(['git', ...GIT_SAFE_CONFIG_ARGS, 'ls-files', '--others', '--exclude-standard', '-z', '--', scope.target ?? '.'], { cwd, signal, timeoutMs: 30000 });
71
71
  if (untracked.exitCode !== 0)
72
72
  throw new Error('无法读取未跟踪文件列表');
73
73
  if (untracked.stdout && !untracked.stdout.endsWith('\0'))
package/lib/client.js CHANGED
@@ -1,6 +1,57 @@
1
1
  // Browser source, distributed directly; the TypeScript build preserves this file.
2
2
  window.__ModuleLoader__.load({id:'dsh-code-security',factory:require=>{
3
3
  const React=require('react'),{useState,useEffect,useRef,useMemo}=React,h=React.createElement;
4
+ var __FR_KEY = "dsh-first-run:dsh-code-security:v1";
5
+ var __FR_TITLE = "代码安全 · 首次使用(4 步)";
6
+ var __FR_STEPS = [{"title":"先全仓扫一次","body":"点「扫描」或让助手跑一次全仓扫描建立基线;第一次结果可能很多,属正常。"},{"title":"之后只看增量","body":"日常用「审 diff」,只报本次改动新增的问题,噪音低得多。"},{"title":"修完复验","body":"修完后点复验,插件会对比上次结果,告诉你哪些真的关掉了。"},{"title":"基线要谨慎","body":"「接受基线」会把当前问题标记为已知,之后不再拦截;只在确认无害时使用。"}];
7
+ // ---- first-run guide (shared snippet) ----
8
+ var __FR_STYLE_ID = 'dsh-first-run-css';
9
+ var __FR_CSS = [
10
+ '.fr-card{border:1px solid var(--dsw-border,rgba(0,0,0,.10));border-radius:12px;padding:14px 16px;margin:0 0 14px;background:linear-gradient(180deg,rgba(59,130,246,.06),rgba(59,130,246,.02))}',
11
+ '.fr-head{display:flex;align-items:center;gap:8px;margin-bottom:6px}',
12
+ '.fr-dot{width:6px;height:6px;border-radius:50%;background:var(--dsw-accent,#3b82f6)}',
13
+ '.fr-title{font-weight:650;font-size:13.5px}',
14
+ '.fr-count{margin-left:auto;font-size:11px;color:var(--dsw-text-weak,#6b7280);font-variant-numeric:tabular-nums}',
15
+ '.fr-step{font-weight:600;font-size:13px;margin:2px 0 4px}',
16
+ '.fr-body{color:var(--dsw-text-weak,#4b5563);font-size:12.5px;line-height:1.65}',
17
+ '.fr-try{margin-top:8px;padding:7px 9px;border-radius:8px;background:rgba(59,130,246,.09);font-size:12px;display:flex;gap:8px}',
18
+ '.fr-try code{font-family:ui-monospace,monospace;white-space:pre-wrap;word-break:break-word}',
19
+ '.fr-bar{display:flex;gap:8px;align-items:center;margin-top:12px}',
20
+ '.fr-progress{display:flex;gap:4px;margin-right:auto}',
21
+ '.fr-seg{width:16px;height:3px;border-radius:2px;background:var(--dsw-border,rgba(0,0,0,.12))}',
22
+ '.fr-seg.on{background:var(--dsw-accent,#3b82f6)}',
23
+ '.fr-btn{border:1px solid var(--dsw-border,rgba(0,0,0,.14));background:transparent;color:inherit;border-radius:8px;padding:4px 10px;font-size:12px;cursor:pointer}',
24
+ '.fr-btn:hover{background:rgba(0,0,0,.04)}',
25
+ '.fr-btn.primary{background:var(--dsw-accent,#3b82f6);border-color:transparent;color:#fff}',
26
+ '.fr-btn.ghost{border-color:transparent;color:var(--dsw-text-weak,#6b7280)}'
27
+ ].join('\n');
28
+ function __frEnsureCss(){ if (typeof document === 'undefined' || document.getElementById(__FR_STYLE_ID)) return; var el = document.createElement('style'); el.id = __FR_STYLE_ID; el.textContent = __FR_CSS; document.head.appendChild(el); }
29
+ function __frRead(){ try { return localStorage.getItem(__FR_KEY) === 'done'; } catch (e) { return false; } }
30
+ function __frWrite(){ try { localStorage.setItem(__FR_KEY, 'done'); } catch (e) {} }
31
+ function __FirstRunCard(){
32
+ var ds = React.useState(__frRead), done = ds[0], setDone = ds[1];
33
+ var is = React.useState(0), index = is[0], setIndex = is[1];
34
+ React.useEffect(function(){ __frEnsureCss(); }, []);
35
+ if (done) return null;
36
+ var step = __FR_STEPS[index];
37
+ var last = index >= __FR_STEPS.length - 1;
38
+ var close = function(){ __frWrite(); setDone(true); };
39
+ var kids = [
40
+ React.createElement('div', { className: 'fr-head', key: 'h' }, React.createElement('span', { className: 'fr-dot' }), React.createElement('span', { className: 'fr-title' }, __FR_TITLE), React.createElement('span', { className: 'fr-count' }, (index + 1) + ' / ' + __FR_STEPS.length)),
41
+ React.createElement('div', { className: 'fr-step', key: 't' }, step.title),
42
+ React.createElement('div', { className: 'fr-body', key: 'b' }, step.body)
43
+ ];
44
+ if (step.tryIt) kids.push(React.createElement('div', { className: 'fr-try', key: 'y' }, React.createElement('span', null, '可以直接说'), React.createElement('code', null, '「' + step.tryIt + '」')));
45
+ var buttons = [React.createElement('div', { className: 'fr-progress', key: 'p' }, __FR_STEPS.map(function(_, i){ return React.createElement('span', { key: i, className: 'fr-seg' + (i <= index ? ' on' : '') }); }))];
46
+ buttons.push(React.createElement('button', { className: 'fr-btn ghost', type: 'button', onClick: close, key: 's' }, '跳过'));
47
+ if (index > 0) buttons.push(React.createElement('button', { className: 'fr-btn', type: 'button', key: 'prev', onClick: function(){ setIndex(index - 1); } }, '上一步'));
48
+ buttons.push(React.createElement('button', { className: 'fr-btn primary', type: 'button', key: 'next', onClick: function(){ if (last) close(); else setIndex(index + 1); } }, last ? '开始使用' : '下一步'));
49
+ kids.push(React.createElement('div', { className: 'fr-bar', key: 'bar' }, buttons));
50
+ return React.createElement('div', { className: 'fr-card' }, kids);
51
+ }
52
+ function __withFirstRun(Component){ return function FirstRunWrapped(props){ return React.createElement(React.Fragment, null, React.createElement(__FirstRunCard, null), React.createElement(Component, props)); }; }
53
+ // ---- end first-run guide ----
54
+
4
55
  let locale,sidebar;
5
56
  const english=()=>{try{return String(locale?.getSnapshot?.().active||'').startsWith('en')}catch{return false}};
6
57
  const t=(zh,en)=>english()?en:zh;
@@ -58,6 +109,6 @@ window.__ModuleLoader__.load({id:'dsh-code-security',factory:require=>{
58
109
  report?.hasScan?h(React.Fragment,null,h('div',{className:'secw-tools'},h('label',{htmlFor:'secw-filter'},t('问题筛选','Filter findings')),h('select',{id:'secw-filter',value:filter,onChange:e=>{setFilter(e.target.value);setLimit(50)}},h('option',{value:'all'},t('全部','All')),h('option',{value:'new'},t('仅新增','New only')),h('option',{value:'high'},t('高风险','High risk'))),h('button',{disabled:busy,onClick:()=>download('sarif')},t('下载 SARIF','Download SARIF')),h('button',{disabled:busy,onClick:()=>download('markdown')},t('下载 Markdown','Download Markdown'))),findings.length?findings.slice(0,limit).map(f=>h(FindingCard,{key:f.id,finding:f,fresh:fresh.has(f.id),onOpen:f=>location(f,false),onCopy:f=>location(f,true)})):h('p',{className:'secw-muted'},t('此筛选下没有问题。','No findings match this filter.')),findings.length>limit?h('button',{onClick:()=>setLimit(n=>n+50)},t('显示更多','Show more')):null,(report.findingsTotal??0)>200?h('p',{className:'secw-muted'},t('面板最多展示 200 项,完整结果请导出报告。','The panel shows up to 200 findings. Export the full report for all results.')):null):null):null);
59
110
  }
60
111
  const css=`.secw{max-width:1060px;min-width:0;margin:auto;font:14px/1.6 system-ui,sans-serif;color:var(--dsw-alias-label-primary,#253047)}.secw *{box-sizing:border-box}.secw h2{font-size:22px;margin:0}.secw h3{font-size:17px;margin:0 0 8px}.secw p{margin:7px 0}.secw label{display:block;margin:8px 0 4px}.secw button,.secw select,.secw input:not([type=checkbox]){font:inherit;color:inherit;border:1px solid var(--dsw-alias-border-l3,#d3dbe5);border-radius:8px;background:var(--dsw-alias-bg-layer-1,#fff);padding:8px 10px;max-width:100%}.secw input:not([type=checkbox]),.secw select{width:100%;min-width:0}.secw button{cursor:pointer;white-space:normal}.secw button:disabled{opacity:.5;cursor:default}.secw .secw-primary{background:#245ee9;color:#fff;border-color:#245ee9}.secw .secw-tools,.secw .secw-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:12px 0}.secw .secw-tools select{width:auto;max-width:100%}.secw .secw-muted{color:var(--dsw-alias-label-secondary,#64718a);font-size:13px}.secw .secw-card,.secw .secw-finding{border:1px solid var(--dsw-alias-border-l3,#d3dbe5);border-radius:11px;padding:14px;margin:12px 0;overflow-wrap:anywhere;background:var(--dsw-alias-bg-layer-1,#fff)}.secw .secw-finding[data-severity=critical],.secw .secw-finding[data-severity=high]{border-left:4px solid #c04836}.secw .secw-coverage{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px;margin:10px 0}.secw .secw-engine{border:1px solid var(--dsw-alias-border-l3,#d3dbe5);border-radius:9px;padding:10px;overflow-wrap:anywhere}.secw .secw-engine strong{margin-right:8px}.secw .secw-engine small{display:block;color:var(--dsw-alias-label-secondary,#64718a)}.secw .secw-badge{display:inline-block;background:var(--dsw-alias-bg-layer-2,#f0f3f8);border-radius:5px;padding:1px 6px;font-size:12px}.secw .secw-badge[data-status=failed],.secw .secw-badge[data-status=partial]{color:#b33928}.secw .secw-error{background:#fff0ed;color:#973321;padding:10px;border-radius:8px;overflow-wrap:anywhere}.secw .secw-notice{background:#edf4ff;color:#17468c;padding:10px;border-radius:8px}.secw .secw-choice{display:flex;align-items:center;gap:8px}.secw fieldset{border:1px solid var(--dsw-alias-border-l3,#d3dbe5);border-radius:8px;margin:12px 0}.secw summary{cursor:pointer;font-weight:600}.secw pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:160px;overflow:auto;background:var(--dsw-alias-bg-layer-2,#f3f5f9);padding:10px;border-radius:6px}.secw .secw-file{font-family:ui-monospace,monospace;overflow-wrap:anywhere}.secw a{color:#245ee9}.secw :focus-visible{outline:3px solid #78a8ff;outline-offset:2px}[role=dialog]:has(.secw){width:min(1200px,calc(100vw - 48px))!important;max-width:1200px!important}@media(max-width:700px){[role=dialog]:has(.secw){width:100%!important;max-width:100%!important;flex-direction:column!important}[role=dialog]:has(.secw) nav{width:100%!important;flex-basis:auto!important;max-height:140px;overflow:auto;border-right:0}[role=dialog]:has(.secw) nav>div:has(>button){display:flex;flex-direction:row!important;flex-wrap:wrap;flex-shrink:0;gap:4px}[role=dialog]:has(.secw)>nav+div{min-width:0;width:100%}[role=dialog]:has(.secw)>nav+div>div:has(.secw){padding:12px!important;min-width:0}.secw .secw-coverage{grid-template-columns:1fr}}`;
61
- function apply(ctx){try{locale=ctx.get?.('locale')||ctx.locale}catch{locale=ctx.locale}try{sidebar=ctx.get?.('sidebarRight')||ctx.sidebarRight}catch{}ctx.slots.inject('settings.section',()=>ctx.slots.register({name:'settings.section',id:'dsh-code-security',order:59,label:()=>t('代码安全','Code security'),inject:()=>({})},Workbench))}
112
+ function apply(ctx){try{locale=ctx.get?.('locale')||ctx.locale}catch{locale=ctx.locale}try{sidebar=ctx.get?.('sidebarRight')||ctx.sidebarRight}catch{}ctx.slots.inject('settings.section',()=>ctx.slots.register({name:'settings.section',id:'dsh-code-security',order:59,label:()=>t('代码安全','Code security'),inject:()=>({})},__withFirstRun(Workbench)))}
62
113
  return{inject:['slots','locale'],apply};
63
114
  }});
package/lib/diff.d.ts CHANGED
@@ -24,6 +24,13 @@ export interface DiffScanResult {
24
24
  findings: Finding[];
25
25
  diff: string;
26
26
  }
27
+ /**
28
+ * 屏蔽仓库自带配置里的可执行钩子。
29
+ *
30
+ * `-c core.fsmonitor=false`:否则 `git diff` / `git ls-files --others` 会执行
31
+ * 被扫仓库 `.git/config` 里的 fsmonitor 命令。
32
+ */
33
+ export declare const GIT_SAFE_CONFIG_ARGS: readonly string[];
27
34
  /** 运行 git diff(默认 HEAD,工作区未提交改动)。 */
28
35
  export declare function runGitDiff(runner: ProcessRunner, cwd: string, base: string, target?: string, timeoutMs?: number, staged?: boolean): Promise<string>;
29
36
  /** 解析 unified diff,返回每个文件的新增行与新行号。 */
package/lib/diff.js CHANGED
@@ -6,18 +6,56 @@
6
6
  import path from 'node:path';
7
7
  import { createHash } from 'node:crypto';
8
8
  import { isExcludedPath, scanLineSet } from './scanner.js';
9
+ /**
10
+ * 屏蔽仓库自带配置里的可执行钩子。
11
+ *
12
+ * `-c core.fsmonitor=false`:否则 `git diff` / `git ls-files --others` 会执行
13
+ * 被扫仓库 `.git/config` 里的 fsmonitor 命令。
14
+ */
15
+ export const GIT_SAFE_CONFIG_ARGS = ['-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false'];
16
+ /**
17
+ * 空树对象 ID:git 对空 tree 的内容做对象头哈希("tree 0\0")。
18
+ *
19
+ * 现算而不是写死常量,既跟随仓库的哈希算法,也不会让一串十六进制被
20
+ * 密钥类规则当成可疑字符串。
21
+ */
22
+ function emptyTreeOidFor(format) {
23
+ return createHash(format).update('tree 0\u0000').digest('hex');
24
+ }
25
+ const emptyTreeCache = new Map();
26
+ /**
27
+ * 取当前仓库的空树对象 ID,用作 `--attr-source`。
28
+ *
29
+ * 把属性查询指向空树后,被扫仓库工作区里的 `.gitattributes` 不再生效,
30
+ * 因此 `filter.<驱动>.clean` 不会被调用,diff 也不会被 clean 输出替换。
31
+ */
32
+ async function emptyTreeOid(runner, cwd, timeoutMs) {
33
+ const cached = emptyTreeCache.get(cwd);
34
+ if (cached !== undefined)
35
+ return cached;
36
+ const result = await runner.run(['git', ...GIT_SAFE_CONFIG_ARGS, 'rev-parse', '--show-object-format'], { cwd, timeoutMs: Math.min(timeoutMs, 10000) });
37
+ const oid = emptyTreeOidFor(result.exitCode === 0 && /sha256/i.test(result.stdout) ? 'sha256' : 'sha1');
38
+ emptyTreeCache.set(cwd, oid);
39
+ return oid;
40
+ }
9
41
  /** 运行 git diff(默认 HEAD,工作区未提交改动)。 */
10
42
  export async function runGitDiff(runner, cwd, base, target, timeoutMs = 30000, staged = false) {
11
43
  if (typeof base !== 'string' || base.trim() === '' || base.trim().startsWith('-') || base.includes('\0')) {
12
44
  throw new Error('参数 base 必须是非空的 Git ref,不能是 Git 命令选项。');
13
45
  }
14
- const argv = ['git', 'diff'];
46
+ const attrSource = '--attr-source=' + await emptyTreeOid(runner, cwd, timeoutMs);
47
+ const argv = ['git', ...GIT_SAFE_CONFIG_ARGS, attrSource, 'diff'];
15
48
  if (staged)
16
49
  argv.push('--cached');
17
50
  argv.push('--no-ext-diff', '--no-textconv', '--no-color', '--relative', '--src-prefix=a/', '--dst-prefix=b/', '--unified=3', '--output-indicator-new=+', '--output-indicator-old=-', '--output-indicator-context= ', base.trim(), '--');
18
51
  if (target !== undefined && target.trim() !== '')
19
52
  argv.push(target.trim());
20
- const result = await runner.run(argv, { timeoutMs, cwd });
53
+ let result = await runner.run(argv, { timeoutMs, cwd });
54
+ // git < 2.40 不认识 --attr-source:退回只关闭 fsmonitor 的调用(filter 加固缺席,
55
+ // 但不会因此拒绝整个扫描)。
56
+ if (result.exitCode !== 0 && /attr-source/i.test(result.stderr)) {
57
+ result = await runner.run(argv.filter((arg) => arg !== attrSource), { timeoutMs, cwd });
58
+ }
21
59
  if (result.exitCode !== 0 && result.exitCode !== 1) {
22
60
  throw new Error('git diff 失败(退出码 ' + String(result.exitCode) + '):' + result.stderr.trim().split(/\r?\n/).slice(-4).join(' | '));
23
61
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-code-security",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "对代码与 Git 改动进行本地静态安全检查,并追踪修复结果。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -35,7 +35,9 @@
35
35
  "patch": "./cordis.patch.yml"
36
36
  },
37
37
  "client": {
38
- "inject": ["@deepseek-ai/dsh-client-ui-settings"],
38
+ "inject": [
39
+ "@deepseek-ai/dsh-client-ui-settings"
40
+ ],
39
41
  "platform": "web"
40
42
  }
41
43
  },