@carllee1983/dbcli 4.0.0 → 6.0.0

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": "4.0.0",
3
+ "version": "6.0.0",
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": "4.0.0",
3
+ "version": "6.0.0",
4
4
  "description": "Database CLI skill and command reference for AI agents",
5
5
  "author": {
6
6
  "name": "Carl Lee",
@@ -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": "4.0.0",
5
+ "version": "6.0.0",
6
6
  "author": {
7
7
  "name": "Carl Lee",
8
8
  "url": "https://github.com/CarlLee1983"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,84 @@ 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
+ ## [6.0.0] - 2026-09-01 - 一份黑名單設定,四個互不相同的比對器
9
+
10
+ 規格 SQL 第 7、8、9 則與 MongoDB 第 3–6 則。設計決策記在
11
+ `docs/adr/0018-a-blacklist-rule-that-does-not-match-fails-loudly.md` 與
12
+ `docs/adr/0019-one-blacklist-rule-one-matcher.md`。
13
+
14
+ SQL 那半:本機 MariaDB、表 `probe_users (id, Password, note)`、值 `s3cret`,八種設定裡七種洩漏,而全部都被設定載入器無聲接受——操作者「規則有效」的唯一證據是 dbcli 沒有抱怨。
15
+
16
+ MongoDB 那半(本機容器 MongoDB 7.0.31 實測):同一組規則被四個比對器讀,沒有兩個一致——請求側是字面的點號成分比對、讀取遮罩懂 `foo.*`、寫入側是祖先走訪、集合名是 `Set.has`。於是 `user.*` 擋得住讀擋不住寫,`pass*` 到處都不生效卻仍印出「可能已遮罩」的提示,`secrets*` 沒有任何一個比對器認得。規格第 3 則實測後不成立:十四個 update operator 全被 ADR-0015 的請求側檢查擋下,寫入側那個缺口是真的但被外層蓋住——仍然照深度防禦補齊。
17
+
18
+ ### Changed
19
+
20
+ - **BREAKING:欄位規則與回傳欄位名比對時,第一段摺成小寫。** 先前規則 `password` 對欄位 `Password` 完全不命中,而最嚴重的一則不是設定寫錯:規則大小寫**寫對了**,`SELECT Password AS PASSWORD` 照樣把值送回來,`query-only` 就做得到——遮罩比對的是回傳時的鍵名,而別名選了那個鍵名,與 ADR-0015 為 MongoDB `$project` 關掉的是同一個形狀,只是這裡經由大小寫抵達。只摺第一段:後面的段落是巢狀物件的鍵(JSON 欄位裡的 `profile.SSN`),不是 SQL 識別字。代價是 PostgreSQL 允許 `"Password"` 與 `"password"` 並存於同一張表,規則寫其中一個現在兩個都遮——過度拒絕,與 ADR-0014、0015 同一個方向。
21
+
22
+ - **BREAKING:以自己的表限定的欄位項改為載入失敗。** `{"users": ["users.password"]}` 從來沒有命中過任何東西。它不能被靜默改寫,因為欄位項裡的點號已經有第二個合法意思——`profile.ssn` 是巢狀路徑,正是第 8 則那個祖先走訪存在的理由。比對第一段是否等於它所在的表名鍵,是唯一不需要猜測就能分辨兩者的判準。**含有這種條目的設定會停止載入,直到改掉為止。**
23
+
24
+ - **寫入側改為與讀取側相同的祖先走訪。** `checkColumnBlacklistOnWrite` 是字面 `includes`,而 `filterColumnsForTables` 會沿點號向上走,於是規則 `profile` 之下 `profile.ssn` 能寫不能讀。沒有任何一種「被列入黑名單」的讀法能容許這件事。
25
+
26
+ - **BREAKING:`blacklist.tables` 的每個條目都是 glob,對所有引擎皆然。** 先前 `isTableBlacklisted` 是 `Set.has`,`tables: ["secrets*"]` 在 MongoDB 與 SQL 完全不擋,而使用者依 Redis 那側的文件正是那樣寫的(`blacklist-validator.ts` 裡 ES 的註解已經記下同一件事)。同一個鍵不該在 Redis 是 glob、在 ES 是 glob、在 SQL 是字面。代價是含 `*` 的設定在 SQL 連線上開始擋東西;方向只會多拒絕不會少拒絕,字面名稱仍匹配它自己。真的叫 `report*` 的表寫成 `report\*` 可回到字面比對。
27
+
28
+ - **BREAKING:`blacklist.columns` 的每一段都是 glob。** `pass*` 現在真的遮 `password`,先前它被 `compilePatterns` 拒絕,而被拒絕的結果是整份文件原樣回傳。萬用字元不跨點號:`pass*` 不匹配 `user.password`。唯一保留的特例是結尾整段的 `*`——`user.*` 仍然涵蓋 `user` 自己與它底下的一切,照純段落 glob 讀會變成只匹配 `user.<一段>`,那會讓已經部署的規則無聲縮小。
29
+
30
+ - **BREAKING:無法編譯的欄位規則改為中止操作。** 先前 `field-masker.ts` 看到 `patterns.length === 0` 就原樣回傳整份文件,而 CLI 照樣印「Some fields may have been redacted」——那句提示是操作者手上唯一的證據,而它是錯的。ADR-0019 Decision 3 與 ADR-0018 Decision 2 同一個理由。`a.*.b` 這種先前被拒的寫法現在合法,留下來會被拒的是真的壞掉的條目(空段落、空字串)。
31
+
32
+ - **請求側、讀取側、寫入側改用同一個比對器。** `reachesProtectedField` 與 `checkColumnBlacklistOnWrite` 現在走 `path-matcher`,所以帶萬用字元的規則在三個端點是同一個意思。仍然逐點號成分比對,不是子字串比對——`passwordless` 不會被 `password` 誤傷。
33
+
34
+ - **`insert` 傳扁平化的欄位路徑,不再是頂層鍵。** `insert --data '{"user":{"password":"x"}}'` 在規則 `user.password` 之下寫得進去(實測確認),因為 `Object.keys(data)` 只看得到 `user`。SQL 的扁平 data 扁平化後結果相同,行為不變。
35
+
36
+ - **`update` 的寫入欄位收集涵蓋所有 operator。** 先前只看 `$set` / `$unset`,`$rename`、`$inc`、`$push`、`$bit` 等十二個 operator 寫的欄位不進黑名單檢查。實測那些 operator 全被請求側擋下,所以這是補一個構不到的洞——一個只因為另一個控制夠嚴才擋得住的控制不算控制,與 ADR-0015 同一個理由。
37
+
38
+ ### Fixed
39
+
40
+ - **glob 的萬用字元涵蓋換行,與 Redis 一致。** `globToRegex` 把 `*` 譯成 `.*`,而 JavaScript 的 `.` 在沒有 dotAll 時不匹配 `\n`;Redis 的 `stringmatchlen` 逐位元組比對,`*` 吃任何位元組。於是 `secrets:*` 保護不到 `secrets:\nx`,而 `parseRedisCommand` 在引號內保留換行,這條路是可達的;同一組 regex 也驅動 `sampleKeyNames`,所以 `dbcli list` 也照樣顯示那個 key。`$` 不需要配套改動:JavaScript 把它錨在輸入結尾(不像 Perl 允許結尾換行),所以字面 pattern 仍然不匹配尾端多一個換行的 key——那也正是 Redis 的答案。同一個函式也用於 Elasticsearch 的 index 運算式,那裡 index 名不含換行,因此不受影響。
41
+
42
+ - **設定裡的前後空白與外層引號會被去掉。** `[" password "]`、`["\"password\""]`、``["`password`"]``、鍵 `" users "` 全部靜默無效。ES 那側的 `es-index-target.ts` 早就 trim 兼解引號,SQL 側沒有。
43
+
44
+ - **表規則同時以完整名稱與最後一段查找。** `{"public.users": …}` 對 `SELECT * FROM users` 不生效,反向也一樣——`extractTableReferences` 會保留限定名稱的完整字串,所以每個鍵只對它被寫成的那一種拼法生效。這是查找的改動而非解析時的猜測:沒有任何地方決定操作者指的是哪一種拼法,同一張表的兩種拼法解析到同一份規則。
45
+
46
+ - **`$rename` 的風險說明不再宣稱它不外洩。** `dml-plan.ts` 的 RENAME tier 訊息寫著 `field rename does not exfiltrate data`,那是錯的:改名之後受保護欄位的值躺在讀取遮罩不認得的名字底下。程式碼裡的斷言本身要當成待驗證的宣稱。
47
+
48
+ - **glob 比對改為線性時間,不再走 regex。** `globToRegex` 把每個 `*` 譯成 `.*`,而多個 `.*` 對**不匹配**的名稱會災難性回溯:`'a' + '*'.repeat(50) + 'b'` 比對一個 300 字元的字串,跑三分鐘沒有回來(2026-08-31 實測),改用 `globMatches` 之後同一則 0.23ms。每一次黑名單判定都走這條路——Redis key 規則、Elasticsearch index 運算式,而在本輪之後還加上所有引擎的欄位規則與表名。設定不必有惡意就踩得到(`*_*_*_*` 是很自然的寫法),而可達的輸入包含來自資料庫而非操作者的 Redis key 名稱。這個缺陷比本輪更早,本輪把它的影響面從兩條路徑擴大到全部,所以在這裡一起修掉。`globToRegex` 保留給真的需要 `RegExp` 物件的呼叫端,兩者以小字母表的**窮舉**比對釘住答案一致——那個窮舉當場就抓到第一版的一個真 bug:`*` 之後的跳脫字元沒有清掉尾綴萬用字元旗標,於是 `*\a` 會匹配 `ab`。
49
+
50
+ ## [5.1.0] - 2026-08-31 - 一次被黑名單擋下的查詢,紀錄裡指向的是沒被擋的那張表
51
+
52
+ `docs/specs/2026-08-30-cross-engine-blacklist-gaps.md` 的 audit 第 10 則。設計決策記在 `docs/adr/0017-the-audit-target-stays-wrong-and-the-record-stops-depending-on-it.md`。
53
+
54
+ ### Added
55
+
56
+ - **SQL 的稽核紀錄帶 `metadata.blacklist_checked`:黑名單拿這句語句比對過的每一個識別子。** 黑名單走 tokenizer(`extractTableReferences`),稽核的 `target` 走另一套單名推導(`extractTableName`),兩份解析給出兩個答案。實測:`SELECT * FROM a JOIN salaries s …` 的 `target` 只有 `a`;`CREATE TABLE dump AS SELECT * FROM salaries` 的 `target` 是被讀的 `salaries`,被建立的 `dump` 不在紀錄裡;`INSERT INTO staging SELECT * FROM salaries` 的 `target` 是 `staging`,被讀的 `salaries` 不見。最尖銳的一則規格沒寫:把 `salaries` 設進黑名單後跑那句 JOIN,拒絕是對的,而**那次拒絕的稽核列 `target` 是 `a`**——用 `target` 去查「有沒有人試圖碰受保護的表」查不到,真正的表名只活在 `error` 的自由文字裡。
57
+
58
+ - **這份清單原樣保存,不做過濾。** `extractTableReferences` 是刻意過度收集的:那句 JOIN 回傳 `["a","salaries","s","id","s.id","a.id"]`,那句 CTAS 回傳 `["dump","salaries","CREATE","TABLE"]`——別名、含點的欄位參照、SQL 關鍵字都在裡面。對黑名單而言這是對的,多一個識別子只會讓它多拒絕。規格原本提的 `metadata.tables` 因此會把 `CREATE` 當成表名寫進稽核;改為以欄位名說明實情,而不是再造一套與黑名單對不起來的解析。
59
+
60
+ ### Changed
61
+
62
+ - **`target` 維持原樣,刻意不改。** 它是下游拿來 filter 的欄位,而兩種候選修法(改成「被作用的那張表」、或改成 tokenizer 的首張)都會在沒有人被告知的情況下改變既有查詢的結果。這次修的是「有一張表完全不在紀錄裡」,把還在的那個欄位一起搬動並不會讓缺席修得更好。`target` 與 `blacklist_checked` 對某些語句會不一致,那是刻意的,不是待清理的 bug。
63
+
64
+ ## [5.0.0] - 2026-08-31 - shell 裡一句真的改了資料的 UPDATE,稽核裡沒有任何一列
65
+
66
+ `docs/specs/2026-08-30-cross-engine-blacklist-gaps.md` 的 audit 第 11 則自己註明是純讀碼結論、值得先端到端確認。確認了,結論成立,而且比讀碼看到的更嚴重。本機 MariaDB、`read-write` 連線、每次先清空 audit:`dbcli query "SELECT …"` 寫一列,而在 `dbcli>` 提示符打的同一句寫零列;一句帶 WHERE 的 `UPDATE` 走完全程、改掉了資料,同樣零列。設計決策記在 `docs/adr/0016-the-sql-shell-audits-every-statement-in-two-rows.md`。
67
+
68
+ ### Changed
69
+
70
+ - **BREAKING(對解析 audit 紀錄的下游而言):`metadata.es_shell_phase` 改名為 `metadata.shell_phase`。** 兩個 shell 現在共用同一個鍵。ADR-0016 Decision 2 的理由是讀 audit 的人不該需要先知道一列是哪個引擎產生的,而兩個鍵名正是那件事。任何在 4.0.0 上解析 `es_shell_phase` 的東西都會斷。
71
+
72
+ - **SQL shell 為每一句語句寫稽核列,讀取也不例外。** 先前只有 tier-two 的寫入閘門決策會進 audit(`createShellWriteGate` 對非 tier-two 直接 return,而 `repl-engine.ts` 自己沒有任何稽核呼叫),所以提示符打的 `SELECT` 與一句真的改了資料的 `UPDATE` 同樣不留痕跡。較窄的方案(只記寫入與拒絕)被否決:一旦「沒有紀錄」有第二種解釋——走了哪個入口——它對任何一個入口都不再有意義。
73
+
74
+ - **形狀與 Elasticsearch shell 相同:送出前 `attempt`、回來或拋出後 `outcome`。** 理由沿用 `EsShellAuditSink` 寫在型別註解裡的那句:只在回程寫的一列描述不了一個沒有回程的語句——被中斷的長 `UPDATE`、`SIGTERM`、client 端逾時而伺服器仍在跑。被拒絕的語句只寫 `outcome`,因為它從未被嘗試。三個拒絕出口(權限、黑名單、寫入閘門)各自補上,其中權限那個出口先前連 tier-two 的決策列都沒有——無 WHERE 的 `DELETE` 在 `read-write` 下由權限檢查擋下,發生在寫入閘門之前。
75
+
76
+ - **稽核輪替的筆數上限從 1000 提高到 10000。** 在上面兩項之下,一次認真的互動 session 就能自己跑到 1000 列,然後輪替會丟掉寫入、留下讀取——剛好把這份檔案的價值反轉。位元組上限不變,它管的是磁碟。這個值先前有五份寫死的副本(schema、schema 自己的 `.default`、v1→v2 遷移、`config.ts`、`init-shared.ts`),已收成單一常數 `DEFAULT_AUDIT_ROTATION`。
77
+
78
+ ### Fixed
79
+
80
+ - **shell 的權限拒絕訊息不再宣稱要求的等級是使用者已經持有的那個。** `repl-engine.ts` 把 `required` 算成 `classification.type === 'UNKNOWN' ? 'admin' : 'read-write'`——一個猜測,不是 `checkPermission` 實際判定的等級。於是 `read-write` 使用者刪整張表讀到 `Permission denied. Required: read-write (current: read-write)`,實際需要的是 `data-admin`。`minimumPermissionFor` 的註解記載這一類錯誤已在其他呼叫端修過,shell 的 SQL 這處是漏掉的一站。
81
+
82
+ ### Note
83
+
84
+ `audit.strict: true` 現在也涵蓋 shell 裡的讀取:送出前那一列寫不出去就拒絕執行。這與 `dbcli query` 和 Elasticsearch shell 既有的行為一致,但 shell 的讀取路徑先前沒有這個失敗模式。不需要這個的人維持 `strict` 關閉即可——那是預設,寫失敗只留一行警告。
85
+
8
86
  ## [4.0.0] - 2026-08-30 - Elasticsearch 的 shell 從來沒有問過 permission,同樣的形狀在 Redis 與 MongoDB 也成立,以及三個沒人比對的版本契約
9
87
 
10
88
  **建議所有把 Elasticsearch 連線交給 AI agent 操作的使用者升級。** `dbcli shell` 連到 Elasticsearch 時,完全沒有檢查連線設定的 permission 等級就把請求送到叢集:`shell.ts` 在到達 SQL 與 Redis 共用的那道閘門之前就分支到 `es-shell.ts`。因此 `permission: query-only` 的連線可以送出 `POST /<index>/_delete_by_query` 清空索引、`DELETE /<index>` 刪掉索引、`PUT /<index>/_mapping` 改寫 schema —— 同樣這些請求走 `dbcli query` 一律會被拒絕。這條路徑也不寫任何 audit 紀錄,所以受影響的人事後無從查證發生過什麼。