Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions dsh-plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,18 @@
"permissions": [
{
"name": "fs.write",
"scope": "~/.dsh/dsh-code-review/**、默认报告目录(~ 展开后的 ~/dsh-code-review-reports/**)及用户经 report_dir 配置的报告目录",
"reason": "落盘任务簿(tasks/ 任务文件)/ 插件运行配置(plugin-config.json)/ 评审报告留档"
"scope": "~/.dsh/dsh-code-review/**、默认报告目录(~ 展开后的 ~/dsh-code-review-reports/**)及用户经 report_dir 配置的报告目录。写入前校验:路径解析为绝对路径后必须位于用户 home 下,且 realpath 解析符号链接后无目录遍历逃逸(issue #23)",
"reason": "落盘任务簿(tasks/ 任务文件)/ 插件运行配置(plugin-config.json)/ 评审报告留档(写入路径按 scope 声明校验,防提示注入把报告写到任意目录)"
},
{
"name": "process.spawn",
"scope": "插件包 bin/ 或 PATH 中的 codecheck(含用户经 engine_bin 显式配置、写入前做本地文件校验的路径);辅助命令 git / where|which / taskkill(平台子进程树终止);以及以 process.execPath 拉起的本插件后台 serve 进程",
"scope": "插件包 bin/ 或 PATH 中的 codecheck(含用户经 engine_bin 显式配置的路径(写入前校验:绝对路径、文件存在、非符号链接、非 setuid/setgid 位,issue #23));辅助命令 git / where|which / taskkill(平台子进程树终止);以及以 process.execPath 拉起的本插件后台 serve 进程",
"reason": "评审引擎与后台 serve 均为外部子进程(由 runner / task-book 以子进程方式拉起执行),辅助命令服务于待提交清单(git)、引擎路径探测(where/which)与超时终止(taskkill)"
},
{
"name": "credentials.read",
"scope": "~/.dsh/.credentials.yaml 与环境变量中的模型凭据引用",
"reason": "调用评审 LLM 所需的 API key,复用宿主既有凭据通道"
"reason": "调用评审 LLM 所需的 API key,复用宿主既有凭据通道。凭据仅用于 API 调用,绝不写入日志或评审报告(issue #23)"
}
],
"contributes": {
Expand Down
15 changes: 13 additions & 2 deletions scripts/engine-e2e-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ const BIN = join(PKG_ROOT, 'bin', engineBinName());
const IS_WIN = process.platform === 'win32';

let failures = 0;
// 条件跳过计数(issue #22):key 缺失等环境性缺失属「跳过」而非「失败」,
// 计入 skipped;末尾文案显式区分,避免无 key 机器误判为引擎失败。
let skipped = 0;
const check = (label, ok, detail = '') => {
console.log(`${ok ? '✔' : '✘'} ${label}${detail ? ` —— ${detail}` : ''}`);
if (!ok) failures += 1;
Expand Down Expand Up @@ -111,6 +114,14 @@ try {
// 提示不再硬编码 codecheck.exe——平台化路径由
// BIN 计算得出,提示直接引用它(Linux/macOS 上出现 .exe 字面会误导)。
check(`引擎存在(${BIN})`, false, `请先把引擎二进制放到插件包 bin/(预期 ${BIN})`);
} else if (!(typeof key === 'string' && key.length >= 8)) {
// issue #22:key 解析失败(undefined)时仍构造 OCR_LLM_TOKEN: key(即成
// 字面量 'undefined')并 spawn 引擎空等 180s 必败——短路跳过引擎调用,
// check 输出明确提示。跳过属条件形态:不计 failures(skipped 计入),
// 末尾文案显式区分,杜绝「无 key 机器误报引擎失败」。
skipped += 1;
check('跳过引擎调用(key 缺失:未配置 DEEPSEEK_API_KEY)', true,
'条件跳过——配置 ~/.dsh/.credentials.yaml 后此步执行真实评审');
} else {
const env = {
...process.env,
Expand Down Expand Up @@ -157,8 +168,8 @@ try {

// 自然退出(process.exit 在 Windows libuv 下可能触发 fetch 句柄断言崩溃)。
if (failures > 0) {
console.log(`\n✘ ${failures} 项未通过`);
console.log(`\n✘ ${failures} 项未通过${skipped > 0 ? `(另有 ${skipped} 项因 key 缺失条件跳过)` : ''}`);
process.exitCode = 1;
} else {
console.log('\n✔ 引擎端到端实证通过:评审模型(用户配置端点)真实审查跑通');
console.log(`\n✔ 引擎端到端实证通过:评审模型(用户配置端点)真实审查跑通${skipped > 0 ? '(引擎调用因 key 缺失已跳过)' : ''}`);
}
18 changes: 14 additions & 4 deletions scripts/sensitive-e2e-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,21 @@ try {
const envProbes = ['EXAMPLE_PLACEHOLDER', 'EXAMPLE_NOT_A_REAL_KEY_12345'];
const leaked = envProbes.filter((s) => report.includes(s));
check('报告未含 .env 内容(密钥值不出现 = 内容未送审)', leaked.length === 0, leaked.join(',') || '无泄露');
// 引擎评审输出同样不得含密钥值(送审对象只有 app.py diff)。
const engineOut = text;
const leakedOut = envProbes.filter((s) => engineOut.includes(s));
check('引擎输出未含 .env 内容(送审链路也未触碰)', leakedOut.length === 0, leakedOut.join(',') || '无泄露');
} else {
// issue #22:报告断言不再静默跳过——评审失败(报告未落盘)时逐条记 FAIL
// 并归因到「评审未产出报告」,杜绝零失败项假绿。
check('报告含「敏感文件点名」节', false, '报告缺失——评审未产出报告,点名断言无法通过');
check('报告点名 .env(内容未送审的兜底人眼检查点)', false, '报告缺失——评审未产出报告,点名断言无法通过');
check('报告未含 .env 内容(密钥值不出现 = 内容未送审)', false, '报告缺失——评审未产出报告,泄露断言无法通过');
}
// 引擎评审输出泄露断言:从 candidates>0 嵌套提出(issue #22)——无论报告是否
// 落盘,只要 reviewOnce 有返回就检查其不泄露 .env 内容;评审静默失败(text 空)
// 时也记录失败归因,而非整段跳过。
const engineOut = text;
const envProbesOut = ['EXAMPLE_PLACEHOLDER', 'EXAMPLE_NOT_A_REAL_KEY_12345'];
const leakedOut = envProbesOut.filter((s) => engineOut.includes(s));
check('引擎输出未含 .env 内容(送审链路也未触碰)', engineOut.length > 0 && leakedOut.length === 0,
engineOut.length === 0 ? '评审输出为空(评审失败/跳过),泄露断言视同未通过' : (leakedOut.join(',') || '无泄露'));
} finally {
rmSync(work, { recursive: true, force: true });
console.log('临时仓库/报告目录已清理。');
Expand Down
14 changes: 12 additions & 2 deletions src/model-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,18 @@ function loadProfileLlmConfig() {
let yaml;
try {
yaml = require('js-yaml');
} catch {
return null; // 开发环境若 junction 未建也能继续(路由表可能为空,resolveProvider 返回 null)
} catch (error) {
// issue #21:js-yaml 依赖损坏/缺失时此前静默 return null——路由表为空、
// 评审 0.0s 失败且失败文案不指向原因,排查者无从下手。输出可诊断信息
// (含真实错误原因)后**仍保持 return null 契约**:运行态降级链与
// scripts/switch-model-e2e.mjs 的 check('解析出端点', !!ep) + ep?.keyEnv
// 安全链都依赖「解析失败 = 返回 null 不抛」,此分支只允许加诊断,绝不
// 能改成抛异常/改返回值。
console.error(
'[dsh-code-review] js-yaml 加载失败:评审模型配置(profile 的 cordis.patch.yml)无法解析,' +
'路由表将为空(评审会因缺少 LLM 端点而失败)。原因:' + String(error?.message ?? error),
);
return null; // 契约不变:解析失败 = 返回 null 不抛(路由表空时由 resolveProvider 返回 null,调用方明确报错)
}
// 根目录统一走 resolveDshRoot:优先 DSH_HOME,回退 ~/.dsh(issue #13)。
const profilesRoot = join(resolveDshRoot(), 'profiles');
Expand Down
21 changes: 17 additions & 4 deletions src/sensitive-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ export const SENSITIVE_PATH_PATTERNS = [
// 下方 id_* 行覆盖、*.pem 由凭据扩展名行覆盖);.ssh 目录段单独无覆盖属
// 已知盲区(引擎侧路径保护另有 .ssh 排除),勿据此删除下方覆盖行。
/(^|\/)\.[^/]*key[^/]*$/i,
/(^|\/)\.[^/]*secret/i,
// 隐藏文件/目录(点开头)中 secret 作为**独立词段**出现(.secret、.secrets.yml、
// .my-secrets、dir/.secrets)。与下方 secrets?|credentials? 行分工:该行只管点
// 开头形态;非隐藏的 secrets/credentials.yml 等由 secrets?|credentials? 行覆盖
// (两个正则不再对同一形态重叠发力)。末尾 (?=[.\-_]|$) 保证 secret 词段后有
// 分隔符或行尾——隐藏词段内嵌 secret 子串的其他形态不再被宽泛 [^/]* 吞入。
/(^|\/)\.([^/]*[.\-_])?secrets?(?=[.\-_]|$)/i,
/(^|\/)\.env(\.[^/]+)?$/i, // .env / .env.production 等
// 凭据扩展名必须作为「独立扩展名」出现(my.key、cert.pfx、server.crt.bak 等):
// 敏感词前必须由点号分隔([^/]*\.),后跟行尾或再一个点(如 .key.bak 的 .bak)。
Expand All @@ -37,15 +42,23 @@ export const SENSITIVE_PATH_PATTERNS = [
// 敏感文件,噪声稀释真正凭据命中的信号(issue #11)。修后路径段内嵌的
// key/cert/pem(sessionkey.go、keycmd.go 等)也不再命中;id_rsa/credentials/.env
// 等由各自独立规则覆盖,不受此行影响。
/(^|\/)[^/]*\.(pem|p12|pfx|keystore|jks|cert|crt|key|kdbx|otpkey)(\.|$)/i,
// 凭据扩展名后允许再跟一段点号修饰(.key.bak 的 .bak)或直接行尾——与 .env
// 行的 (\.[^/]+)?$ 锚定形态对齐(issue #24:原 (\.|$) 只认「单个点或行尾」,
// 边界语义不一致;对齐后 .key.bak.bak 这类「凭据扩展名后仍是文件修饰」也按
// 同口径处理)。
/(^|\/)[^/]*\.(pem|p12|pfx|keystore|jks|cert|crt|key|kdbx|otpkey)(\.[^/]+)?$/i,
/(^|\/)credentials[^/]*$/i,
// token 模式收紧(形态:独立词段 token(s) + **凭据类扩展名或路径结尾**)——
// 原 `.*token[^/]*$` 命中路径任意位置含 "token" 的文件(src/tokenizer.js、
// token-utils/index.js 等业务文件也被点名),噪声淹没「逐个人眼确认」信号。
// 扩展名收敛到凭据类白名单(与下方 token+分隔符规则同一白名单):token.json /
// api_token / my-tokens 命中;token.js(令牌类型定义模块)、tokens.ts、
// design-tokens.md 等业务源码放出——避免把「代码文件」当凭据文件点名。
/(^|\/)(.*[.\-_])?tokens?(\.(json|yml|yaml|txt|ini|conf|cfg|config|log|bak|old|tmp|env))?$/i,
// token 词段后必须跟随分隔符(. _ -)或行尾((?=[._-]|$))——issue #24:
// tokens? 缺词边界时,只要路径内存在 "token" 子串且其后以 .yml 等合法扩展名
// 收尾即命中,tokenizer-decoder.yml / tokenizer.config 这类「token 只是词前缀」
// 的业务命名会被误收(词边界挡住:token 后跟 "i" 不是分隔符也不是行尾)。
/(^|\/)(.*[.\-_])?tokens?(?=[._-]|$)(\.(json|yml|yaml|txt|ini|conf|cfg|config|log|bak|old|tmp|env))?$/i,
// token 后跟分隔符、且带凭据类扩展名的形态(refresh_token_backup.json、
// token_store.yaml、my_token.txt)——上一行的「token 作为独立词段 + 点号
// 扩展名或路径结尾」覆盖不到它们:token 后不是点而是下划线/横线。此处不写
Expand Down Expand Up @@ -117,7 +130,7 @@ export function buildSensitiveBlock(pendingPaths) {
// 路径未经转义直接进反引号代码块——git 文件名可含
// 反引号/换行(git 允许),会闭合代码块并向报告注入任意 markdown。拼接前
// 剔除反引号与换行(点名目的是人眼确认路径,替换为空格不影响辨识)。
const safe = String(h.path).replace(/[`\r\n*_[\]<>|]+/g, ' ');
const safe = String(h.path).replace(/[`\r\n*_~#&\[\]<>|]+/g, ' ');
return `${i + 1}. \`${safe}\``;
});
return [
Expand Down
81 changes: 76 additions & 5 deletions src/set-config-tool.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
* @module dsh-code-review/set-config-tool
*/
import { writeFileConfig, PLUGIN_CONFIG_FILE } from './file-config.js';
import { existsSync, statSync, accessSync, constants } from 'node:fs';
import { resolve, join } from 'node:path';
import { existsSync, statSync, accessSync, lstatSync, realpathSync, constants } from 'node:fs';
import { resolve, join, isAbsolute, basename, dirname } from 'node:path';
import { homedir } from 'node:os';
import { cwdOfExec } from './exec-context.js';

Expand Down Expand Up @@ -189,6 +189,26 @@ export function createSetConfigTool(ctx, getConfig, io = {}) {
engineBinRejected = `engine_bin 校验失败:\`${candidate}\` 存在但不可执行(权限不足或非可执行文件,传空串可恢复自动探测)。`;
}
}
// issue #23:补「绝对路径 / 非符号链接 / 非 setuid/setgid」校验——候选由
// resolve/join 构造通常已是绝对路径,仍显式校验一次(防御未来改动);
// statSync 跟随符号链接,链接指向任意可执行文件也能通过 exists+isFile+
// X_OK 三层,需 lstat 单独判定;setuid/setgid 位会让引擎进程以文件属主权限
// 运行。与 dsh-plugin.json process.spawn scope 承诺逐字对齐。
if (engineBinRejected === null && !isAbsolute(candidate)) {
engineBinRejected = `engine_bin 校验失败:\`${candidate}\` 非绝对路径(该字段未写入)。`;
}
if (engineBinRejected === null) {
try {
const lst = lstatSync(candidate);
if (lst.isSymbolicLink()) {
engineBinRejected = `engine_bin 校验失败:\`${candidate}\` 是符号链接(防引擎路径被重定向到任意可执行文件,不接受符号链接,传空串可恢复自动探测)。`;
} else if (!IS_WIN && (lst.mode & 0o6000) !== 0) {
engineBinRejected = `engine_bin 校验失败:\`${candidate}\` 带 setuid 位(拒绝以文件属主权限拉起引擎,传空串可恢复自动探测)。`;
}
} catch (error) {
engineBinRejected = `engine_bin 校验异常:${String(error?.message ?? error)}(该字段未写入,其余字段不受影响)`;
}
}
} catch (error) {
engineBinRejected = `engine_bin 校验异常:${String(error?.message ?? error)}(该字段未写入,其余字段不受影响)`;
}
Expand All @@ -199,12 +219,62 @@ export function createSetConfigTool(ctx, getConfig, io = {}) {
patch.engine_bin = candidate;
}
}
// 整单只剩被拒的 engine_bin(其余字段时照写,拒绝原因进回显)。
if (Object.keys(patch).length === 0 && engineBinRejected !== null) {
// issue #23(深度方案):report_dir 写前校验——此前直接落盘无校验,
// 恶意/误导路径(提示注入)会让后续评审把报告写到任意目录;码级校验
// 与 dsh-plugin.json fs.write scope 承诺一致:展开 ~ → 绝对路径 → 必须
// 位于用户 home 下 → 已存在段经 realpath 解析符号链接后无目录遍历逃逸。
// 与 engine_bin 同款「部分拒绝」语义:只剔除该字段,其余字段照常落盘。
let reportDirRejected = null;
if (patch.report_dir !== null && patch.report_dir !== undefined) {
let rd;
try {
const home = homedir();
if (patch.report_dir === '~') {
rd = home;
} else if (patch.report_dir.startsWith('~/') || patch.report_dir.startsWith('~\\')) {
rd = join(home, patch.report_dir.slice(2));
} else {
rd = resolve(cwdOfExec(_exec) ?? process.cwd(), patch.report_dir);
}
if (!isAbsolute(rd)) throw new Error(`解析结果 \`${rd}\` 非绝对路径`);
// home 前缀比对:Windows 卷/盘符与目录名大小写不敏感,小写归一后比较。
const norm = (p) => (IS_WIN ? String(p).toLowerCase() : String(p));
const homeReal = realpathSync(home);
let rdReal = rd;
if (existsSync(rd)) {
rdReal = realpathSync(rd); // 已存在:全量解析(含符号链接跳转)
} else {
// 目录尚未创建:逐级向上找最近存在祖先做 realpath,再原序拼接缺失
// 段——防「home 下看似合法、前缀段却是符号链接指向 home 外」(遍历逃逸)。
const missing = [];
let probe = rd;
while (!existsSync(probe)) {
const parent = dirname(probe);
if (parent === probe) break;
missing.push(basename(probe));
probe = parent;
}
rdReal = join(realpathSync(probe), ...missing.reverse());
}
if (!norm(rdReal).startsWith(norm(homeReal))) {
reportDirRejected = `report_dir 校验失败:\`${patch.report_dir}\` 解析为 \`${rd}\`(realpath \`${rdReal}\`),不在用户 home(\`${home}\`)下(传空串可恢复默认)。`;
}
} catch (error) {
reportDirRejected = `report_dir 校验异常:${String(error?.message ?? error)}(该字段未写入,其余字段不受影响)`;
}
if (reportDirRejected !== null) {
delete patch.report_dir;
} else {
// 落盘规范化绝对路径(与 engine_bin 同款口径:校验与消费端同一视角)。
patch.report_dir = rd;
}
}
// 整单只剩被拒的字段(其余字段时照写,拒绝原因进回显)。
if (Object.keys(patch).length === 0 && (engineBinRejected !== null || reportDirRejected !== null)) {
return {
ok: false,
config_path: configFile,
text: `## 代码审查插件配置(写入被拒)\n\n- ${engineBinRejected}\n- 已拒绝写入,现有配置未改动。`,
text: `## 代码审查插件配置(写入被拒)\n\n- ${engineBinRejected ?? reportDirRejected}\n- 已拒绝写入,现有配置未改动。`,
};
}
let wrote = false;
Expand Down Expand Up @@ -237,6 +307,7 @@ export function createSetConfigTool(ctx, getConfig, io = {}) {
// 成功回显必须带拒绝说明——「写入成功」只是其余字段成功,被拒字段
// 不可静默消失。
...(engineBinRejected !== null ? ['', `- ⚠ ${engineBinRejected}(该字段未写入,其余字段已生效)`] : []),
...(reportDirRejected !== null ? ['', `- ⚠ ${reportDirRejected}(该字段未写入,其余字段已生效)`] : []),
'- 提示:面板「设置」里的同名字段在 0.2.0 宿主对第三方插件的回传通道修复前暂不生效,以本工具/本配置文件为准。',
];
return { ok: true, config_path: configFile, text: lines.join('\n') };
Expand Down
Loading