@carllee1983/dbcli 2.0.0 → 2.1.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.
- package/.cursor/rules/dbcli.mdc +8 -1
- package/.cursor/skills/dbcli/reference.md +107 -5
- package/.github/skills/dbcli/SKILL.md +8 -1
- package/.github/skills/dbcli/reference.md +107 -5
- package/CHANGELOG.md +46 -0
- package/assets/SKILL.md +8 -1
- package/assets/SKILL.zh-TW.md +7 -1
- package/assets/reference.md +107 -5
- package/dist/cli-runtime.mjs +553 -39
- package/dist/cli.mjs +2 -1
- package/dist/core.mjs +34 -2
- package/package.json +2 -1
- package/plugins/dbcli-agent/skills/dbcli/SKILL.md +8 -1
- package/plugins/dbcli-agent/skills/dbcli/reference.md +107 -5
- package/skills/dbcli/SKILL.md +8 -1
- package/skills/dbcli/reference.md +107 -5
package/.cursor/rules/dbcli.mdc
CHANGED
|
@@ -50,7 +50,14 @@ run unattended exactly as before; `--yes` skips the terminal prompt a human woul
|
|
|
50
50
|
answer a prompt** — `UPDATE` / `DELETE` with no `WHERE`, `DROP`, `TRUNCATE`, a statement
|
|
51
51
|
the SQL parser cannot read, several statements in one string, and `update` / `delete --where` matching on no primary key and
|
|
52
52
|
no unique index. The process exits `1` with `reason=no_where`, `reason=ddl_destruction`,
|
|
53
|
-
`reason=unparseable
|
|
53
|
+
`reason=unparseable`, `reason=multi_table`, `reason=nested_write` or
|
|
54
|
+
`reason=non_unique_where`, and **nothing
|
|
55
|
+
reaches the database**. In `dbcli shell`, a subcommand whose name is a SQL keyword needs a
|
|
56
|
+
`\` prefix (`\delete users --where id=1`) — a bare `delete …` is read as SQL. A write that joins a second table is always tier two: whether it is
|
|
57
|
+
limited to particular rows depends on the data, not on the statement. So is a statement
|
|
58
|
+
carrying a second write inside it — a data-modifying CTE
|
|
59
|
+
(`WITH x AS (DELETE FROM t RETURNING *) INSERT INTO …`) or a `MERGE` with a
|
|
60
|
+
`WHEN … THEN DELETE` / `THEN UPDATE` action.
|
|
54
61
|
**No flag bypasses this** — not `--yes`, not `--force`. To write every row on purpose, put
|
|
55
62
|
the intent in the SQL itself: add `WHERE 1=1` or a `LIMIT`. `DROP` / `TRUNCATE` have no
|
|
56
63
|
unattended route at all; escalate to a human.
|
|
@@ -346,12 +346,58 @@ says whether this particular statement may run right now.
|
|
|
346
346
|
| Tier | Statements | Interactive terminal | Non-interactive (or `--format json`) |
|
|
347
347
|
| :--- | :--- | :--- | :--- |
|
|
348
348
|
| One | `INSERT`, `UPDATE` / `DELETE` **with** a `WHERE` or `LIMIT`, `CREATE`, `ALTER` | Summary + `y/N`; `--yes` skips it | Runs, exactly as before |
|
|
349
|
-
| Two | `UPDATE` / `DELETE` with **no** `WHERE`, `DROP`, `TRUNCATE`, unparseable statements, several statements in one string | Type the target table name; **no flag skips it** | **Refused**: exit `1`, nothing sent to the database |
|
|
349
|
+
| Two | `UPDATE` / `DELETE` with **no** `WHERE`, `DROP`, `TRUNCATE`, unparseable statements, several statements in one string, a write nested inside another statement, a destructive `MERGE` | Type the target table name; **no flag skips it** | **Refused**: exit `1`, nothing sent to the database |
|
|
350
350
|
|
|
351
|
-
A refusal message names a machine-readable reason — `reason=no_where`,
|
|
352
|
-
`reason=ddl_destruction`, `reason=unparseable`, `reason=multiple_statements` — so a caller can tell it apart from a
|
|
351
|
+
A refusal message names a machine-readable reason — `reason=no_where`, `reason=multi_table`,
|
|
352
|
+
`reason=ddl_destruction`, `reason=unparseable`, `reason=multiple_statements`, `reason=nested_write` — so a caller can tell it apart from a
|
|
353
353
|
connection failure or a permission denial.
|
|
354
354
|
|
|
355
|
+
**A write that joins another table is tier two, whatever its `WHERE` says.** Whether such a
|
|
356
|
+
write is limited to particular rows is not a property of the statement:
|
|
357
|
+
`DELETE p FROM p JOIN o ON p.id = o.ref WHERE o.x > 0` deleted 2 of 5 rows against one
|
|
358
|
+
dataset and all 2000 against another, and a join's `ON` necessarily names the target, so it
|
|
359
|
+
proves nothing. Five rounds of adversarial measurement went into trying to read this off the
|
|
360
|
+
syntax before the rule became "a second table means tier two" (#80, #93). That refuses the
|
|
361
|
+
standard `UPDATE … FROM`, `UPDATE … JOIN … SET` and multi-table `DELETE` idioms for
|
|
362
|
+
unattended callers: rewrite the other table into a subquery in the `WHERE`, or run them
|
|
363
|
+
where someone can confirm.
|
|
364
|
+
|
|
365
|
+
**A statement is not the keyword it starts with.** PostgreSQL's data-modifying CTEs put an
|
|
366
|
+
arbitrary write in front of the statement the first keyword names, and a `MERGE` carries its
|
|
367
|
+
writes in a `WHEN … THEN` list — measured against a 2000-row table,
|
|
368
|
+
`WITH moved AS (DELETE FROM p RETURNING *) INSERT INTO archive …` and
|
|
369
|
+
`MERGE INTO p USING (SELECT 1 AS x) s ON true WHEN MATCHED THEN DELETE` each emptied it
|
|
370
|
+
while reading as an ordinary `INSERT` (#94, #95). So a write nested inside another statement
|
|
371
|
+
is tier two, `reason=nested_write`, and a `MERGE` is classified by its actions: a
|
|
372
|
+
`THEN DELETE` or `THEN UPDATE` is tier two, `reason=multi_table`, while an insert-only or
|
|
373
|
+
`DO NOTHING` one stays tier one. `ON CONFLICT … DO UPDATE`, `ON DUPLICATE KEY UPDATE` and
|
|
374
|
+
`INSERT … SELECT` are untouched by this — they are not nested writes, whatever they read
|
|
375
|
+
like. The cost is a CTE that deletes one row by primary key, and the ordinary `MERGE` upsert,
|
|
376
|
+
both refused for unattended callers.
|
|
377
|
+
|
|
378
|
+
**For a single-table write, the `WHERE` has to be about that table.** `UPDATE p SET c = 1
|
|
379
|
+
WHERE id = 1` is an ordinary write; `UPDATE p SET c = (SELECT max(x) FROM o WHERE o.id = 1)`
|
|
380
|
+
is not, because its only `WHERE` restricts the subquery and the write touches every row. A
|
|
381
|
+
*correlated* reference back to the target does count, so
|
|
382
|
+
`DELETE FROM t WHERE EXISTS (SELECT 1 FROM o WHERE o.tid = t.id)` is tier one while
|
|
383
|
+
`DELETE FROM t WHERE EXISTS (SELECT 1 FROM o WHERE o.id = 1)` deletes every row and is tier
|
|
384
|
+
two. A qualifier the subquery binds itself — including the target's own name, as in
|
|
385
|
+
`DELETE FROM sessions WHERE EXISTS (SELECT 1 FROM sessions WHERE …)` — is about the
|
|
386
|
+
subquery's rows, not the write.
|
|
387
|
+
|
|
388
|
+
This is a lower bound, not a proof: `WHERE id IS NOT NULL` names the target and still touches
|
|
389
|
+
every row, and no static check settles that. What it rules out is the class where nothing in
|
|
390
|
+
the condition is about the table being written.
|
|
391
|
+
|
|
392
|
+
**When the parser cannot read the statement**, there is no tree to judge and the same rule is
|
|
393
|
+
applied to the text: tier one only when the statement reads as a write to one named table
|
|
394
|
+
with a `WHERE` or `LIMIT` and contains no `SELECT`, `TABLE`, `VALUES`, `JOIN`, `USING`, a
|
|
395
|
+
statement-level `WITH`, or an `UPDATE … FROM`. A keyword inside parentheses is not at
|
|
396
|
+
statement level, so `SUBSTRING(x FROM 2)` and `AGAINST ('a' WITH QUERY EXPANSION)` do not
|
|
397
|
+
trip it. This is written as an allowlist because the denylist that preceded it was defeated
|
|
398
|
+
once per review round — `USING`, then `JOIN`, then a CTE, then a subquery, then `TABLE` as a
|
|
399
|
+
subquery.
|
|
400
|
+
|
|
355
401
|
**Escape routes.** For a statement that accepts a `WHERE`, put the intent in the SQL:
|
|
356
402
|
|
|
357
403
|
```bash
|
|
@@ -359,6 +405,7 @@ dbcli query "UPDATE users SET banned = 1" # refused, reason=n
|
|
|
359
405
|
dbcli query "UPDATE users SET banned = 1 WHERE 1=1" # runs — intent is explicit
|
|
360
406
|
dbcli query "DELETE FROM sessions LIMIT 1000" # runs — damage is bounded
|
|
361
407
|
dbcli query "UPDATE users SET banned = 1 WHERE id = 3" --yes # tier one, question skipped
|
|
408
|
+
dbcli query "UPDATE p SET x = 1 FROM o WHERE o.id = 1" # refused — the WHERE is about o, not p
|
|
362
409
|
```
|
|
363
410
|
|
|
364
411
|
This is deliberately not a flag. `WHERE 1=1` appended to a statement that already has a
|
|
@@ -369,7 +416,8 @@ whether they are possible at all, and the typed confirmation must come from a pe
|
|
|
369
416
|
|
|
370
417
|
Every tier-two evaluation is written to the audit log with
|
|
371
418
|
`metadata.write_gate_outcome` (`allowed` / `declined` / `refused`) and
|
|
372
|
-
`metadata.write_gate_reason`.
|
|
419
|
+
`metadata.write_gate_reason`. `dbcli audit write-gate` summarizes them — that is
|
|
420
|
+
the measurement ADR 0010 stakes this gate on.
|
|
373
421
|
|
|
374
422
|
> **Elasticsearch tiers by scope.** A read (`GET` / `HEAD`, plus `POST _search` and
|
|
375
423
|
> `POST _count`) is query-only. Indexing or updating a document is read-write.
|
|
@@ -1988,6 +2036,7 @@ Audit entries are metadata-only by design — never raw SQL bodies, `--param` va
|
|
|
1988
2036
|
| `audit show` | `readonly` | Print a single full entry by id prefix or `--recovery-ref`. |
|
|
1989
2037
|
| `audit clear` | `local-write` | Delete `<conn>.jsonl` + rotated `.jsonl.1` from local disk. Requires `--yes` or interactive confirm. |
|
|
1990
2038
|
| `audit health` | `readonly` | Render `AuditLogger.getHealth()` snapshot (writer state, lock state, rotation usage). |
|
|
2039
|
+
| `audit write-gate` | `readonly` | Summarize tier-two write-gate decisions: how often the gate was reached, by which reason, and how it was answered. |
|
|
1991
2040
|
|
|
1992
2041
|
#### `audit tail`
|
|
1993
2042
|
|
|
@@ -2048,6 +2097,32 @@ Examples:
|
|
|
2048
2097
|
|
|
2049
2098
|
Output reports: writer enabled/disabled, last write result, file-lock state, rotation cap usage (`max_bytes` / `max_entries`). When `audit.enabled = false` (D1 opt-out), `tail` / `show` / `health` still exit 0 and print `Audit is disabled (audit.enabled = false in .dbcli). Use 'dbcli audit health' for details.` (E note).
|
|
2050
2099
|
|
|
2100
|
+
#### `audit write-gate`
|
|
2101
|
+
|
|
2102
|
+
Answers the question ADR 0010 stakes the two-tier write gate on: is tier two stopping anything, or has everyone routed around it? The data was always written — `metadata.write_gate_tier` / `write_gate_outcome` / `write_gate_reason` on every evaluation — but reading it meant hand-writing jq over the JSONL.
|
|
2103
|
+
|
|
2104
|
+
| Flag | Purpose | Default |
|
|
2105
|
+
|---|---|---|
|
|
2106
|
+
| `--format <fmt>` | `table` \| `json`. | `table` |
|
|
2107
|
+
| `--all` | Merge every connection into one measurement. | off |
|
|
2108
|
+
| `--for-agent` | Shortcut for `--format json`. | off |
|
|
2109
|
+
|
|
2110
|
+
```bash
|
|
2111
|
+
dbcli audit write-gate
|
|
2112
|
+
dbcli audit write-gate --format json
|
|
2113
|
+
dbcli audit write-gate --all
|
|
2114
|
+
```
|
|
2115
|
+
|
|
2116
|
+
Reports, over the current connection's retained history (rotated `.jsonl.1` included):
|
|
2117
|
+
|
|
2118
|
+
- `total` — tier-two evaluations, with `range` giving the interval they span.
|
|
2119
|
+
- `outcomes` — `allowed` / `declined` / `refused`. The ratio between them is the line between "the gate is stopping things" and "everyone confirms through it".
|
|
2120
|
+
- `reasons` — which criterion sent the statement to tier two. Every known reason is listed, **including the ones that never fired**: a criterion that triggers zero times is the finding, not a row to omit.
|
|
2121
|
+
- `scanned` + `window` — the denominator. Two decisions in a week and two in a year are different findings.
|
|
2122
|
+
- `tierOne` — counted apart, with its own outcome breakdown, because tier one is recorded only when the operator declined.
|
|
2123
|
+
|
|
2124
|
+
When tier two was never reached, the summary says so and names the conclusion ADR 0010 draws from it (the criterion is wrong, not the gate unnecessary) rather than printing an empty table. An entirely empty audit log is reported as a separate case: it supports no conclusion either way.
|
|
2125
|
+
|
|
2051
2126
|
#### Boundaries
|
|
2052
2127
|
|
|
2053
2128
|
- Entries are append-only JSONL; rotation triggers at `~10 MB` or `~1000` entries (whichever first). Previous segment is preserved as `.jsonl.1`.
|
|
@@ -2537,11 +2612,38 @@ dbcli shell --sql # SQL-only mode
|
|
|
2537
2612
|
Inside the shell:
|
|
2538
2613
|
- Type SQL statements ending with `;` to execute
|
|
2539
2614
|
- Type dbcli commands without the `dbcli` prefix (e.g., `schema users`)
|
|
2615
|
+
- Prefix a subcommand with `\` to force it: `\delete users --where id=1`. Required for the
|
|
2616
|
+
subcommands whose names are also SQL keywords — `insert`, `update`, `delete`, `explain` —
|
|
2617
|
+
because a line starting with one of those is read as SQL,
|
|
2618
|
+
which is what a shell is for. The prefix works on every subcommand, so it is one rule
|
|
2619
|
+
rather than a list of exceptions (#88).
|
|
2620
|
+
- A subcommand runs in its own process with no stdin, so its ordinary y/n confirmation reads
|
|
2621
|
+
EOF and cancels. `\insert` / `\update` / `\delete` therefore need `--force` from inside
|
|
2622
|
+
the shell — or type the SQL at the prompt, where the confirmation can actually be
|
|
2623
|
+
answered. Tier two is unaffected either way: it is refused in the child and points you
|
|
2624
|
+
back to the prompt.
|
|
2540
2625
|
- Use Tab for auto-completion (SQL keywords, table names, column names)
|
|
2541
2626
|
- Type `.help` for meta commands (.quit, .clear, .format, .history, .timing)
|
|
2542
|
-
- Multi-line SQL: keeps accumulating until `;` is found
|
|
2627
|
+
- Multi-line SQL: keeps accumulating until `;` is found. Meta commands still work while a
|
|
2628
|
+
statement is accumulating — `.quit` quits, `.clear` abandons the buffer — and Ctrl-C
|
|
2629
|
+
cancels. A line that reads as SQL but names a subcommand and carries a double-dash option prints
|
|
2630
|
+
a note saying which prefix would have reached the subcommand.
|
|
2543
2631
|
- History persists across sessions (~/.dbcli_history)
|
|
2544
2632
|
|
|
2633
|
+
> **Tier two applies here (2.0.0)** — on SQL connections a typed `UPDATE` / `DELETE` with
|
|
2634
|
+
> no `WHERE`, a `DROP` or a `TRUNCATE` asks for the target table name before it runs.
|
|
2635
|
+
> Anything else typed cancels the statement and returns to the prompt; the session stays
|
|
2636
|
+
> open, and Ctrl-C withdraws the question itself — the next line is read as a statement,
|
|
2637
|
+
> not as the answer. Tier one (the y/N on ordinary writes) is deliberately not wired here — every line
|
|
2638
|
+
> is typed by a person. Piped input (`dbcli shell < script.sql`) has nobody to answer, so a
|
|
2639
|
+
> tier-two statement is refused and the remaining lines still run. Redis, MongoDB and
|
|
2640
|
+
> Elasticsearch shells are unaffected.
|
|
2641
|
+
>
|
|
2642
|
+
> dbcli **subcommands** typed in the shell (`query "..."`, `delete ...`) run as separate
|
|
2643
|
+
> processes with no stdin, so they cannot ask anything: a tier-two statement there is
|
|
2644
|
+
> refused with a message pointing you back to the `dbcli>` prompt, where the confirmation
|
|
2645
|
+
> can actually be typed.
|
|
2646
|
+
|
|
2545
2647
|
The REPL flavor depends on the active engine: SQL engines and MongoDB use the
|
|
2546
2648
|
form above; **Redis** opens a single-line command REPL (see [Redis › Interactive
|
|
2547
2649
|
shell](#interactive-shell)); **Elasticsearch** opens a Kibana Dev Tools-style
|
|
@@ -50,7 +50,14 @@ run unattended exactly as before; `--yes` skips the terminal prompt a human woul
|
|
|
50
50
|
answer a prompt** — `UPDATE` / `DELETE` with no `WHERE`, `DROP`, `TRUNCATE`, a statement
|
|
51
51
|
the SQL parser cannot read, several statements in one string, and `update` / `delete --where` matching on no primary key and
|
|
52
52
|
no unique index. The process exits `1` with `reason=no_where`, `reason=ddl_destruction`,
|
|
53
|
-
`reason=unparseable
|
|
53
|
+
`reason=unparseable`, `reason=multi_table`, `reason=nested_write` or
|
|
54
|
+
`reason=non_unique_where`, and **nothing
|
|
55
|
+
reaches the database**. In `dbcli shell`, a subcommand whose name is a SQL keyword needs a
|
|
56
|
+
`\` prefix (`\delete users --where id=1`) — a bare `delete …` is read as SQL. A write that joins a second table is always tier two: whether it is
|
|
57
|
+
limited to particular rows depends on the data, not on the statement. So is a statement
|
|
58
|
+
carrying a second write inside it — a data-modifying CTE
|
|
59
|
+
(`WITH x AS (DELETE FROM t RETURNING *) INSERT INTO …`) or a `MERGE` with a
|
|
60
|
+
`WHEN … THEN DELETE` / `THEN UPDATE` action.
|
|
54
61
|
**No flag bypasses this** — not `--yes`, not `--force`. To write every row on purpose, put
|
|
55
62
|
the intent in the SQL itself: add `WHERE 1=1` or a `LIMIT`. `DROP` / `TRUNCATE` have no
|
|
56
63
|
unattended route at all; escalate to a human.
|
|
@@ -346,12 +346,58 @@ says whether this particular statement may run right now.
|
|
|
346
346
|
| Tier | Statements | Interactive terminal | Non-interactive (or `--format json`) |
|
|
347
347
|
| :--- | :--- | :--- | :--- |
|
|
348
348
|
| One | `INSERT`, `UPDATE` / `DELETE` **with** a `WHERE` or `LIMIT`, `CREATE`, `ALTER` | Summary + `y/N`; `--yes` skips it | Runs, exactly as before |
|
|
349
|
-
| Two | `UPDATE` / `DELETE` with **no** `WHERE`, `DROP`, `TRUNCATE`, unparseable statements, several statements in one string | Type the target table name; **no flag skips it** | **Refused**: exit `1`, nothing sent to the database |
|
|
349
|
+
| Two | `UPDATE` / `DELETE` with **no** `WHERE`, `DROP`, `TRUNCATE`, unparseable statements, several statements in one string, a write nested inside another statement, a destructive `MERGE` | Type the target table name; **no flag skips it** | **Refused**: exit `1`, nothing sent to the database |
|
|
350
350
|
|
|
351
|
-
A refusal message names a machine-readable reason — `reason=no_where`,
|
|
352
|
-
`reason=ddl_destruction`, `reason=unparseable`, `reason=multiple_statements` — so a caller can tell it apart from a
|
|
351
|
+
A refusal message names a machine-readable reason — `reason=no_where`, `reason=multi_table`,
|
|
352
|
+
`reason=ddl_destruction`, `reason=unparseable`, `reason=multiple_statements`, `reason=nested_write` — so a caller can tell it apart from a
|
|
353
353
|
connection failure or a permission denial.
|
|
354
354
|
|
|
355
|
+
**A write that joins another table is tier two, whatever its `WHERE` says.** Whether such a
|
|
356
|
+
write is limited to particular rows is not a property of the statement:
|
|
357
|
+
`DELETE p FROM p JOIN o ON p.id = o.ref WHERE o.x > 0` deleted 2 of 5 rows against one
|
|
358
|
+
dataset and all 2000 against another, and a join's `ON` necessarily names the target, so it
|
|
359
|
+
proves nothing. Five rounds of adversarial measurement went into trying to read this off the
|
|
360
|
+
syntax before the rule became "a second table means tier two" (#80, #93). That refuses the
|
|
361
|
+
standard `UPDATE … FROM`, `UPDATE … JOIN … SET` and multi-table `DELETE` idioms for
|
|
362
|
+
unattended callers: rewrite the other table into a subquery in the `WHERE`, or run them
|
|
363
|
+
where someone can confirm.
|
|
364
|
+
|
|
365
|
+
**A statement is not the keyword it starts with.** PostgreSQL's data-modifying CTEs put an
|
|
366
|
+
arbitrary write in front of the statement the first keyword names, and a `MERGE` carries its
|
|
367
|
+
writes in a `WHEN … THEN` list — measured against a 2000-row table,
|
|
368
|
+
`WITH moved AS (DELETE FROM p RETURNING *) INSERT INTO archive …` and
|
|
369
|
+
`MERGE INTO p USING (SELECT 1 AS x) s ON true WHEN MATCHED THEN DELETE` each emptied it
|
|
370
|
+
while reading as an ordinary `INSERT` (#94, #95). So a write nested inside another statement
|
|
371
|
+
is tier two, `reason=nested_write`, and a `MERGE` is classified by its actions: a
|
|
372
|
+
`THEN DELETE` or `THEN UPDATE` is tier two, `reason=multi_table`, while an insert-only or
|
|
373
|
+
`DO NOTHING` one stays tier one. `ON CONFLICT … DO UPDATE`, `ON DUPLICATE KEY UPDATE` and
|
|
374
|
+
`INSERT … SELECT` are untouched by this — they are not nested writes, whatever they read
|
|
375
|
+
like. The cost is a CTE that deletes one row by primary key, and the ordinary `MERGE` upsert,
|
|
376
|
+
both refused for unattended callers.
|
|
377
|
+
|
|
378
|
+
**For a single-table write, the `WHERE` has to be about that table.** `UPDATE p SET c = 1
|
|
379
|
+
WHERE id = 1` is an ordinary write; `UPDATE p SET c = (SELECT max(x) FROM o WHERE o.id = 1)`
|
|
380
|
+
is not, because its only `WHERE` restricts the subquery and the write touches every row. A
|
|
381
|
+
*correlated* reference back to the target does count, so
|
|
382
|
+
`DELETE FROM t WHERE EXISTS (SELECT 1 FROM o WHERE o.tid = t.id)` is tier one while
|
|
383
|
+
`DELETE FROM t WHERE EXISTS (SELECT 1 FROM o WHERE o.id = 1)` deletes every row and is tier
|
|
384
|
+
two. A qualifier the subquery binds itself — including the target's own name, as in
|
|
385
|
+
`DELETE FROM sessions WHERE EXISTS (SELECT 1 FROM sessions WHERE …)` — is about the
|
|
386
|
+
subquery's rows, not the write.
|
|
387
|
+
|
|
388
|
+
This is a lower bound, not a proof: `WHERE id IS NOT NULL` names the target and still touches
|
|
389
|
+
every row, and no static check settles that. What it rules out is the class where nothing in
|
|
390
|
+
the condition is about the table being written.
|
|
391
|
+
|
|
392
|
+
**When the parser cannot read the statement**, there is no tree to judge and the same rule is
|
|
393
|
+
applied to the text: tier one only when the statement reads as a write to one named table
|
|
394
|
+
with a `WHERE` or `LIMIT` and contains no `SELECT`, `TABLE`, `VALUES`, `JOIN`, `USING`, a
|
|
395
|
+
statement-level `WITH`, or an `UPDATE … FROM`. A keyword inside parentheses is not at
|
|
396
|
+
statement level, so `SUBSTRING(x FROM 2)` and `AGAINST ('a' WITH QUERY EXPANSION)` do not
|
|
397
|
+
trip it. This is written as an allowlist because the denylist that preceded it was defeated
|
|
398
|
+
once per review round — `USING`, then `JOIN`, then a CTE, then a subquery, then `TABLE` as a
|
|
399
|
+
subquery.
|
|
400
|
+
|
|
355
401
|
**Escape routes.** For a statement that accepts a `WHERE`, put the intent in the SQL:
|
|
356
402
|
|
|
357
403
|
```bash
|
|
@@ -359,6 +405,7 @@ dbcli query "UPDATE users SET banned = 1" # refused, reason=n
|
|
|
359
405
|
dbcli query "UPDATE users SET banned = 1 WHERE 1=1" # runs — intent is explicit
|
|
360
406
|
dbcli query "DELETE FROM sessions LIMIT 1000" # runs — damage is bounded
|
|
361
407
|
dbcli query "UPDATE users SET banned = 1 WHERE id = 3" --yes # tier one, question skipped
|
|
408
|
+
dbcli query "UPDATE p SET x = 1 FROM o WHERE o.id = 1" # refused — the WHERE is about o, not p
|
|
362
409
|
```
|
|
363
410
|
|
|
364
411
|
This is deliberately not a flag. `WHERE 1=1` appended to a statement that already has a
|
|
@@ -369,7 +416,8 @@ whether they are possible at all, and the typed confirmation must come from a pe
|
|
|
369
416
|
|
|
370
417
|
Every tier-two evaluation is written to the audit log with
|
|
371
418
|
`metadata.write_gate_outcome` (`allowed` / `declined` / `refused`) and
|
|
372
|
-
`metadata.write_gate_reason`.
|
|
419
|
+
`metadata.write_gate_reason`. `dbcli audit write-gate` summarizes them — that is
|
|
420
|
+
the measurement ADR 0010 stakes this gate on.
|
|
373
421
|
|
|
374
422
|
> **Elasticsearch tiers by scope.** A read (`GET` / `HEAD`, plus `POST _search` and
|
|
375
423
|
> `POST _count`) is query-only. Indexing or updating a document is read-write.
|
|
@@ -1988,6 +2036,7 @@ Audit entries are metadata-only by design — never raw SQL bodies, `--param` va
|
|
|
1988
2036
|
| `audit show` | `readonly` | Print a single full entry by id prefix or `--recovery-ref`. |
|
|
1989
2037
|
| `audit clear` | `local-write` | Delete `<conn>.jsonl` + rotated `.jsonl.1` from local disk. Requires `--yes` or interactive confirm. |
|
|
1990
2038
|
| `audit health` | `readonly` | Render `AuditLogger.getHealth()` snapshot (writer state, lock state, rotation usage). |
|
|
2039
|
+
| `audit write-gate` | `readonly` | Summarize tier-two write-gate decisions: how often the gate was reached, by which reason, and how it was answered. |
|
|
1991
2040
|
|
|
1992
2041
|
#### `audit tail`
|
|
1993
2042
|
|
|
@@ -2048,6 +2097,32 @@ Examples:
|
|
|
2048
2097
|
|
|
2049
2098
|
Output reports: writer enabled/disabled, last write result, file-lock state, rotation cap usage (`max_bytes` / `max_entries`). When `audit.enabled = false` (D1 opt-out), `tail` / `show` / `health` still exit 0 and print `Audit is disabled (audit.enabled = false in .dbcli). Use 'dbcli audit health' for details.` (E note).
|
|
2050
2099
|
|
|
2100
|
+
#### `audit write-gate`
|
|
2101
|
+
|
|
2102
|
+
Answers the question ADR 0010 stakes the two-tier write gate on: is tier two stopping anything, or has everyone routed around it? The data was always written — `metadata.write_gate_tier` / `write_gate_outcome` / `write_gate_reason` on every evaluation — but reading it meant hand-writing jq over the JSONL.
|
|
2103
|
+
|
|
2104
|
+
| Flag | Purpose | Default |
|
|
2105
|
+
|---|---|---|
|
|
2106
|
+
| `--format <fmt>` | `table` \| `json`. | `table` |
|
|
2107
|
+
| `--all` | Merge every connection into one measurement. | off |
|
|
2108
|
+
| `--for-agent` | Shortcut for `--format json`. | off |
|
|
2109
|
+
|
|
2110
|
+
```bash
|
|
2111
|
+
dbcli audit write-gate
|
|
2112
|
+
dbcli audit write-gate --format json
|
|
2113
|
+
dbcli audit write-gate --all
|
|
2114
|
+
```
|
|
2115
|
+
|
|
2116
|
+
Reports, over the current connection's retained history (rotated `.jsonl.1` included):
|
|
2117
|
+
|
|
2118
|
+
- `total` — tier-two evaluations, with `range` giving the interval they span.
|
|
2119
|
+
- `outcomes` — `allowed` / `declined` / `refused`. The ratio between them is the line between "the gate is stopping things" and "everyone confirms through it".
|
|
2120
|
+
- `reasons` — which criterion sent the statement to tier two. Every known reason is listed, **including the ones that never fired**: a criterion that triggers zero times is the finding, not a row to omit.
|
|
2121
|
+
- `scanned` + `window` — the denominator. Two decisions in a week and two in a year are different findings.
|
|
2122
|
+
- `tierOne` — counted apart, with its own outcome breakdown, because tier one is recorded only when the operator declined.
|
|
2123
|
+
|
|
2124
|
+
When tier two was never reached, the summary says so and names the conclusion ADR 0010 draws from it (the criterion is wrong, not the gate unnecessary) rather than printing an empty table. An entirely empty audit log is reported as a separate case: it supports no conclusion either way.
|
|
2125
|
+
|
|
2051
2126
|
#### Boundaries
|
|
2052
2127
|
|
|
2053
2128
|
- Entries are append-only JSONL; rotation triggers at `~10 MB` or `~1000` entries (whichever first). Previous segment is preserved as `.jsonl.1`.
|
|
@@ -2537,11 +2612,38 @@ dbcli shell --sql # SQL-only mode
|
|
|
2537
2612
|
Inside the shell:
|
|
2538
2613
|
- Type SQL statements ending with `;` to execute
|
|
2539
2614
|
- Type dbcli commands without the `dbcli` prefix (e.g., `schema users`)
|
|
2615
|
+
- Prefix a subcommand with `\` to force it: `\delete users --where id=1`. Required for the
|
|
2616
|
+
subcommands whose names are also SQL keywords — `insert`, `update`, `delete`, `explain` —
|
|
2617
|
+
because a line starting with one of those is read as SQL,
|
|
2618
|
+
which is what a shell is for. The prefix works on every subcommand, so it is one rule
|
|
2619
|
+
rather than a list of exceptions (#88).
|
|
2620
|
+
- A subcommand runs in its own process with no stdin, so its ordinary y/n confirmation reads
|
|
2621
|
+
EOF and cancels. `\insert` / `\update` / `\delete` therefore need `--force` from inside
|
|
2622
|
+
the shell — or type the SQL at the prompt, where the confirmation can actually be
|
|
2623
|
+
answered. Tier two is unaffected either way: it is refused in the child and points you
|
|
2624
|
+
back to the prompt.
|
|
2540
2625
|
- Use Tab for auto-completion (SQL keywords, table names, column names)
|
|
2541
2626
|
- Type `.help` for meta commands (.quit, .clear, .format, .history, .timing)
|
|
2542
|
-
- Multi-line SQL: keeps accumulating until `;` is found
|
|
2627
|
+
- Multi-line SQL: keeps accumulating until `;` is found. Meta commands still work while a
|
|
2628
|
+
statement is accumulating — `.quit` quits, `.clear` abandons the buffer — and Ctrl-C
|
|
2629
|
+
cancels. A line that reads as SQL but names a subcommand and carries a double-dash option prints
|
|
2630
|
+
a note saying which prefix would have reached the subcommand.
|
|
2543
2631
|
- History persists across sessions (~/.dbcli_history)
|
|
2544
2632
|
|
|
2633
|
+
> **Tier two applies here (2.0.0)** — on SQL connections a typed `UPDATE` / `DELETE` with
|
|
2634
|
+
> no `WHERE`, a `DROP` or a `TRUNCATE` asks for the target table name before it runs.
|
|
2635
|
+
> Anything else typed cancels the statement and returns to the prompt; the session stays
|
|
2636
|
+
> open, and Ctrl-C withdraws the question itself — the next line is read as a statement,
|
|
2637
|
+
> not as the answer. Tier one (the y/N on ordinary writes) is deliberately not wired here — every line
|
|
2638
|
+
> is typed by a person. Piped input (`dbcli shell < script.sql`) has nobody to answer, so a
|
|
2639
|
+
> tier-two statement is refused and the remaining lines still run. Redis, MongoDB and
|
|
2640
|
+
> Elasticsearch shells are unaffected.
|
|
2641
|
+
>
|
|
2642
|
+
> dbcli **subcommands** typed in the shell (`query "..."`, `delete ...`) run as separate
|
|
2643
|
+
> processes with no stdin, so they cannot ask anything: a tier-two statement there is
|
|
2644
|
+
> refused with a message pointing you back to the `dbcli>` prompt, where the confirmation
|
|
2645
|
+
> can actually be typed.
|
|
2646
|
+
|
|
2545
2647
|
The REPL flavor depends on the active engine: SQL engines and MongoDB use the
|
|
2546
2648
|
form above; **Redis** opens a single-line command REPL (see [Redis › Interactive
|
|
2547
2649
|
shell](#interactive-shell)); **Elasticsearch** opens a Kibana Dev Tools-style
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,52 @@ 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
|
+
## [2.1.0] - 2026-08-16 - The gate asked the wrong question, and one route never reached it
|
|
9
|
+
|
|
10
|
+
2.0.0 put a two-tier gate in front of raw SQL. This release is what measuring that gate against real servers found: one entire route into the database bypassed it, its qualification criterion asked whether a `WHERE` existed rather than whether it narrowed anything, and its notion of "what kind of statement is this" was the leading keyword — so a full-table delete wearing an `INSERT` or `WITH` in front of it was treated as routine. Each of the three is a way a statement that empties a table reached the database unattended, and each is closed here. The measurement the gate's own ADR bets on is now a command rather than a `jq` invocation somebody has to remember.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **The gate's qualification criterion now requires positive evidence that the `WHERE` narrows the table being written.** It was `statement.where != null`, which asks whether a `WHERE` exists; the question that matters is whether this `WHERE` restricts the target. `UPDATE p SET c = (SELECT … WHERE …)` has one and rewrites every row. A `WHERE` must now reference a column of the table being written — a correlated reference back to the target counts, unless the name is bound by the subquery itself, since `DELETE FROM sessions WHERE EXISTS (SELECT 1 FROM sessions WHERE …)` speaks about the subquery's own rows and was measured emptying the table. This is a lower bound, not a proof: `WHERE id IS NOT NULL` names the target and touches every row. `WHERE 1=1` remains the supported escape hatch (#80, #93).
|
|
15
|
+
|
|
16
|
+
- **A multi-table write is tier two regardless of its `WHERE`, under the new reason `multi_table`.** `DELETE p FROM p JOIN o ON p.id = o.ref WHERE o.x > 0` deleted 2 of 5 rows on one dataset and all 2000 on another — the same statement, a different answer, so scope is not decidable from the text. A join's `ON` always mentions the target table, so treating it as evidence would readmit the entire class 2.0.0 exists to stop, `UPDATE p SET … FROM o WHERE p.id = o.ref` included. Four criteria were tried and each was broken by the next ordinary statement, so the tier is now decided by what the statement *is* — which is known — rather than by what it will touch, which is not. The remedy in the refusal says "rewrite as a single-table statement or have somebody confirm it", not "add a `WHERE`" (#80).
|
|
17
|
+
|
|
18
|
+
- **When the parser cannot read a statement, tier one is now an allowlist rather than a denylist.** Enumerating the keywords that introduce a second table lost one round of adversarial review each: `USING`, `JOIN`, CTEs, subqueries, `TABLE` as a subquery. A statement now has to read as "writes one named table, has a `WHERE` or `LIMIT`" and contain no `SELECT` / `TABLE` / `VALUES` / `JOIN` / `USING`, no statement-level `WITH`, and no `UPDATE … FROM` to qualify. Keywords inside parentheses are not statement-level, so `SUBSTRING(x FROM 2)` and `AGAINST ('a' WITH QUERY EXPANSION)` are not misread (#80).
|
|
19
|
+
|
|
20
|
+
- **Statement type is decided by shape, not by the leading keyword, so a write hidden in front of one no longer buys tier-one treatment.** Measured on PostgreSQL 16 against a 2000-row table: `WITH moved AS (DELETE FROM p RETURNING *) INSERT INTO archive …` deleted 2000 rows and `MERGE INTO p … WHEN MATCHED THEN DELETE` deleted 2000 — both tier one, both skipped by `--yes`. The same CTE under a `CREATE TABLE … AS` head and `MERGE … WHEN MATCHED THEN UPDATE` behave the same way. The criterion is now a nested write anywhere inside parentheses, which holds because a parenthesised expression that is not the statement body cannot contain a write: `INSERT` / `UPDATE` / `DELETE` are reserved words in both dialects, so a column named that way must be quoted, and quoting is stripped before the read. `MERGE` is the exception, so only `MERGE INTO` is matched. Locking clauses (`SELECT … FOR UPDATE`) and foreign-key referential actions (`ON DELETE` / `ON UPDATE`) are stripped first — the latter after a review round found `ON DELETE CASCADE` blocking every table creation with a foreign key, while the same constraint added via `ALTER TABLE` was allowed, one meaning with two answers. A `MERGE` is classified by its `WHEN … THEN` actions: `THEN DELETE` / `THEN UPDATE` are tier-two `multi_table` because `MERGE` reads its rows from `USING`, while a pure-insert or `DO NOTHING` merge stays tier one, which is what keeps ordinary upserts working (#94, #95).
|
|
21
|
+
|
|
22
|
+
- **Every gate decision is audited as `db-write`, so filtering the audit log by tier no longer misses two thirds of them.** `side_effect_tier` was read from the command's capability table, so the same `DROP TABLE users` decision was recorded `readonly` when it arrived through `query`, `db-write` through `delete`, and `interactive` through `shell` — and tier is the first filter any audit consumer reaches for. `AuditOutcome` gained `sideEffectTier` so a caller that knows more than the capability table can state what the statement actually does; the type deliberately accepts only `db-write` / `local-write`, because this opening exists to be more accurate, not to downgrade a write. `--dry-run` / `--plan` still win, being an explicit execution mode rather than an outcome. The capability table is unchanged — `interactive` remains a correct description of the `shell` command itself (#83).
|
|
23
|
+
|
|
24
|
+
#### Automation affected
|
|
25
|
+
|
|
26
|
+
Three shapes that ran unattended before now exit `1`, all of them full-table writes that 2.0.0 intended to stop and mis-tiered:
|
|
27
|
+
|
|
28
|
+
| Invocation | Now | Remedy |
|
|
29
|
+
| :--- | :--- | :--- |
|
|
30
|
+
| `UPDATE p SET c = (SELECT … WHERE …)` — `WHERE` only inside a subquery | `reason=no_where` | add a `WHERE` on the target table, or `WHERE 1=1` |
|
|
31
|
+
| `DELETE p FROM p JOIN o …` / `UPDATE p SET … FROM o …` | `reason=multi_table` | rewrite as a single-table statement, or run it where a person can confirm |
|
|
32
|
+
| data-modifying CTEs and `MERGE … THEN DELETE/UPDATE` | `reason=multi_table` | as above; pure-insert `MERGE` upserts are unaffected |
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **`dbcli shell` now applies the tier-two gate, the last SQL route that bypassed it.** The REPL called the adapter directly with only permission and blacklist checks in the way, so `DELETE FROM users` demanded a typed table name under `dbcli query` and asked nothing at all one prompt over — protection that depended on which entrance the user picked, which is the thing #70 set out to remove. Only tier two is wired in: every line in a shell is hand-typed, so a per-statement `y/N` would become reflex, and tier one exists for batches of routine writes. A refusal prints to stderr and returns to the prompt with the session, connection and buffer intact; piped input (`dbcli shell < script.sql`) has nobody to answer, so that statement is refused and the rest of the file runs. Every tier-two evaluation is audited. Core still writes to neither stdout nor stderr (ADR 0009): `ReplEngine` takes a `ReplWriteGate` callback and `src/commands/shell.ts` supplies it, asking through the REPL's own readline interface — inquirer opens a second reader and was measured consuming each keypress twice. Line handling is queued, because readline emits every line of a paste before the first `await` returns (#78).
|
|
37
|
+
|
|
38
|
+
- **`dbcli audit write-gate` turns the measurement ADR 0010 bets on into one command.** The ADR does not justify tier two by argument; it bets that in six months the records will say whether it stopped anything. The data was being written all along (`write_gate_tier` / `write_gate_outcome` / `write_gate_reason`) but reading it meant hand-writing `jq` over `.dbcli/audit/*.jsonl` — a measurement nobody performs is a falsification condition that never fires. The summary answers three questions: how often tier two was reached, grouped by reason; how many were allowed, cancelled and refused; and over what span those numbers were measured. Zero is reported as a conclusion rather than an empty table — it is evidence the criterion is wrong, not that the gate was unnecessary — while an empty audit log is stated as yielding no conclusion at all. Reasons that never fired stay on the table showing `0`, since an omitted row can answer "how often" but not "at all". Tier one is counted separately with its outcome distribution rather than a hardcoded label. Counting is a pure core function; reading files and formatting stay in the command layer. A compile-time guard in `src/commands/audit.ts` fails in both directions if the reason or outcome list drifts from the gate's own (#79).
|
|
39
|
+
|
|
40
|
+
- **`\` calls a subcommand from inside `dbcli shell`, and SQL keywords win the name collision.** Typing `delete users --where status=active` was classified as SQL on its leading keyword, and with no semicolon it entered multiline mode silently — every subsequent line, `.quit` included, was swallowed into the buffer, leaving a shell that could only be killed from outside. A shell is for typing SQL, so `DELETE FROM users WHERE …` must stay typeable; subcommands moved to their own namespace instead. `\` is separate from the existing meta `.` — the latter is implemented by the shell, the former runs a dbcli subcommand in its own process — and it works for every subcommand, so what has to be remembered is a rule rather than a list of the four that actually collide (`insert`, `update`, `delete`, `explain`). The prefix is checked before the SQL rule, so a trailing `;` typed out of muscle memory does not turn it back into SQL. When a line is classified as SQL but starts with a subcommand name and carries a double-dash option, one line says which prefix would have reached the subcommand — unless the second word is a keyword like `FROM` / `SET` / `INTO`, since `--` also opens a SQL comment (#88).
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **Multiline mode no longer hijacks the shell, and Ctrl-C now cancels what it says it cancels.** While a statement is buffering, the shell's own meta commands are handled instead of being appended, and `.quit` / `.clear` also discard the buffer — but not inside an unterminated string literal, where the line is text rather than a command and lifting it out would split the statement; `MultilineBuffer` is the only place that knows the quoting state, so it answers `isInsideLiteral()`. Separately, pressing Ctrl-C at the gate's "type the table name" question invoked the SIGINT listener while readline's `question` stayed mounted, so the next line typed was consumed as the answer. Each question now gets an `AbortController`; SIGINT aborts it and resolves `null`, which prints "cancelled" rather than "input did not match X" — the latter describes an answer that was never given. The audit still records `declined`, because a decision was made. And cancelling a multiline statement only ever printed a message: `MultilineBuffer.reset()` existed with no caller in the repository, so the next statement was appended to the abandoned half and came back as a syntax error pointing at a keyword the user never typed (#85, #88).
|
|
45
|
+
|
|
46
|
+
- **A subcommand refused inside `dbcli shell` said something untrue, and before that it could not run at all.** `Bun.spawn` gives a child no stdin by default, so every subcommand was judged unattended: tier two was refused with "nobody can confirm this right now, run it in an interactive terminal", said to a person sitting at one. The child now carries `DBCLI_SHELL_SUBCOMMAND=1` and the refusal states the actual situation and offers a route that works — type the statement at the `dbcli>` prompt, where dbcli will ask for the table name. `code` and `reason` are untouched, since agents branch on those; only the prose for humans changed. Verifying it surfaced two defects further upstream that made the situation unreachable: `--config` was placed after the subcommand, but it is a program-level option that `query` / `insert` / `update` / `delete` do not declare, so every write subcommand ended in `unknown option`; and the tokenizer left matched quotes inside tokens, which go straight into `Bun.spawn`'s argv with no shell to strip them, so `query "DELETE FROM users"` arrived as one quoted identifier (#84).
|
|
47
|
+
|
|
48
|
+
- **`dbcli q` now enforces permission itself instead of relying on another module to do it.** `q.ts` never called `enforcePermission` and the adapter does not check either; nothing broke today only because `saved-queries/parser.ts` rejects snippets at load time that are not `SELECT` / `WITH`, contain write keywords, or hold multiple statements. A property held jointly by two modules, one of which does not know it is holding it, comes apart the first time somebody reasonably asks for snippets that write. This is defence in depth rather than a bug fix — under the current contract every executable snippet still passes. The check reads `prepared.rewrittenSql` rather than the driver SQL, which is a size-guard wrapper and would describe dbcli's wrapping instead of the requested statement, and it runs before the `--dry-run` branch, so a refusal is not worded differently for a statement that was not going to execute anyway. `checkTablesBlacklist` now receives the real statement type instead of a hardcoded `'SELECT'` (#81).
|
|
49
|
+
|
|
50
|
+
- **518 test files had never been type-checked, and now 519 are.** `tsconfig.json` matched them with `"tests/**/*.{ts,tsx}"`, and TypeScript's include globs support `*`, `?` and `**/` but do not expand braces — `tsc --listFiles | grep -c "/tests/"` returned `0`. Every type-level assertion written in a test file was decoration: a compile-time guard was found still passing after a union member was removed from under it. The pattern was replaced with `tsconfig.tests.json` and `typecheck:tests`, initially covering only the directories that were clean and ratcheting outward over five batches (339 errors across 174 files, now zero), and `typecheck:tests` is in CI and the release checklist. Roughly six in ten were unchecked indexed access, but the pass also found tests that were wrong: an import from a module that does not exist (Bun strips type-only imports without resolving them), `buildSchemaContext()` called with no argument, a `makeEntry` helper whose `...overrides` silently overwrote the two fields written above it, spies typed as bare `ReturnType<typeof spyOn>` and thereby erasing 13 parameters into `any`, mock adapters missing four methods `QueryableAdapter` requires, and `permission: 'read-only'` — not one of the four legal values — standing in for "insufficient permission" and reaching the right verdict for the wrong reason. `tests/helpers/test-config.ts` was producing a value that was not a valid `DbcliConfig` at all, its `Partial` overrides spread after the required fields turning them all optional (#93, #97).
|
|
51
|
+
|
|
52
|
+
- **The `inspect` leak check no longer fails at random.** It asserted `expect(stdout).not.toContain('5432')` over the whole serialized output; `--no-connect` emits no port field, but an audit entry's UUID ended in `5432` and reddened CI once. Three UUIDs of 32 hex characters each puts this at roughly one run in 700 — rare enough not to look like a real problem, frequent enough to burn a CI run and an investigation now and then. The check now walks the parsed structure: a value must *be* the port, a string must *contain* the host, and credential field names count wherever they appear (the fixture password is the single character `p`, so value matching cannot help there). The connection object's key set is pinned to `name` / `database` / `version`, which is stricter than before — any added field fails — where the old check exploded on the substring "host" appearing anywhere (#92).
|
|
53
|
+
|
|
8
54
|
## [2.0.0] - 2026-08-14 - A write nobody can confirm does not run
|
|
9
55
|
|
|
10
56
|
### Changed
|
package/assets/SKILL.md
CHANGED
|
@@ -50,7 +50,14 @@ run unattended exactly as before; `--yes` skips the terminal prompt a human woul
|
|
|
50
50
|
answer a prompt** — `UPDATE` / `DELETE` with no `WHERE`, `DROP`, `TRUNCATE`, a statement
|
|
51
51
|
the SQL parser cannot read, several statements in one string, and `update` / `delete --where` matching on no primary key and
|
|
52
52
|
no unique index. The process exits `1` with `reason=no_where`, `reason=ddl_destruction`,
|
|
53
|
-
`reason=unparseable
|
|
53
|
+
`reason=unparseable`, `reason=multi_table`, `reason=nested_write` or
|
|
54
|
+
`reason=non_unique_where`, and **nothing
|
|
55
|
+
reaches the database**. In `dbcli shell`, a subcommand whose name is a SQL keyword needs a
|
|
56
|
+
`\` prefix (`\delete users --where id=1`) — a bare `delete …` is read as SQL. A write that joins a second table is always tier two: whether it is
|
|
57
|
+
limited to particular rows depends on the data, not on the statement. So is a statement
|
|
58
|
+
carrying a second write inside it — a data-modifying CTE
|
|
59
|
+
(`WITH x AS (DELETE FROM t RETURNING *) INSERT INTO …`) or a `MERGE` with a
|
|
60
|
+
`WHEN … THEN DELETE` / `THEN UPDATE` action.
|
|
54
61
|
**No flag bypasses this** — not `--yes`, not `--force`. To write every row on purpose, put
|
|
55
62
|
the intent in the SQL itself: add `WHERE 1=1` or a `LIMIT`. `DROP` / `TRUNCATE` have no
|
|
56
63
|
unattended route at all; escalate to a human.
|
package/assets/SKILL.zh-TW.md
CHANGED
|
@@ -42,7 +42,13 @@ legacy 單檔 `.dbcli`。若要防護同一 OS 使用者的惡意 process,host
|
|
|
42
42
|
人能回答提問時會直接被拒絕** — 沒有 `WHERE` 的 `UPDATE` / `DELETE`、`DROP`、`TRUNCATE`、
|
|
43
43
|
SQL parser 讀不懂的語句、一個字串裡塞了多句語句,以及 `update` / `delete --where` 沒有命中主鍵或唯一索引的情況。
|
|
44
44
|
行程以 `1` 結束,訊息點名 `reason=no_where`、`reason=ddl_destruction`、
|
|
45
|
-
`reason=unparseable` 或
|
|
45
|
+
`reason=unparseable`、`reason=multi_table`、`reason=nested_write` 或
|
|
46
|
+
`reason=non_unique_where`,而且**什麼都不會
|
|
47
|
+
送到資料庫**。在 `dbcli shell` 裡,名稱與 SQL 關鍵字相同的子指令需要 `\` 前綴
|
|
48
|
+
(`\delete users --where id=1`)——直接打 `delete …` 會被當成 SQL。接了第二張表的寫入一律第二級:它會動到幾列取決於資料而不是語句。
|
|
49
|
+
裡面還夾著另一個寫入的語句也是——資料修改型 CTE
|
|
50
|
+
(`WITH x AS (DELETE FROM t RETURNING *) INSERT INTO …`)與帶
|
|
51
|
+
`WHEN … THEN DELETE` / `THEN UPDATE` 動作的 `MERGE` 都算。
|
|
46
52
|
**沒有任何旗標可以繞過** — `--yes` 不行,`--force` 也不行。真的要寫全表,就把意圖寫進
|
|
47
53
|
SQL 本身:補上 `WHERE 1=1` 或 `LIMIT`。`DROP` / `TRUNCATE` 完全沒有無人看管的路徑,請
|
|
48
54
|
升級交給人類處理。
|
package/assets/reference.md
CHANGED
|
@@ -346,12 +346,58 @@ says whether this particular statement may run right now.
|
|
|
346
346
|
| Tier | Statements | Interactive terminal | Non-interactive (or `--format json`) |
|
|
347
347
|
| :--- | :--- | :--- | :--- |
|
|
348
348
|
| One | `INSERT`, `UPDATE` / `DELETE` **with** a `WHERE` or `LIMIT`, `CREATE`, `ALTER` | Summary + `y/N`; `--yes` skips it | Runs, exactly as before |
|
|
349
|
-
| Two | `UPDATE` / `DELETE` with **no** `WHERE`, `DROP`, `TRUNCATE`, unparseable statements, several statements in one string | Type the target table name; **no flag skips it** | **Refused**: exit `1`, nothing sent to the database |
|
|
349
|
+
| Two | `UPDATE` / `DELETE` with **no** `WHERE`, `DROP`, `TRUNCATE`, unparseable statements, several statements in one string, a write nested inside another statement, a destructive `MERGE` | Type the target table name; **no flag skips it** | **Refused**: exit `1`, nothing sent to the database |
|
|
350
350
|
|
|
351
|
-
A refusal message names a machine-readable reason — `reason=no_where`,
|
|
352
|
-
`reason=ddl_destruction`, `reason=unparseable`, `reason=multiple_statements` — so a caller can tell it apart from a
|
|
351
|
+
A refusal message names a machine-readable reason — `reason=no_where`, `reason=multi_table`,
|
|
352
|
+
`reason=ddl_destruction`, `reason=unparseable`, `reason=multiple_statements`, `reason=nested_write` — so a caller can tell it apart from a
|
|
353
353
|
connection failure or a permission denial.
|
|
354
354
|
|
|
355
|
+
**A write that joins another table is tier two, whatever its `WHERE` says.** Whether such a
|
|
356
|
+
write is limited to particular rows is not a property of the statement:
|
|
357
|
+
`DELETE p FROM p JOIN o ON p.id = o.ref WHERE o.x > 0` deleted 2 of 5 rows against one
|
|
358
|
+
dataset and all 2000 against another, and a join's `ON` necessarily names the target, so it
|
|
359
|
+
proves nothing. Five rounds of adversarial measurement went into trying to read this off the
|
|
360
|
+
syntax before the rule became "a second table means tier two" (#80, #93). That refuses the
|
|
361
|
+
standard `UPDATE … FROM`, `UPDATE … JOIN … SET` and multi-table `DELETE` idioms for
|
|
362
|
+
unattended callers: rewrite the other table into a subquery in the `WHERE`, or run them
|
|
363
|
+
where someone can confirm.
|
|
364
|
+
|
|
365
|
+
**A statement is not the keyword it starts with.** PostgreSQL's data-modifying CTEs put an
|
|
366
|
+
arbitrary write in front of the statement the first keyword names, and a `MERGE` carries its
|
|
367
|
+
writes in a `WHEN … THEN` list — measured against a 2000-row table,
|
|
368
|
+
`WITH moved AS (DELETE FROM p RETURNING *) INSERT INTO archive …` and
|
|
369
|
+
`MERGE INTO p USING (SELECT 1 AS x) s ON true WHEN MATCHED THEN DELETE` each emptied it
|
|
370
|
+
while reading as an ordinary `INSERT` (#94, #95). So a write nested inside another statement
|
|
371
|
+
is tier two, `reason=nested_write`, and a `MERGE` is classified by its actions: a
|
|
372
|
+
`THEN DELETE` or `THEN UPDATE` is tier two, `reason=multi_table`, while an insert-only or
|
|
373
|
+
`DO NOTHING` one stays tier one. `ON CONFLICT … DO UPDATE`, `ON DUPLICATE KEY UPDATE` and
|
|
374
|
+
`INSERT … SELECT` are untouched by this — they are not nested writes, whatever they read
|
|
375
|
+
like. The cost is a CTE that deletes one row by primary key, and the ordinary `MERGE` upsert,
|
|
376
|
+
both refused for unattended callers.
|
|
377
|
+
|
|
378
|
+
**For a single-table write, the `WHERE` has to be about that table.** `UPDATE p SET c = 1
|
|
379
|
+
WHERE id = 1` is an ordinary write; `UPDATE p SET c = (SELECT max(x) FROM o WHERE o.id = 1)`
|
|
380
|
+
is not, because its only `WHERE` restricts the subquery and the write touches every row. A
|
|
381
|
+
*correlated* reference back to the target does count, so
|
|
382
|
+
`DELETE FROM t WHERE EXISTS (SELECT 1 FROM o WHERE o.tid = t.id)` is tier one while
|
|
383
|
+
`DELETE FROM t WHERE EXISTS (SELECT 1 FROM o WHERE o.id = 1)` deletes every row and is tier
|
|
384
|
+
two. A qualifier the subquery binds itself — including the target's own name, as in
|
|
385
|
+
`DELETE FROM sessions WHERE EXISTS (SELECT 1 FROM sessions WHERE …)` — is about the
|
|
386
|
+
subquery's rows, not the write.
|
|
387
|
+
|
|
388
|
+
This is a lower bound, not a proof: `WHERE id IS NOT NULL` names the target and still touches
|
|
389
|
+
every row, and no static check settles that. What it rules out is the class where nothing in
|
|
390
|
+
the condition is about the table being written.
|
|
391
|
+
|
|
392
|
+
**When the parser cannot read the statement**, there is no tree to judge and the same rule is
|
|
393
|
+
applied to the text: tier one only when the statement reads as a write to one named table
|
|
394
|
+
with a `WHERE` or `LIMIT` and contains no `SELECT`, `TABLE`, `VALUES`, `JOIN`, `USING`, a
|
|
395
|
+
statement-level `WITH`, or an `UPDATE … FROM`. A keyword inside parentheses is not at
|
|
396
|
+
statement level, so `SUBSTRING(x FROM 2)` and `AGAINST ('a' WITH QUERY EXPANSION)` do not
|
|
397
|
+
trip it. This is written as an allowlist because the denylist that preceded it was defeated
|
|
398
|
+
once per review round — `USING`, then `JOIN`, then a CTE, then a subquery, then `TABLE` as a
|
|
399
|
+
subquery.
|
|
400
|
+
|
|
355
401
|
**Escape routes.** For a statement that accepts a `WHERE`, put the intent in the SQL:
|
|
356
402
|
|
|
357
403
|
```bash
|
|
@@ -359,6 +405,7 @@ dbcli query "UPDATE users SET banned = 1" # refused, reason=n
|
|
|
359
405
|
dbcli query "UPDATE users SET banned = 1 WHERE 1=1" # runs — intent is explicit
|
|
360
406
|
dbcli query "DELETE FROM sessions LIMIT 1000" # runs — damage is bounded
|
|
361
407
|
dbcli query "UPDATE users SET banned = 1 WHERE id = 3" --yes # tier one, question skipped
|
|
408
|
+
dbcli query "UPDATE p SET x = 1 FROM o WHERE o.id = 1" # refused — the WHERE is about o, not p
|
|
362
409
|
```
|
|
363
410
|
|
|
364
411
|
This is deliberately not a flag. `WHERE 1=1` appended to a statement that already has a
|
|
@@ -369,7 +416,8 @@ whether they are possible at all, and the typed confirmation must come from a pe
|
|
|
369
416
|
|
|
370
417
|
Every tier-two evaluation is written to the audit log with
|
|
371
418
|
`metadata.write_gate_outcome` (`allowed` / `declined` / `refused`) and
|
|
372
|
-
`metadata.write_gate_reason`.
|
|
419
|
+
`metadata.write_gate_reason`. `dbcli audit write-gate` summarizes them — that is
|
|
420
|
+
the measurement ADR 0010 stakes this gate on.
|
|
373
421
|
|
|
374
422
|
> **Elasticsearch tiers by scope.** A read (`GET` / `HEAD`, plus `POST _search` and
|
|
375
423
|
> `POST _count`) is query-only. Indexing or updating a document is read-write.
|
|
@@ -1988,6 +2036,7 @@ Audit entries are metadata-only by design — never raw SQL bodies, `--param` va
|
|
|
1988
2036
|
| `audit show` | `readonly` | Print a single full entry by id prefix or `--recovery-ref`. |
|
|
1989
2037
|
| `audit clear` | `local-write` | Delete `<conn>.jsonl` + rotated `.jsonl.1` from local disk. Requires `--yes` or interactive confirm. |
|
|
1990
2038
|
| `audit health` | `readonly` | Render `AuditLogger.getHealth()` snapshot (writer state, lock state, rotation usage). |
|
|
2039
|
+
| `audit write-gate` | `readonly` | Summarize tier-two write-gate decisions: how often the gate was reached, by which reason, and how it was answered. |
|
|
1991
2040
|
|
|
1992
2041
|
#### `audit tail`
|
|
1993
2042
|
|
|
@@ -2048,6 +2097,32 @@ Examples:
|
|
|
2048
2097
|
|
|
2049
2098
|
Output reports: writer enabled/disabled, last write result, file-lock state, rotation cap usage (`max_bytes` / `max_entries`). When `audit.enabled = false` (D1 opt-out), `tail` / `show` / `health` still exit 0 and print `Audit is disabled (audit.enabled = false in .dbcli). Use 'dbcli audit health' for details.` (E note).
|
|
2050
2099
|
|
|
2100
|
+
#### `audit write-gate`
|
|
2101
|
+
|
|
2102
|
+
Answers the question ADR 0010 stakes the two-tier write gate on: is tier two stopping anything, or has everyone routed around it? The data was always written — `metadata.write_gate_tier` / `write_gate_outcome` / `write_gate_reason` on every evaluation — but reading it meant hand-writing jq over the JSONL.
|
|
2103
|
+
|
|
2104
|
+
| Flag | Purpose | Default |
|
|
2105
|
+
|---|---|---|
|
|
2106
|
+
| `--format <fmt>` | `table` \| `json`. | `table` |
|
|
2107
|
+
| `--all` | Merge every connection into one measurement. | off |
|
|
2108
|
+
| `--for-agent` | Shortcut for `--format json`. | off |
|
|
2109
|
+
|
|
2110
|
+
```bash
|
|
2111
|
+
dbcli audit write-gate
|
|
2112
|
+
dbcli audit write-gate --format json
|
|
2113
|
+
dbcli audit write-gate --all
|
|
2114
|
+
```
|
|
2115
|
+
|
|
2116
|
+
Reports, over the current connection's retained history (rotated `.jsonl.1` included):
|
|
2117
|
+
|
|
2118
|
+
- `total` — tier-two evaluations, with `range` giving the interval they span.
|
|
2119
|
+
- `outcomes` — `allowed` / `declined` / `refused`. The ratio between them is the line between "the gate is stopping things" and "everyone confirms through it".
|
|
2120
|
+
- `reasons` — which criterion sent the statement to tier two. Every known reason is listed, **including the ones that never fired**: a criterion that triggers zero times is the finding, not a row to omit.
|
|
2121
|
+
- `scanned` + `window` — the denominator. Two decisions in a week and two in a year are different findings.
|
|
2122
|
+
- `tierOne` — counted apart, with its own outcome breakdown, because tier one is recorded only when the operator declined.
|
|
2123
|
+
|
|
2124
|
+
When tier two was never reached, the summary says so and names the conclusion ADR 0010 draws from it (the criterion is wrong, not the gate unnecessary) rather than printing an empty table. An entirely empty audit log is reported as a separate case: it supports no conclusion either way.
|
|
2125
|
+
|
|
2051
2126
|
#### Boundaries
|
|
2052
2127
|
|
|
2053
2128
|
- Entries are append-only JSONL; rotation triggers at `~10 MB` or `~1000` entries (whichever first). Previous segment is preserved as `.jsonl.1`.
|
|
@@ -2537,11 +2612,38 @@ dbcli shell --sql # SQL-only mode
|
|
|
2537
2612
|
Inside the shell:
|
|
2538
2613
|
- Type SQL statements ending with `;` to execute
|
|
2539
2614
|
- Type dbcli commands without the `dbcli` prefix (e.g., `schema users`)
|
|
2615
|
+
- Prefix a subcommand with `\` to force it: `\delete users --where id=1`. Required for the
|
|
2616
|
+
subcommands whose names are also SQL keywords — `insert`, `update`, `delete`, `explain` —
|
|
2617
|
+
because a line starting with one of those is read as SQL,
|
|
2618
|
+
which is what a shell is for. The prefix works on every subcommand, so it is one rule
|
|
2619
|
+
rather than a list of exceptions (#88).
|
|
2620
|
+
- A subcommand runs in its own process with no stdin, so its ordinary y/n confirmation reads
|
|
2621
|
+
EOF and cancels. `\insert` / `\update` / `\delete` therefore need `--force` from inside
|
|
2622
|
+
the shell — or type the SQL at the prompt, where the confirmation can actually be
|
|
2623
|
+
answered. Tier two is unaffected either way: it is refused in the child and points you
|
|
2624
|
+
back to the prompt.
|
|
2540
2625
|
- Use Tab for auto-completion (SQL keywords, table names, column names)
|
|
2541
2626
|
- Type `.help` for meta commands (.quit, .clear, .format, .history, .timing)
|
|
2542
|
-
- Multi-line SQL: keeps accumulating until `;` is found
|
|
2627
|
+
- Multi-line SQL: keeps accumulating until `;` is found. Meta commands still work while a
|
|
2628
|
+
statement is accumulating — `.quit` quits, `.clear` abandons the buffer — and Ctrl-C
|
|
2629
|
+
cancels. A line that reads as SQL but names a subcommand and carries a double-dash option prints
|
|
2630
|
+
a note saying which prefix would have reached the subcommand.
|
|
2543
2631
|
- History persists across sessions (~/.dbcli_history)
|
|
2544
2632
|
|
|
2633
|
+
> **Tier two applies here (2.0.0)** — on SQL connections a typed `UPDATE` / `DELETE` with
|
|
2634
|
+
> no `WHERE`, a `DROP` or a `TRUNCATE` asks for the target table name before it runs.
|
|
2635
|
+
> Anything else typed cancels the statement and returns to the prompt; the session stays
|
|
2636
|
+
> open, and Ctrl-C withdraws the question itself — the next line is read as a statement,
|
|
2637
|
+
> not as the answer. Tier one (the y/N on ordinary writes) is deliberately not wired here — every line
|
|
2638
|
+
> is typed by a person. Piped input (`dbcli shell < script.sql`) has nobody to answer, so a
|
|
2639
|
+
> tier-two statement is refused and the remaining lines still run. Redis, MongoDB and
|
|
2640
|
+
> Elasticsearch shells are unaffected.
|
|
2641
|
+
>
|
|
2642
|
+
> dbcli **subcommands** typed in the shell (`query "..."`, `delete ...`) run as separate
|
|
2643
|
+
> processes with no stdin, so they cannot ask anything: a tier-two statement there is
|
|
2644
|
+
> refused with a message pointing you back to the `dbcli>` prompt, where the confirmation
|
|
2645
|
+
> can actually be typed.
|
|
2646
|
+
|
|
2545
2647
|
The REPL flavor depends on the active engine: SQL engines and MongoDB use the
|
|
2546
2648
|
form above; **Redis** opens a single-line command REPL (see [Redis › Interactive
|
|
2547
2649
|
shell](#interactive-shell)); **Elasticsearch** opens a Kibana Dev Tools-style
|