@nsyan/db 1.3.1 → 1.3.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.
- package/README.md +2 -1
- package/docs/USAGE.md +9 -3
- package/index.ts +54 -21
- package/package.json +1 -1
- package/src/core/audit.ts +1 -1
- package/src/core/scan/tree.ts +8 -4
- package/src/core/scan/trust.ts +86 -0
- package/src/core/scan/validate.ts +10 -1
- package/src/core/scan/walker.ts +110 -31
- package/src/core/types.ts +3 -0
- package/src/dialects/elasticsearch.ts +47 -7
- package/src/dialects/relational-dialect.ts +4 -1
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
- **10 种数据库 · 6 家族**:PostgreSQL · MySQL · Oracle · 达梦 · Redis · Elasticsearch · MongoDB · Neo4j · Hive · Spark
|
|
17
17
|
- **AI 工具**:查询 / 表结构 / 连接清单 / 扫描建连,读多写少场景的 token 友好输出
|
|
18
|
-
- **扫描建连**:AI 驱动——工具返回项目文件树,会话 AI 自行定位并阅读配置文件(任意语言/格式),提取候选后弹框确认建连;防幻觉校验(url
|
|
18
|
+
- **扫描建连**:AI 驱动——工具返回项目文件树,会话 AI 自行定位并阅读配置文件(任意语言/格式),提取候选后弹框确认建连;防幻觉校验(url 必须能被方言解析)+ 来源可信度评级(远端证据 / 环境标识 / 跨环境冲突)提示“能连通 ≠ 属于当前环境”
|
|
19
19
|
- **安全模型**:只读模式、写确认 + 执行理由、连接级强制只读、家族白名单、管理命令恒拒
|
|
20
20
|
- **审计**(可选):写操作按天落盘,含理由与完整 SQL
|
|
21
21
|
- **零构建**:TypeScript 由 pi/tsx 直接加载,core/dialects 零 pi 运行时依赖
|
|
@@ -74,6 +74,7 @@ pi install ./packages/db # 本仓库开发者
|
|
|
74
74
|
| 家族白名单 | Redis/ES/Mongo 只读白名单;`DROP`、管理 DDL、服务端 JS、`CALL dbms.*` 恒拒 |
|
|
75
75
|
| 审计日志 | 默认关;`/db config` 开启后写操作按天落盘(完整 SQL,0600) |
|
|
76
76
|
| 凭据 | 配置文件 `0600`;扫描场景密码经确认框补录(v1.3 起随 AI 阅读进会话,用户知情接受) |
|
|
77
|
+
| 扫描越界 | `path` 相对会话工作目录(`ctx.cwd`)解析,按**真实路径(realpath)**判定:`..` 上跳、绝对路径、仓库内指向子树外的软链(如 `dbconf -> /etc`)一律拒绝;`ctx.cwd` 缺失时 fail-closed,绝不回落到宿主进程目录 |
|
|
77
78
|
|
|
78
79
|
## ⚙️ 设置
|
|
79
80
|
|
package/docs/USAGE.md
CHANGED
|
@@ -60,13 +60,19 @@
|
|
|
60
60
|
|
|
61
61
|
**流程**:
|
|
62
62
|
|
|
63
|
-
1. AI 调用 `scan_project_configs` 获取项目文件树(path
|
|
64
|
-
2. AI
|
|
63
|
+
1. AI 调用 `scan_project_configs` 获取项目文件树(path 相对**会话工作目录**(`ctx.cwd`)解析,越界拒绝;判定按**真实路径**(realpath):仓库内指向子树外的软链(如 `dbconf -> /etc`)同样拒绝,扫描不会成为越界的第二个入口)
|
|
64
|
+
2. AI 先定位**当前生效的环境**(Spring:application.yml/bootstrap.yml + pom.xml 的 profile,Maven `@xxx@` 占位符真值在 `<profile><properties>` 里),再挑出可能含连接配置的文件阅读提取;配置指向配置中心(nacos/apollo)时以**远端为准**(仓库里签入的副本常已过期)
|
|
65
65
|
3. AI 调用 `db_scan_save` 提交候选——工具逐个弹确认框:确认 → 补录缺字段 → **测试连接通过才写盘**;同名连接三选一(覆盖/改名/跳过)
|
|
66
66
|
4. 被拒绝的候选(url 与类型矛盾等)AI 会按返回原因修正后重提
|
|
67
67
|
|
|
68
|
+
> ⚠️ **“能连通”≠“属于当前环境”**:同一仓库常并存 dev/test/devops/prod/dm 多套配置,连错的库(尤其是废弃库名、其他环境的地址)一样能测通。请核对确认框里的**来源**字段是否对应生效 profile。
|
|
69
|
+
|
|
68
70
|
**防幻觉校验**(确定性规则,AI 不可绕过):dialectId 必须在支持列表内;带 `url` 的候选必须能被对应方言 `parseUrl` 解析(解析成功后以 URL 为准);host 必填、port 范围 1~65535。
|
|
69
71
|
|
|
72
|
+
**来源可信度**(确定性规则,展示用、不拒绝):插件按候选的 `source` 评级并在确认框/结果里展示理由——🟢 source 含**远端检索证据**(远端 URL / `dataId=` / 显式「远端」)/ 🟡 本地文件带环境标识,或**仅自述配置中心产品名**(仓库里签入的副本也会命中产品名,而签入副本会漂移,所以不升级为可信)/ 🔴 无环境标识、或本批次跨多个环境。`testConnection` 只证明库存在、凭据有效,**不证明它属于当前环境**,这一评级就是你在写盘前拦住「连错环境」的那一眼。
|
|
73
|
+
|
|
74
|
+
> 边界说明:评级输入是**模型自述的 source 文本**,不是独立取证(写什么就信什么);它提高的是「误连环境」的被发现概率,不是安全保证。
|
|
75
|
+
|
|
70
76
|
**结果状态**:✅ 可直接建 / ✏️ 待补字段(确认框中补录)/ ⏭️ 同名已存在。
|
|
71
77
|
|
|
72
78
|
### 配置中心场景(Nacos / Apollo / Spring Cloud Config)
|
|
@@ -83,7 +89,7 @@
|
|
|
83
89
|
```
|
|
84
90
|
3. 从返回的 YAML 原文提取连接候选 → `db_scan_save` 提交(校验/确认/测试连接照常)
|
|
85
91
|
|
|
86
|
-
本地 profile 文件(如 `application-dev.yml
|
|
92
|
+
本地 profile 文件(如 `application-dev.yml`)与配置中心共有的项目:**以远端为准**(仓库里签入的副本常已过期),两边若都提取出候选,重复连接靠同名查重(⏭️ exists 状态)与确认框兜底,但冲突值不取本地。
|
|
87
93
|
|
|
88
94
|
**隐私行为变化**(v1.3.0,用户知情接受):配置文件原文(含密码)会随 AI 阅读进入会话上下文;v1.2 及以前"密码只在终端补录、不进模型上下文"的承诺不再适用于扫描场景(查询/连接管理不受影响)。
|
|
89
95
|
|
package/index.ts
CHANGED
|
@@ -5,7 +5,9 @@ import { registry } from "./src/dialects/index.js";
|
|
|
5
5
|
import { decide, effectiveReadonly, writeRequiresReason } from "./src/core/policy.js";
|
|
6
6
|
import { appendAuditLog } from "./src/core/audit.js";
|
|
7
7
|
import { collectTree } from "./src/core/scan/tree.js";
|
|
8
|
+
import { MAX_DEPTH } from "./src/core/scan/walker.js";
|
|
8
9
|
import { validateCandidates } from "./src/core/scan/validate.js";
|
|
10
|
+
import { TRUST_GLYPH, TRUST_LABEL } from "./src/core/scan/trust.js";
|
|
9
11
|
import type { Candidate, ConnConfig, DbTypeId } from "./src/core/types.js";
|
|
10
12
|
import {
|
|
11
13
|
loadConfigs, saveConfigs, loadPluginConfig, savePluginConfig, getConfigSummary,
|
|
@@ -78,7 +80,7 @@ function familyHint(c: ConnConfig): string {
|
|
|
78
80
|
const SCAN_TRIGGER_HINT = [
|
|
79
81
|
"[项目扫描建连]",
|
|
80
82
|
"当用户说“连一下这个项目的数据库”“帮我把这项目的库配上”“/db scan”等时,走两步 AI 扫描流程:",
|
|
81
|
-
"第 1 步:调用 scan_project_configs 获取项目文件树(path
|
|
83
|
+
"第 1 步:调用 scan_project_configs 获取项目文件树(path 相对会话工作目录 ctx.cwd 解析,按真实路径判定越界,越界拒绝);",
|
|
82
84
|
"第 2 步:从树中自行判断哪些文件可能包含数据库连接配置(任意语言:Spring/Py/Go/TS/Rust 的 yml/toml/env/ini/json 等都算),用自带读文件工具阅读它们,",
|
|
83
85
|
" 从中提取连接候选(类型/host/端口/库名/账号/密码),用户说“只要 pg”这类话时只提取对应类型;",
|
|
84
86
|
"第 3 步:调用 db_scan_save 提交候选——工具会弹确认框,用户逐个确认并补录密码后才写盘,绝不静默建连。",
|
|
@@ -134,7 +136,16 @@ const SCAN_GLYPH: Record<Candidate["status"], string> = {
|
|
|
134
136
|
function candidateLine(c: Candidate): string {
|
|
135
137
|
const p = c.partial;
|
|
136
138
|
const miss = c.missing.length ? `,缺: ${c.missing.join("/")}` : "";
|
|
137
|
-
|
|
139
|
+
const trust = `${TRUST_GLYPH[c.trust.level]}${TRUST_LABEL[c.trust.level]}(环境: ${c.trust.env ?? "未知"})`;
|
|
140
|
+
return `${SCAN_GLYPH[c.status]} [${c.status}] ${p.name} (${c.dialectId}) ${p.host ?? ""}:${p.port ?? ""}${p.database ? "/" + p.database : ""}${miss} ${trust} <- ${c.source}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 会话工作目录(信任锚)——扫描与审计都用它,不用 `process.cwd()`。
|
|
144
|
+
* `process.cwd()` 是宿主进程的 OS cwd,pi-web 下是 pi-web 包目录、TUI 下是启动目录,
|
|
145
|
+
* 都与会话 cwd 不同源(见 walker.resolveRoot 注释)。缺失时返回 null,由调用方 fail-closed。 */
|
|
146
|
+
function sessionCwdOf(ctx: any): string | null {
|
|
147
|
+
const cwd = ctx?.cwd;
|
|
148
|
+
return typeof cwd === "string" && cwd ? cwd : null;
|
|
138
149
|
}
|
|
139
150
|
|
|
140
151
|
// ── 导出扩展 ──────────────────────────────────────
|
|
@@ -148,9 +159,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
148
159
|
const confirmAndSaveCandidate = async (ctx: any, cand: Candidate): Promise<"saved" | "skipped" | "failed"> => {
|
|
149
160
|
const p = cand.partial;
|
|
150
161
|
let name = p.name!;
|
|
162
|
+
// 来源可信度必须出现在确认框里:testConnection 通过之后,唯一能拦住「连错环境」的就是用户这一眼
|
|
163
|
+
const trustBlock = `\n${TRUST_GLYPH[cand.trust.level]} ${TRUST_LABEL[cand.trust.level]}(环境: ${cand.trust.env ?? "未知"})\n`
|
|
164
|
+
+ cand.trust.reasons.map((r) => ` · ${r}`).join("\n");
|
|
151
165
|
const ok = await ctx.ui.confirm(
|
|
152
166
|
"AI 扫描建连",
|
|
153
|
-
`添加 ${name} (${cand.dialectId})?\n${p.host ?? ""}:${p.port ?? ""}${p.database ? "/" + p.database : ""}\n来源: ${cand.source}`,
|
|
167
|
+
`添加 ${name} (${cand.dialectId})?\n${p.host ?? ""}:${p.port ?? ""}${p.database ? "/" + p.database : ""}\n来源: ${cand.source}${trustBlock}`,
|
|
154
168
|
);
|
|
155
169
|
if (!ok) return "skipped";
|
|
156
170
|
|
|
@@ -538,15 +552,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
538
552
|
if (result.rows.length > 10) {
|
|
539
553
|
lines.push(`... 还有 ${result.rows.length - 10} 行`);
|
|
540
554
|
}
|
|
541
|
-
if (result.rows.length > 50) {
|
|
555
|
+
if (result.rows.length > 50 || result.truncated) {
|
|
542
556
|
try {
|
|
543
557
|
const exp = writeQueryExport(result.columns ?? [], result.rows, `db-menu-${config.name.replace(/[^\w.-]/g, "_")}`);
|
|
544
|
-
lines.push(
|
|
558
|
+
lines.push(`已导出取回的结果 (${result.rows.length} 行): ${exp.csvPath} | ${exp.jsonPath}`);
|
|
545
559
|
} catch { /* 导出失败不影响主结果 */ }
|
|
546
560
|
}
|
|
547
561
|
}
|
|
548
562
|
if (result.truncated) {
|
|
549
|
-
lines.push("(结果已达 maxRows
|
|
563
|
+
lines.push("(结果已达 maxRows 上限截断,服务端仍有更多数据未取回)");
|
|
550
564
|
}
|
|
551
565
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
552
566
|
} else {
|
|
@@ -815,7 +829,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
815
829
|
if (verdict.isWrite && cfg.audit_enabled) {
|
|
816
830
|
appendAuditLog({
|
|
817
831
|
time: new Date().toISOString(),
|
|
818
|
-
project:
|
|
832
|
+
project: sessionCwdOf(ctx) ?? "(会话目录未知)",
|
|
819
833
|
connection: config.name,
|
|
820
834
|
type: config.type,
|
|
821
835
|
summary: verdict.summary ?? "",
|
|
@@ -835,15 +849,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
835
849
|
const rows = result.rows.slice(0, 50).map((r) => r.join(" | "));
|
|
836
850
|
text += [header, separator, ...rows].join("\n");
|
|
837
851
|
if (result.rows.length > 50) {
|
|
838
|
-
|
|
839
|
-
|
|
852
|
+
text += `\n... 还有 ${result.rows.length - 50} 行(本次共取回 ${result.rows.length} 行)`;
|
|
853
|
+
}
|
|
854
|
+
// 取回行数超展示上限,或被 maxRows 截断时落盘 /tmp(CSV+JSON),只回路径不贴全量
|
|
855
|
+
if (result.rows.length > 50 || result.truncated) {
|
|
840
856
|
try {
|
|
841
857
|
const exp = writeQueryExport(result.columns ?? [], result.rows, `db-query-${config.name.replace(/[^\w.-]/g, "_")}`);
|
|
842
|
-
text += `\n
|
|
858
|
+
text += `\n已导出取回的结果 (${result.rows.length} 行): ${exp.csvPath} | ${exp.jsonPath}`;
|
|
843
859
|
} catch { /* 导出失败不影响主结果 */ }
|
|
844
860
|
}
|
|
845
861
|
if (result.truncated) {
|
|
846
|
-
text += `\n(结果已达 maxRows
|
|
862
|
+
text += `\n(结果已达 maxRows 上限截断,服务端仍有更多数据未取回)`;
|
|
847
863
|
}
|
|
848
864
|
} else {
|
|
849
865
|
text += "无数据返回。";
|
|
@@ -957,24 +973,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
957
973
|
pi.registerTool({
|
|
958
974
|
name: "scan_project_configs",
|
|
959
975
|
label: "扫描项目数据库配置",
|
|
960
|
-
description: "获取项目文件树,供 AI 自行定位并阅读数据库连接配置文件(任意语言:Spring/Py/Go/TS/Rust 的 yml/toml/env/ini/json 等均覆盖)。本身不做提取;提取结果用 db_scan_save 提交。",
|
|
961
|
-
promptSnippet: "当用户说「连一下这个项目的数据库」「帮我把这项目的库配上」「/db scan 配置pg」等时调用第 1 步。返回项目文件树后:自己判断哪些文件可能含数据库连接配置(用户指了类型就只找该类型),用自带读文件工具阅读,提取连接候选(类型/host/端口/库名/账号/密码),然后调用 db_scan_save 提交。path
|
|
976
|
+
description: "获取项目文件树,供 AI 自行定位并阅读数据库连接配置文件(任意语言:Spring/Py/Go/TS/Rust 的 yml/toml/env/ini/json 等均覆盖)。本身不做提取;提取结果用 db_scan_save 提交。path 相对会话工作目录(ctx.cwd)解析,按真实路径(realpath)强制限定在该子树内。",
|
|
977
|
+
promptSnippet: "当用户说「连一下这个项目的数据库」「帮我把这项目的库配上」「/db scan 配置pg」等时调用第 1 步。返回项目文件树后:自己判断哪些文件可能含数据库连接配置(用户指了类型就只找该类型),用自带读文件工具阅读,提取连接候选(类型/host/端口/库名/账号/密码),然后调用 db_scan_save 提交。path 相对会话工作目录解析,必须在会话工作目录子树内。",
|
|
978
|
+
promptGuidelines: [
|
|
979
|
+
"扫描建连(scan_project_configs → db_scan_save)先定位「当前生效的环境」再取配置:Spring 项目读 application.yml/bootstrap.yml 与 pom.xml 的 profiles(Maven 的 @xxx@ 占位符真值在 pom.xml 的 <profile><properties> 里);同仓库常并存 dev/test/devops/prod/dm 多套配置,读错 profile 会拿到废弃库。",
|
|
980
|
+
"配置指向配置中心(nacos/apollo/spring-cloud-config)时以远端为准:仓库里签入的 *_配置副本.yaml 常已过期,远端与本地冲突时用远端值;每个候选的 source 写清「生效 profile + 来源文件/dataId」。",
|
|
981
|
+
"能连通只证明库存在且凭据有效,不等于它属于当前环境:来源可疑(非生效 profile、环境无法确定)的候选不要提交,先向用户确认。插件按确定性规则给每个候选评级并随工具返回(🟢 source 含远端证据 URL/dataId、🟡 本地文件带环境标识或仅自述配置中心、🔴 无环境标识或本批跨环境冲突),汇报结果时必须如实转述评级与理由,不得把 🟡/🔴 说成已核实;从远端拉的候选请在 source 里写明 dataId 或 URL。",
|
|
982
|
+
],
|
|
962
983
|
parameters: Type.Object({
|
|
963
|
-
path: Type.Optional(Type.String({ description: "
|
|
984
|
+
path: Type.Optional(Type.String({ description: "扫描根目录,缺省为会话工作目录;强制限定在会话工作目录子树内(按真实路径判定,指向子树外的软链同样被拒,越界拒绝)" })),
|
|
964
985
|
}),
|
|
965
|
-
async execute(_toolCallId: string, params: { path?: string }, _signal: any) {
|
|
986
|
+
async execute(_toolCallId: string, params: { path?: string }, _signal: any, _onUpdate?: any, ctx?: any) {
|
|
987
|
+
// 锚点取会话 cwd(ctx.cwd)。缺失时 **fail-closed**:用 process.cwd() 兜底等于把红线
|
|
988
|
+
// 钉在宿主目录上(pi-web 下就是 pi-web 包目录),正是本版修掉的错锚点
|
|
989
|
+
const anchor = sessionCwdOf(ctx);
|
|
990
|
+
if (!anchor) {
|
|
991
|
+
return { details: undefined,
|
|
992
|
+
content: [{ type: "text" as const, text: "扫描失败: 无法获取会话工作目录(ctx.cwd 缺失)——拒绝在未知锚点下扫描。" }],
|
|
993
|
+
};
|
|
994
|
+
}
|
|
966
995
|
try {
|
|
967
|
-
const tree = collectTree(params.path ?? ".");
|
|
996
|
+
const tree = collectTree(params.path ?? ".", anchor);
|
|
968
997
|
const text = [
|
|
969
998
|
`项目根目录: ${tree.root}(文件总数 ${tree.total}${tree.truncated ? ",已截断展示前 " + tree.lines.length + " 条" : ""})`,
|
|
999
|
+
...(tree.depthCapped ? [`⚠️ 文件树可能不完整:有目录未展开(超过 ${MAX_DEPTH} 层或目录数上限)——可把更深的子目录作为 path 再扫一次`] : []),
|
|
970
1000
|
"",
|
|
971
1001
|
"文件树(相对路径,浅层优先):",
|
|
972
1002
|
...tree.lines,
|
|
973
1003
|
"",
|
|
974
1004
|
"下一步(必须执行):",
|
|
975
|
-
"1.
|
|
976
|
-
"2.
|
|
977
|
-
"3.
|
|
1005
|
+
"1. 先定位「当前生效的环境」,再看文件:Spring 项目看 application.yml/bootstrap.yml 与 pom.xml 的 profiles(Maven 的 @xxx@ 占位符真值在 pom.xml 的 <profile><properties> 里),确定生效 profile(如 devops)与生效配置文件(如 application-devops.yml)——同仓库常并存 dev/test/devops/prod/dm 多套配置,读错 profile 会拿到废弃库;再判断哪些文件可能包含数据库连接配置——任意语言/格式都算:Spring application*.yml、Python settings/config.py、Go config.yaml、Node .env、Rust .env+config.toml、docker-compose、K8s manifest 等;",
|
|
1006
|
+
"2. 用你的读文件工具阅读生效 profile 的文件;若 bootstrap.yml/application.yml 指向配置中心(nacos/apollo/spring-cloud-config),必须以远端配置为准(仓库里签入的副本常已过期)——例如 Nacos:先用 POST {server}/nacos/v1/auth/login(form: username/password)拿 accessToken,再 GET {server}/nacos/v1/cs/configs?dataId=<服务名>.yaml&group=<group>&tenant=<namespace-id>&accessToken=<token>;远端与本地冲突时以远端为准;",
|
|
1007
|
+
"3. 从生效配置中提取连接候选:dialectId(postgresql/mysql/oracle/dm/redis/elasticsearch/mongodb/neo4j/hive/spark)、host、port、database、username、password、url(如有完整连接串)、source(写清「生效 profile + 来源文件/dataId」,如 pom.xml#devops → application-devops.yml);用户指定了类型(如「配置pg」)就只提取该类型;来源可疑(非生效 profile、环境无法确定)的候选不要提交,先向用户确认;",
|
|
978
1008
|
"4. 调用 db_scan_save 提交候选数组。带 url 的候选必须能被对应方言解析,编造的 url 会被拒绝;",
|
|
979
1009
|
"5. 工具会弹确认框让用户逐个确认并补录密码,把工具返回的保存结果汇报给用户。",
|
|
980
1010
|
].join("\n");
|
|
@@ -1003,7 +1033,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1003
1033
|
password: Type.Optional(Type.String({ description: "密码(缺失时确认框会引导用户补录)" })),
|
|
1004
1034
|
url: Type.Optional(Type.String({ description: "完整连接串(可选,提供时必须能被方言解析,否则整条拒绝)" })),
|
|
1005
1035
|
name: Type.Optional(Type.String({ description: "连接名称(缺省自动生成)" })),
|
|
1006
|
-
source: Type.Optional(Type.String({ description: "
|
|
1036
|
+
source: Type.Optional(Type.String({ description: "来源描述。本地文件写「生效 profile + 来源文件」,如 pom.xml#devops → application-devops.yml;从配置中心拉的必须写远端证据(远端 URL 或 dataId=),否则只能评为 🟡。插件按此评级并在确认框展示,缺失/无环境标识评为 🔴" })),
|
|
1007
1037
|
}), { description: "连接候选数组" }),
|
|
1008
1038
|
}),
|
|
1009
1039
|
async execute(_toolCallId: string, params: { candidates: unknown[] }, _signal: any, _onUpdate?: any, ctx?: any) {
|
|
@@ -1025,7 +1055,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1025
1055
|
for (const cand of candidates) {
|
|
1026
1056
|
try {
|
|
1027
1057
|
const r = await confirmAndSaveCandidate(ctx, cand);
|
|
1028
|
-
|
|
1058
|
+
const trustLine = `${TRUST_GLYPH[cand.trust.level]}${TRUST_LABEL[cand.trust.level]}(环境: ${cand.trust.env ?? "未知"})`;
|
|
1059
|
+
// 理由一并回传:promptGuidelines 要求模型如实转述评级与理由,模型看不到就无从转述
|
|
1060
|
+
const why = cand.trust.reasons.length ? `\n ${cand.trust.reasons.join(";")}` : "";
|
|
1061
|
+
results.push(`${SCAN_GLYPH[cand.status]} ${cand.partial.name} (${cand.dialectId}): ${r === "saved" ? "已保存" : r === "skipped" ? "用户跳过" : "连接失败未保存"} ${trustLine}${why}`);
|
|
1029
1062
|
} catch (err) {
|
|
1030
1063
|
results.push(`❌ ${cand.partial.name} (${cand.dialectId}): 异常 ${err instanceof Error ? err.message : String(err)}`);
|
|
1031
1064
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nsyan/db",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "AI 数据库接入扩展 for pi —— 达梦(DM)/PostgreSQL/MySQL/Oracle/Redis/Elasticsearch/MongoDB/Neo4j/Hive/Spark 十种数据库,六大家族方言,提供查询/表结构/扫描建连/连接清单 LLM 工具 | Database access extension for the pi coding agent: query, schema browsing, connection scanning and audit tools for PostgreSQL, MySQL, Oracle, DM (Dameng 达梦), Redis, Elasticsearch, MongoDB, Neo4j, Hive and Spark",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-extension",
|
package/src/core/audit.ts
CHANGED
|
@@ -12,7 +12,7 @@ export const AUDIT_DIR = join(homedir(), ".pi", "agent", "db-audit");
|
|
|
12
12
|
export interface AuditEntry {
|
|
13
13
|
/** ISO 时间 */
|
|
14
14
|
time: string;
|
|
15
|
-
/**
|
|
15
|
+
/** 执行时的项目路径(会话 cwd = ctx.cwd),跨项目区分用 */
|
|
16
16
|
project: string;
|
|
17
17
|
/** 连接名 */
|
|
18
18
|
connection: string;
|
package/src/core/scan/tree.ts
CHANGED
|
@@ -10,15 +10,18 @@ export interface TreeResult {
|
|
|
10
10
|
root: string; // 绝对路径
|
|
11
11
|
total: number; // 收集到的文件总数
|
|
12
12
|
truncated: boolean; // 是否超出 MAX_TREE_LINES 被截断
|
|
13
|
+
depthCapped: boolean; // 是否有目录未展开(超 MAX_DEPTH 或目录预算;如实上报,避免静默漏扫)
|
|
13
14
|
lines: string[]; // 相对路径清单(已排序:浅层优先)
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const MAX_TREE_LINES = 3000;
|
|
17
18
|
|
|
18
|
-
/** 收集项目文件树(相对路径排序清单);越界由 resolveRoot
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
/** 收集项目文件树(相对路径排序清单);越界由 resolveRoot 拒绝。
|
|
20
|
+
* anchor 传会话工作目录(`ctx.cwd`),勿传 `process.cwd()`,原因见 walker.resolveRoot。
|
|
21
|
+
* 输出确定:walk 对目录项排序,本函数再按「浅层优先 + 字典序」排序,同一项目每次结果一致 */
|
|
22
|
+
export function collectTree(rootInput: string, anchor: string): TreeResult {
|
|
23
|
+
const root = resolveRoot(rootInput, anchor);
|
|
24
|
+
const { files, depthCapped } = walk(root);
|
|
22
25
|
const rels = files
|
|
23
26
|
.map((f) => f.slice(root.length + 1).split("\\").join("/"))
|
|
24
27
|
.sort((a, b) => {
|
|
@@ -29,6 +32,7 @@ export function collectTree(rootInput: string): TreeResult {
|
|
|
29
32
|
root,
|
|
30
33
|
total: rels.length,
|
|
31
34
|
truncated: rels.length > MAX_TREE_LINES,
|
|
35
|
+
depthCapped,
|
|
32
36
|
lines: rels.slice(0, MAX_TREE_LINES),
|
|
33
37
|
};
|
|
34
38
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// scan/trust.ts —— 候选「来源可信度」评估(确定性规则,纯函数,可单测)
|
|
2
|
+
// 动机:db_scan_save 唯一的客观闸门是 testConnection,而「能连通」只证明库存在、凭据有效,
|
|
3
|
+
// 不证明它属于当前环境。同仓库常并存 dev/test/devops/prod/dm 多套配置,签入的配置中心
|
|
4
|
+
// 副本又会随时间漂移,模型很容易提取到废弃库名或别的环境地址——而且照样能测通。
|
|
5
|
+
// 本模块不判断配置对不对(无确定解),只把「来源能证明什么」分级、在确认框里显式展示,
|
|
6
|
+
// 把环境判断交回用户,而不是让它静默通过。
|
|
7
|
+
//
|
|
8
|
+
// 边界(必须明说):评级输入是**模型自述的 `source` 文本**,不是独立取证——写什么就信什么。
|
|
9
|
+
// 它抬高的是「误连环境」的被发现概率(用户在确认框多看一眼),不构成安全保证;
|
|
10
|
+
// 真正的闸门只有 testConnection 与用户确认。
|
|
11
|
+
// 因此 🟢 要求 source 里有**远端检索证据**(URL / dataId / 显式「远端」);仅出现产品名
|
|
12
|
+
// (nacos/apollo/…)不升级——仓库里签入的同名副本会命中,而那恰恰是会漂移的东西。
|
|
13
|
+
|
|
14
|
+
export type TrustLevel = "high" | "medium" | "low";
|
|
15
|
+
|
|
16
|
+
export interface SourceTrust {
|
|
17
|
+
level: TrustLevel;
|
|
18
|
+
/** 从 source 识别出的环境标识(local/dev/test/devops/prod…),识别不出为 null */
|
|
19
|
+
env: string | null;
|
|
20
|
+
/** 展示给用户的中文理由:只陈述来源能证明什么,不含结论 */
|
|
21
|
+
reasons: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 远端检索证据:只有真的去远端拉过才会写进 source 的标记(URL / Nacos OpenAPI / dataId= / 显式「远端」) */
|
|
25
|
+
const REMOTE_EVIDENCE = /https?:\/\/|dataid\s*=|cs\/configs|(?:远端|远程|remote)/i;
|
|
26
|
+
|
|
27
|
+
/** 配置中心产品名:仓库里签入的副本文件名同样命中,不能单独证明「取自远端」 */
|
|
28
|
+
const CONFIG_CENTER_NAME = /\bnacos\b|\bapollo\b|\bconsul\b|config[-_ ]?center|config[-_ ]?server|spring-cloud-config/i;
|
|
29
|
+
|
|
30
|
+
/** 环境标识白名单:按分隔符整段比对,避免 "dev" 命中 "devops" */
|
|
31
|
+
const ENV_TOKENS = new Set([
|
|
32
|
+
"local", "devops", "dev", "sit", "test", "uat", "stage", "staging", "pre", "gray", "prod", "release", "dm",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const PROD_TOKENS = new Set(["prod", "release"]);
|
|
36
|
+
|
|
37
|
+
/** 从 source 文本取环境段(按 . _ - / \ # 空格 = 切分后整段匹配;切 `=` 使 `group=devops` 也能识别) */
|
|
38
|
+
export function envOf(source: string): string | null {
|
|
39
|
+
for (const tok of source.toLowerCase().split(/[._\-/\\#\s=]+/)) {
|
|
40
|
+
if (ENV_TOKENS.has(tok)) return tok;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 批量评估来源可信度(跨候选的环境冲突必须看整批,故入口是数组)。
|
|
47
|
+
* @param sources 与候选一一对应的 source 文本(空串表示未提供)
|
|
48
|
+
*/
|
|
49
|
+
export function assessSourceTrust(sources: readonly string[]): SourceTrust[] {
|
|
50
|
+
const envs = sources.map(envOf);
|
|
51
|
+
const distinct = [...new Set(envs.filter((e): e is string => e !== null))];
|
|
52
|
+
|
|
53
|
+
return sources.map((source, i) => {
|
|
54
|
+
const reasons: string[] = [];
|
|
55
|
+
const env = envs[i];
|
|
56
|
+
let level: TrustLevel;
|
|
57
|
+
|
|
58
|
+
if (REMOTE_EVIDENCE.test(source)) {
|
|
59
|
+
level = "high";
|
|
60
|
+
reasons.push("来源含远端检索证据(URL / dataId / 显式「远端」):远端配置是运行时真值");
|
|
61
|
+
} else if (CONFIG_CENTER_NAME.test(source)) {
|
|
62
|
+
level = "medium";
|
|
63
|
+
reasons.push("来源自述为配置中心但未写远端证据(URL / dataId):请确认它取自远端接口,而不是仓库里签入的副本——签入的副本常已过期");
|
|
64
|
+
} else if (env === null) {
|
|
65
|
+
level = "low";
|
|
66
|
+
reasons.push("来源无环境标识:同仓库常并存多套环境配置,且签入的配置中心副本可能已过期,无法判断它是否属于当前环境");
|
|
67
|
+
} else {
|
|
68
|
+
level = "medium";
|
|
69
|
+
reasons.push(`来源为本地文件、环境标识「${env}」:请确认它就是当前生效 profile(Maven 的 @xxx@ 占位符真值在 pom.xml 的 <profile><properties> 里)`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 一批里出现多个环境,说明至少有一部分候选不属于当前环境,必须让用户定夺
|
|
73
|
+
if (distinct.length > 1 && env !== null) {
|
|
74
|
+
level = "low";
|
|
75
|
+
reasons.unshift(`本批次候选来自多个环境(${distinct.join("、")}):请确认当前生效的是哪一个,只取生效环境的库`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (env !== null && PROD_TOKENS.has(env)) {
|
|
79
|
+
reasons.push("疑似生产环境:保存后建议用 /db 编辑该连接、把环境标签设为 prod(会启用强制只读)");
|
|
80
|
+
}
|
|
81
|
+
return { level, env, reasons };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const TRUST_GLYPH: Record<TrustLevel, string> = { high: "🟢", medium: "🟡", low: "🔴" };
|
|
86
|
+
export const TRUST_LABEL: Record<TrustLevel, string> = { high: "来源可信", medium: "来源待核", low: "来源存疑" };
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { basename } from "node:path";
|
|
8
8
|
import { registry } from "../../dialects/index.js";
|
|
9
|
+
import { assessSourceTrust } from "./trust.js";
|
|
9
10
|
import type { Candidate, CandidateInput, CandidateStatus, ConnConfig, DbTypeId } from "../types.js";
|
|
10
11
|
|
|
11
12
|
/** 各类型建连必需字段(空串同样视为缺失)——家族语义,非正则,保留 */
|
|
@@ -37,6 +38,13 @@ export function validateCandidates(raw: unknown, existingNames: Set<string>): Va
|
|
|
37
38
|
const candidates: Candidate[] = [];
|
|
38
39
|
const rejected: string[] = [];
|
|
39
40
|
const seenNames = new Set(existingNames);
|
|
41
|
+
// 来源可信度:跨候选的环境冲突要看整批,故先批量评估,再按原始下标挂到候选上
|
|
42
|
+
const trusts = assessSourceTrust(
|
|
43
|
+
list.map((item) =>
|
|
44
|
+
item && typeof item === "object" && nonEmpty((item as Record<string, unknown>).source)
|
|
45
|
+
? String((item as Record<string, unknown>).source)
|
|
46
|
+
: ""),
|
|
47
|
+
);
|
|
40
48
|
|
|
41
49
|
for (const [i, item] of list.entries()) {
|
|
42
50
|
if (item === null || typeof item !== "object") {
|
|
@@ -106,7 +114,8 @@ export function validateCandidates(raw: unknown, existingNames: Set<string>): Va
|
|
|
106
114
|
dialectId: dialect.id,
|
|
107
115
|
partial: { name, ...bag } as Partial<import("../types.js").ConnConfig>,
|
|
108
116
|
missing,
|
|
109
|
-
source: nonEmpty(c.source) ? String(c.source) : "AI
|
|
117
|
+
source: nonEmpty(c.source) ? String(c.source) : "AI 提取(未提供来源)",
|
|
118
|
+
trust: trusts[i],
|
|
110
119
|
warnings: Array.isArray(c.warnings) ? c.warnings.map(String).slice(0, 3) : undefined,
|
|
111
120
|
});
|
|
112
121
|
}
|
package/src/core/scan/walker.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
// scan/walker.ts —— 目录漫步:默认忽略常见无关目录;resolveRoot 越界拒绝(Spec §8.5 红线)
|
|
2
|
-
import { readdirSync, statSync, type Dirent } from "node:fs";
|
|
3
|
-
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
2
|
+
import { readdirSync, realpathSync, statSync, type Dirent } from "node:fs";
|
|
3
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
|
|
5
|
+
// 忽略的目录:只列**确定无关**的(依赖/构建产物/版本控制元数据/工具缓存)。
|
|
6
|
+
// 注意 docs 不在此列:本模块不对「目录名是否与配置有关」做猜测(见 tree.ts 设计注释),
|
|
7
|
+
// 配置样例/真实配置放 docs 下的项目是存在的,漏扫比多扫代价大。
|
|
5
8
|
const IGNORED_DIRS = new Set([
|
|
6
|
-
"node_modules", ".git", "target", "dist", "venv", "
|
|
7
|
-
"build", "coverage", "__pycache__", ".idea", ".vscode",
|
|
9
|
+
"node_modules", ".git", ".svn", ".hg", "target", "dist", "venv", ".venv", "logs",
|
|
10
|
+
"build", "coverage", "__pycache__", ".idea", ".vscode", ".next", ".nuxt", ".cache",
|
|
11
|
+
".gradle", ".tox", ".pytest_cache", ".mypy_cache", ".turbo", ".parcel-cache", ".sass-cache",
|
|
8
12
|
]);
|
|
9
13
|
|
|
10
14
|
const MAX_FILES = 2000;
|
|
11
|
-
|
|
15
|
+
/** 目录访问预算:软链别名会重复走到同一个真实目录(见 walk 的断环说明),
|
|
16
|
+
* 需要一个上界防止病态目录图把扫描拖死;超限经 depthCapped 上报,不静默。 */
|
|
17
|
+
const MAX_DIRS = 20000;
|
|
18
|
+
/** 深度上限:Java 多模块可达 `m/src/main/java/com/x/.../service/mapper/impl`(实测 14 层)、
|
|
19
|
+
* monorepo 嵌套更深;超限会通过 depthCapped 如实上报。导出供工具提示引用,避免硬编码层数漂移。 */
|
|
20
|
+
export const MAX_DEPTH = 16;
|
|
12
21
|
|
|
13
22
|
/** 扫描目标配置文件:不受普通文件配额挤占(大型 Java 工程源码文件可轻易冲爆 MAX_FILES) */
|
|
14
23
|
const CONFIG_EXTS = new Set([".yml", ".yaml", ".properties"]);
|
|
@@ -17,47 +26,117 @@ function isConfigFile(name: string): boolean {
|
|
|
17
26
|
return base === ".env" || base.startsWith(".env.") || CONFIG_EXTS.has(extname(base));
|
|
18
27
|
}
|
|
19
28
|
|
|
29
|
+
/** target 是否在 base 子树内(纯路径判定,用于拦住软链逃逸) */
|
|
30
|
+
function within(base: string, target: string): boolean {
|
|
31
|
+
const rel = relative(base, target);
|
|
32
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel));
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
/**
|
|
21
|
-
* 把输入路径解析为绝对路径;越界(
|
|
22
|
-
*
|
|
36
|
+
* 把输入路径解析为绝对路径;越界(anchor 子树之外、`..` 上跳)直接抛
|
|
37
|
+
* "scan path out of scope"——与「密码不进上下文」同级别的红线。
|
|
38
|
+
*
|
|
39
|
+
* anchor 必须由调用方显式传入「会话工作目录」(pi 的 `ctx.cwd`),不能取 `process.cwd()`:
|
|
40
|
+
* 扩展与 pi 主进程同进程,`process.cwd()` 是**宿主进程的 OS cwd**。pi-web 下宿主 cwd 是
|
|
41
|
+
* pi-web 包目录、TUI 下是 pi 的启动目录,都与会话 cwd 不同源(会话 cwd 存在 session header
|
|
42
|
+
* 里,`/resume` 后可与启动目录不同)。锚错对象的后果是双向的:
|
|
43
|
+
* - fail-closed:项目路径全被拒(pi-web 下的必然结果);
|
|
44
|
+
* - fail-open:宿主 cwd 子树反而被放行——红线保护了不相干的目录树。
|
|
45
|
+
* 因此锚点只能来自 `ctx.cwd`,绝不能由模型传入的 input 反推。
|
|
46
|
+
*
|
|
47
|
+
* 判定分两道:
|
|
48
|
+
* ① 字面路径必须在 anchor 子树内(拦 `..` 上跳与绝对路径);
|
|
49
|
+
* ② **真实路径**(realpath)也必须在 anchor 的真实路径子树内——字面合法 ≠ 真实位置合法:
|
|
50
|
+
* 仓库里一个 `dbconf -> /etc` 的软链会让 abs 字面在子树内、真实位置在外(仅比对
|
|
51
|
+
* 「root 的 realpath 与其子项」会让软链扫描根自我授权)。两端都取 realpath 再比对,
|
|
52
|
+
* 因此「项目本身位于软链路径下」(如 macOS `/tmp → /private/tmp`)不会被误伤。
|
|
23
53
|
*/
|
|
24
|
-
export function resolveRoot(input: string): string {
|
|
25
|
-
const cwd =
|
|
54
|
+
export function resolveRoot(input: string, anchor: string): string {
|
|
55
|
+
const cwd = resolve(anchor);
|
|
26
56
|
const abs = isAbsolute(input) ? resolve(input) : resolve(cwd, input);
|
|
27
57
|
const rel = relative(cwd, abs);
|
|
28
|
-
|
|
29
|
-
|
|
58
|
+
// 注意:不能写 rel.startsWith("..")——cwd 内合法的 `..foo/` 也会命中前缀,属假阳性误杀
|
|
59
|
+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
|
|
60
|
+
throw new Error(`scan path out of scope: ${input}(允许根目录: ${cwd})`);
|
|
61
|
+
}
|
|
62
|
+
let anchorReal: string;
|
|
63
|
+
try { anchorReal = realpathSync(cwd); } catch { throw new Error(`scan anchor does not exist: ${cwd}`); }
|
|
64
|
+
let absReal: string;
|
|
65
|
+
try { absReal = realpathSync(abs); } catch { throw new Error(`scan path does not exist: ${input}`); }
|
|
66
|
+
if (!within(anchorReal, absReal)) {
|
|
67
|
+
throw new Error(`scan path out of scope: ${input}(真实路径 ${absReal} 不在允许根目录 ${anchorReal} 子树内)`);
|
|
30
68
|
}
|
|
31
69
|
return abs;
|
|
32
70
|
}
|
|
33
71
|
|
|
34
|
-
|
|
35
|
-
|
|
72
|
+
export interface WalkResult {
|
|
73
|
+
files: string[];
|
|
74
|
+
/** 是否有目录未展开(超 MAX_DEPTH 深度上限或 MAX_DIRS 目录预算),静默截断会让模型误判「项目里没有更深的配置」 */
|
|
75
|
+
depthCapped: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 递归收集文本候选文件(上限 MAX_FILES);配置类文件单独收集不被挤占。
|
|
79
|
+
* 与 v1.3.1 的行为差异:
|
|
80
|
+
* ① 不再跳过 `.` 开头的目录(`.config/db.yml` 这类配置此前永远扫不到),改为靠 IGNORED_DIRS 排噪;
|
|
81
|
+
* ② 软链不再静默忽略:目标 realpath 在 root 子树内则按真实类型访问(拦越界逃逸);
|
|
82
|
+
* ③ 深度超限不再静默:通过 depthCapped 如实上报;
|
|
83
|
+
* ④ 输出确定:目录项排序 + 只用祖先链断环(不做全局 realpath 去重),
|
|
84
|
+
* 真目录与软链别名两条路径都保留,不随文件系统的 readdir 顺序变化。*/
|
|
85
|
+
export function walk(root: string): WalkResult {
|
|
36
86
|
const out: string[] = [];
|
|
37
87
|
const configs: string[] = [];
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
88
|
+
let rootReal = resolve(root);
|
|
89
|
+
try { rootReal = realpathSync(root); } catch { /* root 不存在时由 readdir 兜底 */ }
|
|
90
|
+
let depthCapped = false;
|
|
91
|
+
let dirsVisited = 0;
|
|
92
|
+
|
|
93
|
+
const push = (path: string, isConfig: boolean): void => {
|
|
94
|
+
if (isConfig) {
|
|
95
|
+
if (configs.length < MAX_FILES) configs.push(path);
|
|
96
|
+
} else if (out.length < MAX_FILES) {
|
|
97
|
+
out.push(path);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* ancestors 只装「当前遍历路径上」的目录 realpath,用于断环,**不做全局去重**:
|
|
103
|
+
* 全局去重会让真目录与软链别名按 readdir 顺序互相吞掉(谁先遍历到谁赢),
|
|
104
|
+
* 同一项目因此在 APFS/ext4 上产出不同的文件树——别名路径照样要走,只是不能成环。
|
|
105
|
+
*/
|
|
106
|
+
const visit = (dir: string, depth: number, ancestors: ReadonlySet<string>): void => {
|
|
107
|
+
if (depth > MAX_DEPTH || dirsVisited >= MAX_DIRS) { depthCapped = true; return; }
|
|
108
|
+
let real: string;
|
|
109
|
+
try { real = realpathSync(dir); } catch { return; }
|
|
110
|
+
if (ancestors.has(real)) return; // 软链环(a/self -> a):断环即可
|
|
111
|
+
dirsVisited++;
|
|
112
|
+
const next = new Set(ancestors);
|
|
113
|
+
next.add(real);
|
|
41
114
|
let entries: Dirent[];
|
|
42
115
|
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
116
|
+
// readdir 顺序随文件系统而变(APFS 插入序 / ext4 哈希序),排序后文件树才是确定的
|
|
117
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
43
118
|
for (const e of entries) {
|
|
44
119
|
if (IGNORED_DIRS.has(e.name)) continue;
|
|
45
|
-
if (e.isDirectory() && e.name.startsWith(".")) continue;
|
|
46
120
|
const p = join(dir, e.name);
|
|
47
|
-
if (e.isDirectory()) visit(p, depth + 1);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
121
|
+
if (e.isDirectory()) { visit(p, depth + 1, next); continue; }
|
|
122
|
+
if (e.isFile()) { push(p, isConfigFile(e.name)); continue; }
|
|
123
|
+
// 软链:readdir 的 Dirent 对软链 isDirectory/isFile 均为 false,需按 realpath 判定。
|
|
124
|
+
// 指向 root 子树之外的一律忽略——扫描目录树不能成为越界的第二个入口。
|
|
125
|
+
if (!e.isSymbolicLink()) continue;
|
|
126
|
+
let target: string;
|
|
127
|
+
try { target = realpathSync(p); } catch { continue; }
|
|
128
|
+
if (!within(rootReal, target)) continue;
|
|
129
|
+
let targetIsDir = false, targetIsFile = false;
|
|
130
|
+
try {
|
|
131
|
+
const st = statSync(target);
|
|
132
|
+
targetIsDir = st.isDirectory();
|
|
133
|
+
targetIsFile = st.isFile();
|
|
134
|
+
} catch { continue; }
|
|
135
|
+
if (targetIsDir) visit(p, depth + 1, next);
|
|
136
|
+
// 软链文件名可能不带配置后缀(.env 被链接成 env.local):名字与目标名任一像配置就按配置收
|
|
137
|
+
else if (targetIsFile) push(p, isConfigFile(e.name) || isConfigFile(basename(target)));
|
|
59
138
|
}
|
|
60
139
|
};
|
|
61
|
-
visit(root, 0);
|
|
62
|
-
return [...configs, ...out];
|
|
140
|
+
visit(root, 0, new Set<string>());
|
|
141
|
+
return { files: [...configs, ...out], depthCapped };
|
|
63
142
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// core/types.ts
|
|
2
|
+
import type { SourceTrust } from "./scan/trust.js";
|
|
2
3
|
export type DbTypeId = "postgresql" | "mysql" | "oracle" | "dm"
|
|
3
4
|
| "redis" | "elasticsearch" | "mongodb" | "hive" | "spark" | "neo4j";
|
|
4
5
|
export type DbFamily = "relational" | "kv" | "search" | "document" | "bigdata" | "graph";
|
|
@@ -58,6 +59,8 @@ export interface Candidate {
|
|
|
58
59
|
partial: Partial<ConnConfig>; // 归一化后的字段(含默认 name)
|
|
59
60
|
missing: string[]; // 待补字段名(incomplete 时)
|
|
60
61
|
source: string; // 来源描述
|
|
62
|
+
/** 来源可信度(插件按确定性规则从模型自述的 source 评估,供展示,不影响 status;不是独立取证) */
|
|
63
|
+
trust: SourceTrust;
|
|
61
64
|
warnings?: string[];
|
|
62
65
|
}
|
|
63
66
|
|
|
@@ -144,7 +144,7 @@ class ElasticsearchDialect extends SearchDialect {
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
protected async doSearch(client: unknown, config: ConnConfig, sql: string, kind: DslKind, opts: ExecOpts): Promise<{ columns: string[]; rows: unknown[][]; rowCount: number }> {
|
|
147
|
+
protected async doSearch(client: unknown, config: ConnConfig, sql: string, kind: DslKind, opts: ExecOpts): Promise<{ columns: string[]; rows: unknown[][]; rowCount: number; truncated?: boolean }> {
|
|
148
148
|
const es = client as ClientV8;
|
|
149
149
|
const index = config.database;
|
|
150
150
|
if (!index) throw new Error("ES 连接缺少默认 index(database 字段)");
|
|
@@ -160,7 +160,8 @@ class ElasticsearchDialect extends SearchDialect {
|
|
|
160
160
|
if (kind.endpoint === "_count") {
|
|
161
161
|
const res = await es.count({ index });
|
|
162
162
|
const count = (unwrap(res) as unknown as { count?: number }).count ?? 0;
|
|
163
|
-
|
|
163
|
+
// rowCount 为“取回行数”(1 行 = 计数值本身),实际计数在单元格里,避免“返回 88 行”却只给一行的误读
|
|
164
|
+
return { columns: ["count"], rows: [[count]], rowCount: 1 };
|
|
164
165
|
}
|
|
165
166
|
if (kind.endpoint === "_mget") {
|
|
166
167
|
// 注:_mget 需兼容 v7 客户端的 body 形态(本文件为 v7/v8/v9 三客户端分发),
|
|
@@ -168,8 +169,11 @@ class ElasticsearchDialect extends SearchDialect {
|
|
|
168
169
|
const res = await es.mget({ index, body: { docs: [] } } as unknown as Parameters<ClientV8["mget"]>[0]);
|
|
169
170
|
return docsToRows(unwrap(res));
|
|
170
171
|
}
|
|
171
|
-
// _search:DSL 整体即 body
|
|
172
|
-
|
|
172
|
+
// _search:DSL 整体即 body;body 显式带数字 size 时不再注入顶层 size
|
|
173
|
+
// (尊重调用方分页与 size:0 纯聚合意图,否则顶层 size 会覆盖 body.size 使聚合退化为普通搜索)
|
|
174
|
+
const searchParams: Record<string, unknown> = { index, body };
|
|
175
|
+
if (typeof (body as { size?: unknown } | undefined)?.size !== "number") searchParams.size = opts.maxRows;
|
|
176
|
+
const res = await es.search(searchParams as Parameters<ClientV8["search"]>[0]);
|
|
173
177
|
return hitsToRows(unwrap(res));
|
|
174
178
|
}
|
|
175
179
|
throw new Error(`ES 写端点 ${kind.endpoint} 需走非只读确认流程执行,本方言 executeOn 仅执行读查询`);
|
|
@@ -235,12 +239,48 @@ class ElasticsearchDialect extends SearchDialect {
|
|
|
235
239
|
}
|
|
236
240
|
|
|
237
241
|
// ── 结果转换 ──────────────────────────────
|
|
238
|
-
|
|
239
|
-
|
|
242
|
+
interface EsHitsBody {
|
|
243
|
+
hits?: { total?: number | { value?: number }; hits?: Array<{ _id?: string; _source?: unknown }> };
|
|
244
|
+
aggregations?: Record<string, unknown>;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 搜索响应 → 行;纯聚合查询(无命中 + aggregations)展开为聚合行,命中存在时保持文档视图 */
|
|
248
|
+
export function hitsToRows(res: unknown): { columns: string[]; rows: unknown[][]; rowCount: number; truncated?: boolean } {
|
|
249
|
+
const body = res as EsHitsBody;
|
|
240
250
|
const hits = body.hits?.hits ?? [];
|
|
241
251
|
const total = typeof body.hits?.total === "number" ? body.hits.total : (body.hits?.total?.value ?? hits.length);
|
|
252
|
+
if (hits.length === 0 && body.aggregations !== undefined
|
|
253
|
+
&& body.aggregations !== null && typeof body.aggregations === "object") {
|
|
254
|
+
return aggsToRows(body.aggregations);
|
|
255
|
+
}
|
|
242
256
|
const rows = hits.map((h) => [h._id, typeof h._source === "object" ? JSON.stringify(h._source) : h._source]);
|
|
243
|
-
|
|
257
|
+
const rowCount = typeof total === "number" ? total : hits.length;
|
|
258
|
+
// total 为服务端统计(可能是 gte 下限),大于取回行数即已截断(供工具层提示与导出)
|
|
259
|
+
return { columns: ["_id", "_source"], rows, rowCount, truncated: rowCount > rows.length };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** 聚合结果 → 行:列 [aggregation, key, doc_count, value];metric 取值,桶内子聚合折叠为 value 列 JSON */
|
|
263
|
+
export function aggsToRows(aggs: Record<string, unknown>): { columns: string[]; rows: unknown[][]; rowCount: number } {
|
|
264
|
+
const rows: unknown[][] = [];
|
|
265
|
+
for (const [name, agg] of Object.entries(aggs)) {
|
|
266
|
+
if (agg === null || typeof agg !== "object") { rows.push([name, null, null, agg ?? null]); continue; }
|
|
267
|
+
const buckets = (agg as { buckets?: unknown }).buckets;
|
|
268
|
+
if (Array.isArray(buckets)) {
|
|
269
|
+
for (const b of buckets) {
|
|
270
|
+
if (b === null || typeof b !== "object") { rows.push([name, b, null, null]); continue; }
|
|
271
|
+
const bucket = b as Record<string, unknown>;
|
|
272
|
+
const key = bucket.key_as_string ?? bucket.key ?? null;
|
|
273
|
+
const docCount = typeof bucket.doc_count === "number" ? bucket.doc_count : null;
|
|
274
|
+
const extra = Object.entries(bucket).filter(([k]) => k !== "key" && k !== "key_as_string" && k !== "doc_count");
|
|
275
|
+
rows.push([name, key, docCount, extra.length > 0 ? JSON.stringify(Object.fromEntries(extra)) : null]);
|
|
276
|
+
}
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const metric = agg as { value?: unknown; value_as_string?: unknown };
|
|
280
|
+
if ("value" in metric) { rows.push([name, null, null, metric.value_as_string ?? metric.value ?? null]); continue; }
|
|
281
|
+
rows.push([name, null, null, JSON.stringify(agg)]);
|
|
282
|
+
}
|
|
283
|
+
return { columns: ["aggregation", "key", "doc_count", "value"], rows, rowCount: rows.length };
|
|
244
284
|
}
|
|
245
285
|
|
|
246
286
|
function docsToRows(res: unknown): { columns: string[]; rows: unknown[][]; rowCount: number } {
|
|
@@ -56,7 +56,10 @@ export abstract class RelationalDialect implements Dialect {
|
|
|
56
56
|
await this.withConnection(config, async (conn) => {
|
|
57
57
|
for (const s of stmts) last = await this.doExecute(conn.client, s, opts);
|
|
58
58
|
}, opts.timeoutSec * 1000);
|
|
59
|
-
|
|
59
|
+
// 结果集被 maxRows 截断时标注(各行返回 rowCount=服务端完整行数、rows 已按 maxRows 截取);
|
|
60
|
+
// 仅对结果集(有列)判定,写操作 rows 为空而 rowCount=影响行数,不得误标
|
|
61
|
+
const truncated = last.columns.length > 0 && last.rowCount > last.rows.length;
|
|
62
|
+
return { success: true, ...last, truncated, duration: `${Date.now() - start}ms` };
|
|
60
63
|
} catch (err: unknown) {
|
|
61
64
|
return { success: false, error: err instanceof Error ? err.message : String(err), duration: `${Date.now() - start}ms` };
|
|
62
65
|
}
|