@carllee1983/dbcli 6.0.0 → 7.0.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbcli-agent",
3
- "version": "6.0.0",
3
+ "version": "7.0.1",
4
4
  "description": "Database CLI skill and command reference for AI agents.",
5
5
  "author": {
6
6
  "name": "Carl Lee",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbcli-agent",
3
- "version": "6.0.0",
3
+ "version": "7.0.1",
4
4
  "description": "Database CLI skill and command reference for AI agents",
5
5
  "author": {
6
6
  "name": "Carl Lee",
@@ -639,7 +639,7 @@ schema. Raw `query` / `export` invocations render a sortable table only.
639
639
  ## Notes
640
640
 
641
641
  - Query-only mode auto-appends `LIMIT 1000`; add `--no-limit` for `information_schema` or statements that break with `LIMIT`.
642
- - Blacklisted tables and columns are redacted from query output.
642
+ - Blacklisted tables and columns are redacted from query output. Every entry in `blacklist.tables` and `blacklist.columns` is a glob (`*`, `?`, `[a-z]`) on every engine, and a rule is compared against the whole dotted path case-insensitively — a rule spelled `password` also covers `Password`, and `profile.ssn` covers `profile.SSN`; a table literally named `report*` has to be written `report\*` to match literally again. `--fields` is unaffected and still matches exactly. A rule that cannot mean anything is rejected when the config loads rather than silently protecting nothing: a column entry qualified with its own table (`{"users": ["users.password"]}`) fails to load, and an unparsable rule makes every `dbcli es` request fail until it is fixed.
643
643
  - `schema` reports `estimatedRowCount` and `sizeCategory` (small / medium / large / huge). For large/huge tables add `WHERE` or `LIMIT` — bands in [reference.md](../skills/dbcli/reference.md#schema).
644
644
  - `doctor` on `mongodb+srv://` reports whether SRV resolves natively or through the DoH fallback — useful when the runtime restricts DNS.
645
645
  - **Global flags:** `--version`, `--config <path>`, `--global`, `--use <name>`, `--timeout <ms>`, `--statement-timeout <ms>`, `-v` / `--verbose` / `-vv`, `-q` / `--quiet`, `--no-color` (also honours `NO_COLOR`). Root-level flags must precede the command unless the command explicitly declares a command-level option.
@@ -1102,6 +1102,26 @@ dbcli blacklist column remove users.password
1102
1102
 
1103
1103
  **Subcommands:** `list`, `table add <name>`, `table remove <name>`, `column add <table.column>`, `column remove <table.column>`
1104
1104
 
1105
+ **Matching semantics (7.0.0):** every entry in `blacklist.tables` and `blacklist.columns` is a
1106
+ glob (`*`, `?`, `[abc]`, `[a-z]`) on **every** engine, not only Redis and Elasticsearch — `tables:
1107
+ ["secrets*"]` blocks the SQL table `secrets_2026` and the MongoDB collection `secrets_2026` alike.
1108
+ A table literally named `report*` has to be written `report\*` to match literally again. Rules and
1109
+ returned names are compared over the **whole dotted path, case-insensitively**, so `password` also
1110
+ covers `Password` and `profile.ssn` covers `profile.SSN`; folding happens at the comparison and the
1111
+ config keeps rules as written. The cost is deliberate over-rejection: where PostgreSQL holds both
1112
+ `"Password"` and `"password"`, a rule naming either redacts both. `--fields` is unaffected and still
1113
+ matches exactly — it names keys in the document in front of the operator, not a protection rule.
1114
+
1115
+ Column-level entries are a **display filter, not an access control**: masking matches returned
1116
+ names, so `SELECT password_hash AS x` still returns the value. Table-level entries are the
1117
+ enforceable half.
1118
+
1119
+ A rule that cannot mean anything fails loudly instead of silently protecting nothing: a column entry
1120
+ qualified with its own table (`{"users": ["users.password"]}`) fails to load, and a rule the
1121
+ Elasticsearch shell cannot parse (`pass[word`, `a.**`) makes every `dbcli es` request fail until the
1122
+ config is fixed. Entries are trimmed and unquoted, and a rule filed under `public.users` applies to
1123
+ `SELECT * FROM users` and the reverse.
1124
+
1105
1125
  **`list` options:** `--config <path>`, `--format <text|json>` (default: `text`). JSON writes one
1106
1126
  document to stdout: `{ "tables": string[], "columns": Record<string, string[]>, "warnings":
1107
1127
  [{ "collection", "raw", "reason" }] }`. Invalid MongoDB blacklist patterns appear in `warnings`;
@@ -3547,6 +3567,12 @@ dbcli query "KEYS *" # → returns only non-blacklisted key
3547
3567
 
3548
3568
  Rejections are written to the audit log with `success: false` and `metadata.rejection_reason: 'blacklist'` + `matched_pattern`.
3549
3569
 
3570
+ Enforcement covers `q` and `report` as well (7.0.0). Before that, saved queries and the built-in
3571
+ `@diag/redis-key-stats` diagnostic ran on an adapter that carried the connection but none of its
3572
+ rules, so `dbcli q @<name>` could read a protected key in plaintext and a report could persist
3573
+ protected key names. Both now resolve their key targets before execution, apply the connection's
3574
+ blacklist and `redis.mask` rules, and drop protected key names from `SCAN` evidence.
3575
+
3550
3576
  ### Value / hash-field masking (v1.22)
3551
3577
 
3552
3578
  Where the key-glob blacklist *rejects*, masking instead *redacts*: a matched read still
@@ -2,7 +2,7 @@
2
2
  "name": "dbcli-agent",
3
3
  "displayName": "dbcli Agent",
4
4
  "description": "Database CLI skill and command reference for AI agents.",
5
- "version": "6.0.0",
5
+ "version": "7.0.1",
6
6
  "author": {
7
7
  "name": "Carl Lee",
8
8
  "url": "https://github.com/CarlLee1983"
@@ -639,7 +639,7 @@ schema. Raw `query` / `export` invocations render a sortable table only.
639
639
  ## Notes
640
640
 
641
641
  - Query-only mode auto-appends `LIMIT 1000`; add `--no-limit` for `information_schema` or statements that break with `LIMIT`.
642
- - Blacklisted tables and columns are redacted from query output.
642
+ - Blacklisted tables and columns are redacted from query output. Every entry in `blacklist.tables` and `blacklist.columns` is a glob (`*`, `?`, `[a-z]`) on every engine, and a rule is compared against the whole dotted path case-insensitively — a rule spelled `password` also covers `Password`, and `profile.ssn` covers `profile.SSN`; a table literally named `report*` has to be written `report\*` to match literally again. `--fields` is unaffected and still matches exactly. A rule that cannot mean anything is rejected when the config loads rather than silently protecting nothing: a column entry qualified with its own table (`{"users": ["users.password"]}`) fails to load, and an unparsable rule makes every `dbcli es` request fail until it is fixed.
643
643
  - `schema` reports `estimatedRowCount` and `sizeCategory` (small / medium / large / huge). For large/huge tables add `WHERE` or `LIMIT` — bands in [reference.md](reference.md#schema).
644
644
  - `doctor` on `mongodb+srv://` reports whether SRV resolves natively or through the DoH fallback — useful when the runtime restricts DNS.
645
645
  - **Global flags:** `--version`, `--config <path>`, `--global`, `--use <name>`, `--timeout <ms>`, `--statement-timeout <ms>`, `-v` / `--verbose` / `-vv`, `-q` / `--quiet`, `--no-color` (also honours `NO_COLOR`). Root-level flags must precede the command unless the command explicitly declares a command-level option.
@@ -1102,6 +1102,26 @@ dbcli blacklist column remove users.password
1102
1102
 
1103
1103
  **Subcommands:** `list`, `table add <name>`, `table remove <name>`, `column add <table.column>`, `column remove <table.column>`
1104
1104
 
1105
+ **Matching semantics (7.0.0):** every entry in `blacklist.tables` and `blacklist.columns` is a
1106
+ glob (`*`, `?`, `[abc]`, `[a-z]`) on **every** engine, not only Redis and Elasticsearch — `tables:
1107
+ ["secrets*"]` blocks the SQL table `secrets_2026` and the MongoDB collection `secrets_2026` alike.
1108
+ A table literally named `report*` has to be written `report\*` to match literally again. Rules and
1109
+ returned names are compared over the **whole dotted path, case-insensitively**, so `password` also
1110
+ covers `Password` and `profile.ssn` covers `profile.SSN`; folding happens at the comparison and the
1111
+ config keeps rules as written. The cost is deliberate over-rejection: where PostgreSQL holds both
1112
+ `"Password"` and `"password"`, a rule naming either redacts both. `--fields` is unaffected and still
1113
+ matches exactly — it names keys in the document in front of the operator, not a protection rule.
1114
+
1115
+ Column-level entries are a **display filter, not an access control**: masking matches returned
1116
+ names, so `SELECT password_hash AS x` still returns the value. Table-level entries are the
1117
+ enforceable half.
1118
+
1119
+ A rule that cannot mean anything fails loudly instead of silently protecting nothing: a column entry
1120
+ qualified with its own table (`{"users": ["users.password"]}`) fails to load, and a rule the
1121
+ Elasticsearch shell cannot parse (`pass[word`, `a.**`) makes every `dbcli es` request fail until the
1122
+ config is fixed. Entries are trimmed and unquoted, and a rule filed under `public.users` applies to
1123
+ `SELECT * FROM users` and the reverse.
1124
+
1105
1125
  **`list` options:** `--config <path>`, `--format <text|json>` (default: `text`). JSON writes one
1106
1126
  document to stdout: `{ "tables": string[], "columns": Record<string, string[]>, "warnings":
1107
1127
  [{ "collection", "raw", "reason" }] }`. Invalid MongoDB blacklist patterns appear in `warnings`;
@@ -3547,6 +3567,12 @@ dbcli query "KEYS *" # → returns only non-blacklisted key
3547
3567
 
3548
3568
  Rejections are written to the audit log with `success: false` and `metadata.rejection_reason: 'blacklist'` + `matched_pattern`.
3549
3569
 
3570
+ Enforcement covers `q` and `report` as well (7.0.0). Before that, saved queries and the built-in
3571
+ `@diag/redis-key-stats` diagnostic ran on an adapter that carried the connection but none of its
3572
+ rules, so `dbcli q @<name>` could read a protected key in plaintext and a report could persist
3573
+ protected key names. Both now resolve their key targets before execution, apply the connection's
3574
+ blacklist and `redis.mask` rules, and drop protected key names from `SCAN` evidence.
3575
+
3550
3576
  ### Value / hash-field masking (v1.22)
3551
3577
 
3552
3578
  Where the key-glob blacklist *rejects*, masking instead *redacts*: a matched read still
package/CHANGELOG.md CHANGED
@@ -5,6 +5,247 @@ All notable changes to dbcli are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [7.0.1] - 2026-09-02 - agent 讀到的黑名單語意還停在 4.0.0
9
+
10
+ ### Documentation
11
+
12
+ - **skill 的黑名單語意跟上 6.0.0 與 7.0.0。** `skill:check` 與 `plugin:check` 只驗結構、
13
+ 清單項與 code token 對齊,不驗內容是否跟得上行為,於是 `assets/SKILL.md`、
14
+ `assets/SKILL.zh-TW.md` 與 `assets/reference.md` 在這兩版之間一個字都沒動——而這兩版改的
15
+ 正是 agent 每次操作第一步要讀的東西。`reference.md` 的 `blacklist` 小節先前只列四個子指令,
16
+ 現在寫明每個條目在所有引擎都是 glob(真的叫 `report*` 的表要寫成 `report\*`)、規則與回傳
17
+ 名稱整條點分路徑不分大小寫、`--fields` 不受影響仍是精確比對、欄位層級是顯示過濾而非存取
18
+ 控制,以及讀不出意思的規則會在載入時失敗而不是靜默不保護。Redis 的 Blacklist enforcement
19
+ 小節補上 `q` 與 `report` 現在也強制執行、`SCAN` evidence 會移除受保護的 key 名稱。
20
+ `SKILL.md` 的 Notes 兩個語系同步擴寫。十份下游副本由 `plugin:sync` 帶上。
21
+
22
+ ## [7.0.0] - 2026-09-02 - 一條規則擋得住寫、擋不住讀,差別只在大小寫
23
+
24
+ ADR-0019 在自己的 Consequences 裡寫下這一則:一份設定的大小寫折疊仍然是三套規則。
25
+ 這一版把它收成一套,並在收的過程中發現同一份設定還有一條完全繞過它的路——規則從來
26
+ 沒有抵達 `dbcli q` 與 `dbcli report` 用的那個 adapter。設計決策記在
27
+ `docs/adr/0020-one-fold-rule-for-every-blacklist-comparison.md` 與
28
+ `docs/adr/0021-connection-only-adapters-say-without-rules.md`。
29
+
30
+ 2026-09-01 直接呼叫四個比對器量到的起點——除了最後一列,每一列都是「寫入被拒、
31
+ 讀取原文回傳」的設定,而操作者確認規則有效的方式,通常就是看寫入被擋下來:
32
+
33
+ | 規則 | 欄位 | SQL 讀 | MongoDB 讀 | 請求側 | 寫入 |
34
+ | --- | --- | --- | --- | --- | --- |
35
+ | `Password` | `password` | masked | **returned** | **allowed** | refused |
36
+ | `password` | `PASSWORD` | masked | **returned** | **allowed** | refused |
37
+ | `PASS*` | `password` | masked | **returned** | **allowed** | refused |
38
+ | `profile.ssn` | `profile.SSN` | **returned** | **returned** | **allowed** | refused |
39
+ | `profile.ss*` | `profile.SS_num` | **returned** | **returned** | **allowed** | refused |
40
+ | `pass*` | `password` | masked | masked | refused | refused |
41
+
42
+ ### Security
43
+
44
+ - **Redis 的 `dbcli q` 與 `dbcli report` 不再繞過 blacklist。** 自 saved query
45
+ 功能加入後,這兩條路徑用的是只有 connection、沒有規則的 generic adapter,命令層
46
+ 同時對 Redis 產生空 target:`q @snippet` 讀得出受保護 key 的明文,內建的
47
+ `@diag/redis-key-stats` 會把受保護 key 名稱寫進持久報告。上面那張表量的是折疊,
48
+ 這一條連折疊都到不了——規則根本不在那個 adapter 上。generic factory 現在預設接
49
+ 完整 config,Redis target 重用既有的 command metadata;確實只測連線的呼叫端
50
+ (`init` 的設定測試、credential 輪替的密碼驗證)必須明寫
51
+ `createAdapterWithoutRules`。`inspect` 目前不列舉 Redis 物件,一併改走安全預設是
52
+ 為了之後加上時不會重開同一個洞。設計取捨見 ADR-0021。
53
+
54
+ - **開發相依的三筆勸告釘到範圍外。** `autoprefixer > browserslist`(兩筆 high)與
55
+ `tailwindcss > postcss-nested > postcss-selector-parser`(一筆 low)都只在
56
+ devDependencies 的傳遞相依裡,上游的版本範圍還沒放寬,改用既有的 `overrides` 釘住。
57
+ 不影響安裝 dbcli 的人拿到的相依樹。
58
+
59
+ ### Changed
60
+
61
+ - **BREAKING:規則與欄位名的比對整條路徑都不分大小寫。** 先前只有第一段折疊,
62
+ 而且只在 SQL 與 Elasticsearch 的讀取側折——MongoDB 的讀取遮罩與請求側完全不折,
63
+ 於是規則 `Password` 之下 `$project: {"leak": "$password"}` 被放行,明文原樣回傳。
64
+ 那正是 ADR-0018 Decision 1 要關掉的別名繞道,只是換了一個引擎抵達:欄位名的
65
+ 大小寫由請求方選定,設定端再怎麼驗證也擋不住,因為那條規則本身就是對的。
66
+ 代價與 ADR-0014、0015、0018 選的方向一致:PostgreSQL 允許 `"Password"` 與
67
+ `"password"` 並存、MongoDB 允許一份文件同時有 `profile.SSN` 與 `profile.ssn`,
68
+ 規則寫其中一個現在兩個都遮。過度拒絕可以用更精確的規則收回來,反過來——受保護的
69
+ 欄位因為請求換個大小寫就回傳——沒有任何東西會告訴你它發生過。
70
+
71
+ - **BREAKING:折疊涵蓋第一段之後的段落。** ADR-0018 刻意只折第一段,理由是後面的
72
+ 段落是巢狀物件的鍵、大小寫有意義。那個理由對資料是成立的,對系統不成立:寫入側
73
+ 本來就整條路徑小寫,所以「讀取側保持大小寫敏感」不是一個立場而是一個意外,而它
74
+ 在每一次讀取上都往 fail-open 的方向解決。PostgreSQL 16 的 `jsonb` 欄位實測,
75
+ 規則 `profile.ss_num` 與 `PROFILE.SS_num` 先前都原文回傳,現在都省略。
76
+
77
+ - **`--fields` 維持精確比對,不受這次改動影響。** 黑名單規則比對的是請求方選定
78
+ 大小寫的名稱;`--fields` 的路徑是操作者指名眼前這份文件的鍵,兩個只差大小寫的
79
+ 鍵是他們可能真的要分開處理的兩個欄位。
80
+
81
+ ### Fixed
82
+
83
+ - **一套折疊規則沒有走到巢狀下潛與規則挑選。** 折疊改成 `foldCase` 之後(`ς`→`σ`、
84
+ `İ`→`i`),剩下的裸 `toLowerCase` 就不再是同一套:規則 `profile.ΑΣ` 在巢狀下潛時
85
+ 回傳它指名的鍵、遮掉它沒指名的;設定在 `ασ` 底下的規則對 collection `ΑΣ` 查不到,
86
+ 而查不到的意思是「沒有規則」——明文原樣回傳;Elasticsearch 條目 `ΑΣ` 構得到 index
87
+ `ασ`、構不到它的 backing index `.ds-ασ-2026`,擋不住 backing index 就是擋不住讀取。
88
+ `dbcli check` 另有一個獨立的形狀:它從 `BlacklistManager` 的私有 state 撈 Set 自己
89
+ 比對,那份 Set 只有字面條目,萬用字元規則對這條路徑等於不存在(ADR-0019 Decision 4),
90
+ 現在問 `isTableBlacklisted`。`dbcli doctor` 的未保護欄位報告先前完全不折,規則換個
91
+ 大小寫寫就漏報。`blacklist-validator` 的 dedupe 與三個 `dml-plan`、
92
+ `query-risk-analyzer` 的顧問輸出不會放行(強制執行仍在 `BlacklistManager`),一併
93
+ 收攏——一套折疊規則就是一套。
94
+
95
+ - **折疊本身仍然是兩套規則,差別只在一個希臘字母。** `foldFieldPath` 折整串、
96
+ `globMatches` 折每個字元,而 `String.prototype.toLowerCase` 的 `Final_Sigma`
97
+ 是 Unicode 預設大小寫轉換裡唯一看上下文的規則:`Σ` 前面是字母、後面不是字母時
98
+ 折成 `ς`,其餘位置折成 `σ`。於是規則 `ΑΣ*` 對它自己指名的欄位 `ΑΣ_num` 回答
99
+ `false`,明文原樣回傳。ADR-0020 要的是一套折疊規則,這是它還沒真的成立的地方
100
+ ——寫入側從來不受影響,分歧只在讀取側往 fail-open 解決。
101
+
102
+ 折疊改由 `foldCase`(`src/utils/case-fold.ts`)一個函式回答,並把 `ς` 併回
103
+ `σ` 讓它不再看上下文;`globMatches` 的 `caseInsensitive` 改成整串折文字一次。
104
+ 折疊同時必須**保長**:`?` 與每個 `*`-free 區段都是固定寬度的窗口,而
105
+ `toLowerCase` 有唯一一個會變長的字元 `İ`(U+0130 → `i` + U+0307),所以它映成
106
+ `i`——那也是土耳其文給它的大小寫關係。掃過 U+0000–U+2FFFF 驗證:0 個碼位改變
107
+ 長度、0 處上下文分歧、冪等;三項都寫成測試留在
108
+ `tests/unit/core/contiguous-section-matcher.test.ts`。
109
+
110
+ 兩側折的**粒度**也要一樣:pattern 是逐碼元讀的,所以 astral 字元進到 token
111
+ 時是半個代理對,而 `foldCase` 對半個代理對是恆等函式——文字那一側卻真的把碼點
112
+ 映成了小寫。折疊之前兩側一起不折而碰巧相等;折了一側才露出來。規則 `𐐀` 於是
113
+ 比不上欄位 `𐐀`,連自己都命不中,涵蓋每一種有大小寫的 astral 文字(Adlam 在
114
+ 內),而 `maskMongoRows` 把所有欄位規則都送進這個比對器,所以純字面的 MongoDB
115
+ 規則同樣受影響。parser 改為逐碼點前進,token 仍是一個碼元。字元類則改回比對
116
+ **未折疊**的名稱——它的大小寫由 regex 自己的 `i` 旗標回答(Decision 2),折過
117
+ 再比在 BMP 上不多做任何事,卻會換掉 astral 字元的低位代理。
118
+
119
+ 兩個既有限制不變,也不是這一則造成的:`?` 吃一個碼元,所以它從來就吃不下一個
120
+ 完整的 astral 字元;字元類編譯時沒有 `u` 旗標,所以 astral 範圍是碼元的集合而
121
+ 不是碼點的集合。
122
+
123
+ `İ` 映成 `i` 有一項**不是**過度拒絕的代價,而且是被迫的:字面規則 `İ` 在 main
124
+ 上構得到拼成 `i` + U+0307 的欄位(`toLowerCase` 把兩者都送到那個序列),現在
125
+ 雙向都構不到。對照 main 量到的:
126
+
127
+ | 規則 | 欄位 | main | 這個分支 |
128
+ | --- | --- | --- | --- |
129
+ | `İ` | `i` + U+0307 | 受保護 | **原文回傳** |
130
+ | `i` + U+0307 | `İ` | 受保護 | **原文回傳** |
131
+ | `İ` | `i`、`I` | 原文回傳 | 受保護 |
132
+ | `İ` | `İ` | 受保護 | 受保護 |
133
+
134
+ 沒有第三個選項:保長要求 `foldCase(x).length === x.length`,而 `İ` 是一個碼元、
135
+ `i` + U+0307 是兩個,任何保長的折疊都不可能把兩者映成同一個字串。另一邊是上面
136
+ 那個 `?` 對不齊——它在 `isTableBlacklisted` 上對**每一條**用到 `?` 的規則都是
137
+ fail-open,不是一對字元——所以這個 trade 的方向和這份記錄其餘部分一致。黑名單
138
+ 在任何地方都不做 Unicode 正規化,兩種拼法就是兩個名字;需要兩種拼法的部署就
139
+ 寫兩條規則。失去的是字面那條路徑,不是 glob:規則 `İ*` 在 main 上構得到 `İd`,
140
+ 現在也構得到。
141
+
142
+ - **表格名稱是這份設定的第二套折疊。** `BlacklistManager` 用裸的 `.toLowerCase()`
143
+ 折表格名,欄位卻走 `foldFieldPath`,於是同一個 `isTableBlacklisted` 裡精確比對
144
+ 那一半與 `wildcardTables` 那一半折得不一樣——一條規則對 `Σ` 與 `İ` 有兩個答案,
145
+ 取決於它有沒有帶 metachar。兩半現在都是 `foldFieldPath`。這會把上面那則 `İ` 的
146
+ 代價一併帶到表格與 collection 名稱上,而那正是重點:一條路徑只因為沒拿到新的
147
+ 折疊而保住舊答案,那是意外不是保護。
148
+
149
+ 另有一則記錄而非修正:`globMatches('İ','i')` 為真而 `globMatches('[İ]','i')`
150
+ 為假——字元類的大小寫由 regex 的 `i` 旗標回答(Decision 2 不許改寫 pattern 的
151
+ 文字)。兩者都仍然命中 `İ` 自己,所以不是 fail-open,但那是同一個比對器裡的
152
+ 第二個答案。
153
+
154
+ 其餘代價與 ADR-0020 選的方向一致——一份文件同時有 `ς` 與 `σ` 結尾的兩個欄位、
155
+ 或同時有 `İd` 與 `id` 時,指名其一的規則兩個都遮。
156
+
157
+ - **連續區段比對是 O(depth³),而深度由回應決定不由設定決定。** `namesProtectedField`、
158
+ `redactFields` 與 MongoDB 的 `findProtectedFieldReference` 都對每個起點列舉每個
159
+ 終點、每個候選再 `slice().join('.')` 組成字串。一條規則的點分元件數是固定的,
160
+ 所以那些區段裡有 O(n²) 個從一開始就不可能命中。改為依規則寬度取窗口,並讓呼叫端
161
+ 把已經走過的元件陣列直接傳進去,而不是 `join` 完再 `split` 回來。三個呼叫端現在
162
+ 共用 `path-matcher.ts` 的 `reachesProtectedSegments`;規則集的折疊與編譯也共用
163
+ 一份依規則集記憶的結果,`findProtectedFieldReference` 先前每次呼叫都重做一遍。
164
+
165
+ 同一台機器、`git worktree` 建的 main 對照組、同一輪、五次取中位數:
166
+
167
+ | shape | main | 這個分支 |
168
+ | --- | --- | --- |
169
+ | `redactFields` 1000 hits x 20 fields | 79ms | 25ms |
170
+ | `redactFields` 5000 hits x 20 fields | 387ms | 119ms |
171
+ | `namesProtectedField` depth=5 x10000 | 34ms | 14ms |
172
+ | `namesProtectedField` depth=40 x10000 | 6175ms | 103ms |
173
+ | `findProtectedFieldReference` depth=40 x10000 | 6206ms | 90ms |
174
+
175
+ 深度 5→40 是 8 倍,成本先前是 182 倍,現在是 7.4 倍。
176
+
177
+ - **MongoDB 的請求檢查把帶 metachar 的條目同時放進字面集合。** 那正是 ADR-0020 的
178
+ falsification 段落對 ES shell 點名的形狀,只是在 `findProtectedFieldReference`
179
+ 上沒被檢查到:`back\slash` 靠字串相等命中自己,`Back\Slash` 什麼都命不中。
180
+ 兩條路徑現在共用同一個 `contiguousRulesFor`,含 metachar 的條目只當 pattern。
181
+ 淨結果是這類規則在 MongoDB 上不再保護任何東西:讀取遮罩(`field-masker.ts`)
182
+ 本來就只走 `compilePatterns`,所以請求端那層字面拒絕擋不住 `find()`,是一層看
183
+ 得見卻不成立的保護。要保護一個名字裡真的有 `[` 或 `\` 的欄位,規則要寫成
184
+ 跳脫形式(`col\[1\]`)。
185
+
186
+ - **帶萬用字元的點分規則碰不到巢狀鍵。** SQL 與 Elasticsearch 的讀取路徑上,
187
+ `profile.SS_num` 遮得掉 PostgreSQL `jsonb` 欄位裡的那個鍵,`profile.ss*` 原文回傳
188
+ ——字面規則會下潛巢狀記錄,萬用字元規則只比對頂層鍵名,而 `jsonb` 的巢狀鍵在被
189
+ 走訪之前不是任何地方的名字。MongoDB 的遮罩兩種都認得,所以這是同一個鍵在兩個引擎
190
+ 上有兩種意思。`profile.*` 先前擋得住是因為尾綴形式匹配頂層的 `profile` 自己,與
191
+ 巢狀無關。只有「點分且帶萬用字元」的規則、且結果裡真的有巢狀記錄時才會列舉路徑,
192
+ 深度以最長的規則為上限。`omittedColumns` 對這類規則回報的是規則原文而非鍵名——
193
+ 一條萬用字元規則可以在每一列命中不同的鍵,逐鍵回報會讓那份清單隨結果集成長。
194
+ `--fields` 的過濾改用已編譯的規則回答(`reachesOmitted`),所以
195
+ `--fields profile.SS_num` 在 `profile.ss*` 之下與字面規則一樣整個欄位消失,
196
+ 不再是留著欄位但值為 `null`。
197
+
198
+ - **MongoDB 的遮罩碰不到巢狀陣列裡的文件。** `{list: [[{ssn: …}]]}` 在規則 `list.ss*`
199
+ 之下原文回傳,因為 `maskValue` 只在陣列元素本身不是陣列時才遞迴。SQL 側遮得掉,
200
+ 所以這是同一份設定的兩個答案,只是漏的那一邊換了。陣列在任何深度都是容器,不是
201
+ 路徑的一段。
202
+
203
+ - **BREAKING:Elasticsearch shell 遇到讀不懂的欄位規則改為拒絕請求。** 先前
204
+ `pass[word`、`a.**` 這類條目在 ES shell 這條路上被靜默當成字面名稱,於是保護零個
205
+ 欄位;現在它們會讓每一個 `dbcli es` 請求失敗,直到設定改掉為止。同一則的另一半是
206
+ 反斜線:`back\slash` 先前在 ES shell 上遮的是回應鍵 `back\slash`(字面),現在
207
+ `\` 是跳脫字元,這條規則讀成 `backslash`——與其他引擎一致,但既有設定的意思變了。拒絕發生在收集
208
+ 規則的當下,早於送出——擋在回程等於 cluster 已經執行過那個請求。與
209
+ `compileGlobRules`、`maskMongoRows` 同一個理由(ADR-0019 Decision 3)。
210
+
211
+ - **Elasticsearch shell 是同一份設定的第五個比對器。** `namesProtectedField` 與
212
+ `redactFields` 只做精確字串比對,也完全不編譯 glob,於是 `columns: {users: ["Password"]}`
213
+ (或 `["pass*"]`、`["profile.ssn"]`)之下,`dbcli es` 把 `dbcli query --index` 遮掉的
214
+ 明文原樣送回來。兩者現在走同一個折疊函式與同一個 `compilePatterns` / `matchAny`;
215
+ 無法解析的規則改為在收集規則時就拒絕,而不是在回應的第一個鍵上——擋在回程等於
216
+ cluster 已經執行過那個請求了。
217
+
218
+ - **`blacklist.tables` 的 glob 掃描讀的是小寫化過的條目。** `tables: ["[A-z]ecrets"]`
219
+ 認不得 `_ecrets`:字元類別在儲存時被折小寫,`Z` 與 `a` 之間六個 ASCII 字元離開了
220
+ 集合。改為保留原樣條目建 glob 清單,折疊留在比對。同一型的第三處在
221
+ `matchesIndexGlob`,ES 的 index 運算式比對也是拿黑名單條目本身當 pattern。
222
+
223
+ - **含跳脫字元的規則在 ES shell 上曾因大小寫給出相反的答案。** 含 metachar 的條目
224
+ 同時留在字面集合裡,於是 `back\slash` 靠字串相等命中自己(原文剛好已是小寫),
225
+ 而 `Back\Slash` 兩邊都接不到——glob 語意把 `\S` 讀成字面 `S`,字面比對又比不過
226
+ 折疊後的名稱。含 metachar 的條目現在只當 pattern。
227
+
228
+ - **一條規則折到多個回傳欄位時,只有一個被列進 `omittedColumns`。** 結果同時有
229
+ `Password` 與 `password` 時兩欄都被遮,但通知只列一個,而呼叫端用精確名稱過濾
230
+ 表頭,於是另一欄以空白欄位回來,看起來像 NULL 而不是「被遮蔽」。那份通知是操作者
231
+ 判斷黑名單有沒有生效的唯一證據。
232
+
233
+ - **`isColumnBlacklisted` 完全不看萬用字元規則。** 它回答的是 `compactVisibleSchema`
234
+ 與 `dbcli schema` 給 agent 看的那份摘要,於是 `pass*` 之下摘要照列 `password`,而讀取
235
+ 遮罩會把它遮掉——同一條規則,兩個答案。現在走同一個 `compilePatterns` / `matchAny`。
236
+
237
+ - **ES shell 的規則不走設定載入器的正規化。** `'"Token"'` 在其他引擎上有效,在
238
+ `dbcli es` 上是死規則,因為這裡只做 `trim()`。改為共用 `normalizeBlacklistEntry`。
239
+
240
+ - **`isColumnBlacklisted` 折了被問的欄位名,沒折規則。** 規則 `Password` 對它自己
241
+ 指名的欄位回答 `false`——比對的兩側折得不一樣,正是 ADR-0018 記下的那個失敗形狀。
242
+ 這條路徑餵的是 `context` 的 schema 摘要。
243
+
244
+ - **glob 規則在比對時折疊,而不是把 pattern 的文字改小寫。** 把 `[A-z]` 小寫成
245
+ `[a-z]` 會讓它代表的字元集合悄悄變小(`Z` 與 `a` 之間那六個 ASCII 字元離開了
246
+ 字元類別),規則保護的東西會比它寫的少。`globMatches` 新增 `caseInsensitive`
247
+ 選項,在字元比對的地方折,pattern 的文字一個字都不動。
248
+
8
249
  ## [6.0.0] - 2026-09-01 - 一份黑名單設定,四個互不相同的比對器
9
250
 
10
251
  規格 SQL 第 7、8、9 則與 MongoDB 第 3–6 則。設計決策記在
package/assets/SKILL.md CHANGED
@@ -639,7 +639,7 @@ schema. Raw `query` / `export` invocations render a sortable table only.
639
639
  ## Notes
640
640
 
641
641
  - Query-only mode auto-appends `LIMIT 1000`; add `--no-limit` for `information_schema` or statements that break with `LIMIT`.
642
- - Blacklisted tables and columns are redacted from query output.
642
+ - Blacklisted tables and columns are redacted from query output. Every entry in `blacklist.tables` and `blacklist.columns` is a glob (`*`, `?`, `[a-z]`) on every engine, and a rule is compared against the whole dotted path case-insensitively — a rule spelled `password` also covers `Password`, and `profile.ssn` covers `profile.SSN`; a table literally named `report*` has to be written `report\*` to match literally again. `--fields` is unaffected and still matches exactly. A rule that cannot mean anything is rejected when the config loads rather than silently protecting nothing: a column entry qualified with its own table (`{"users": ["users.password"]}`) fails to load, and an unparsable rule makes every `dbcli es` request fail until it is fixed.
643
643
  - `schema` reports `estimatedRowCount` and `sizeCategory` (small / medium / large / huge). For large/huge tables add `WHERE` or `LIMIT` — bands in [reference.md](reference.md#schema).
644
644
  - `doctor` on `mongodb+srv://` reports whether SRV resolves natively or through the DoH fallback — useful when the runtime restricts DNS.
645
645
  - **Global flags:** `--version`, `--config <path>`, `--global`, `--use <name>`, `--timeout <ms>`, `--statement-timeout <ms>`, `-v` / `--verbose` / `-vv`, `-q` / `--quiet`, `--no-color` (also honours `NO_COLOR`). Root-level flags must precede the command unless the command explicitly declares a command-level option.
@@ -500,7 +500,7 @@ dbcli export "SELECT * FROM orders" --format html --output orders.html
500
500
  ## 備註
501
501
 
502
502
  - Query-only 模式自動補 `LIMIT 1000`;查 `information_schema` 或會被 `LIMIT` 破壞的語句請加 `--no-limit`。
503
- - 被 blacklist 的 table / column 會從查詢輸出中遮蔽。
503
+ - 被 blacklist 的 table / column 會從查詢輸出中遮蔽。`blacklist.tables` 與 `blacklist.columns` 的每個條目在所有引擎上都是 glob(`*`、`?`、`[a-z]`),規則與名稱比對時整條點分路徑都不分大小寫——寫成 `password` 的規則同時涵蓋 `Password`,`profile.ssn` 也涵蓋 `profile.SSN`;真的叫 `report*` 的表要寫成 `report\*` 才能回到字面比對。`--fields` 不受影響,維持精確比對。讀不出意思的規則會在設定載入時就被拒絕,而不是靜默地什麼都不保護:以自己的表限定的欄位項(`{"users": ["users.password"]}`)會載入失敗,無法解析的規則則會讓每一個 `dbcli es` 請求失敗,直到改掉為止。
504
504
  - `schema` 回報 `estimatedRowCount` 與 `sizeCategory`(small / medium / large / huge)。大 / 巨大表要加 `WHERE` 或 `LIMIT` — 分界值見 [reference.md](reference.md#schema)。
505
505
  - 對 `mongodb+srv://` 連線,`doctor` 會回報 SRV 是用原生解析或走 DoH fallback — 在執行環境限制 DNS 時很有用。
506
506
  - **全域旗標:** `--version`、`--config <path>`、`--global`、`--use <name>`、`--timeout <ms>`、`--statement-timeout <ms>`、`-v` / `--verbose` / `-vv`、`-q` / `--quiet`、`--no-color`(也尊重 `NO_COLOR`)。除非指令明確宣告 command-level 選項,否則 root-level 旗標必須放在指令之前。
@@ -1102,6 +1102,26 @@ dbcli blacklist column remove users.password
1102
1102
 
1103
1103
  **Subcommands:** `list`, `table add <name>`, `table remove <name>`, `column add <table.column>`, `column remove <table.column>`
1104
1104
 
1105
+ **Matching semantics (7.0.0):** every entry in `blacklist.tables` and `blacklist.columns` is a
1106
+ glob (`*`, `?`, `[abc]`, `[a-z]`) on **every** engine, not only Redis and Elasticsearch — `tables:
1107
+ ["secrets*"]` blocks the SQL table `secrets_2026` and the MongoDB collection `secrets_2026` alike.
1108
+ A table literally named `report*` has to be written `report\*` to match literally again. Rules and
1109
+ returned names are compared over the **whole dotted path, case-insensitively**, so `password` also
1110
+ covers `Password` and `profile.ssn` covers `profile.SSN`; folding happens at the comparison and the
1111
+ config keeps rules as written. The cost is deliberate over-rejection: where PostgreSQL holds both
1112
+ `"Password"` and `"password"`, a rule naming either redacts both. `--fields` is unaffected and still
1113
+ matches exactly — it names keys in the document in front of the operator, not a protection rule.
1114
+
1115
+ Column-level entries are a **display filter, not an access control**: masking matches returned
1116
+ names, so `SELECT password_hash AS x` still returns the value. Table-level entries are the
1117
+ enforceable half.
1118
+
1119
+ A rule that cannot mean anything fails loudly instead of silently protecting nothing: a column entry
1120
+ qualified with its own table (`{"users": ["users.password"]}`) fails to load, and a rule the
1121
+ Elasticsearch shell cannot parse (`pass[word`, `a.**`) makes every `dbcli es` request fail until the
1122
+ config is fixed. Entries are trimmed and unquoted, and a rule filed under `public.users` applies to
1123
+ `SELECT * FROM users` and the reverse.
1124
+
1105
1125
  **`list` options:** `--config <path>`, `--format <text|json>` (default: `text`). JSON writes one
1106
1126
  document to stdout: `{ "tables": string[], "columns": Record<string, string[]>, "warnings":
1107
1127
  [{ "collection", "raw", "reason" }] }`. Invalid MongoDB blacklist patterns appear in `warnings`;
@@ -3547,6 +3567,12 @@ dbcli query "KEYS *" # → returns only non-blacklisted key
3547
3567
 
3548
3568
  Rejections are written to the audit log with `success: false` and `metadata.rejection_reason: 'blacklist'` + `matched_pattern`.
3549
3569
 
3570
+ Enforcement covers `q` and `report` as well (7.0.0). Before that, saved queries and the built-in
3571
+ `@diag/redis-key-stats` diagnostic ran on an adapter that carried the connection but none of its
3572
+ rules, so `dbcli q @<name>` could read a protected key in plaintext and a report could persist
3573
+ protected key names. Both now resolve their key targets before execution, apply the connection's
3574
+ blacklist and `redis.mask` rules, and drop protected key names from `SCAN` evidence.
3575
+
3550
3576
  ### Value / hash-field masking (v1.22)
3551
3577
 
3552
3578
  Where the key-glob blacklist *rejects*, masking instead *redacts*: a matched read still