@carllee1983/dbcli 2.0.0 → 3.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.
- package/.cursor/rules/dbcli.mdc +8 -1
- package/.cursor/skills/dbcli/reference.md +114 -9
- package/.github/skills/dbcli/SKILL.md +8 -1
- package/.github/skills/dbcli/reference.md +114 -9
- package/CHANGELOG.md +82 -0
- package/assets/SKILL.md +8 -1
- package/assets/SKILL.zh-TW.md +7 -1
- package/assets/reference.md +114 -9
- package/dist/cli-runtime.mjs +679 -104
- package/dist/cli.mjs +3 -1
- package/dist/core.mjs +34 -2
- package/package.json +3 -1
- package/plugins/dbcli-agent/skills/dbcli/SKILL.md +8 -1
- package/plugins/dbcli-agent/skills/dbcli/reference.md +114 -9
- package/skills/dbcli/SKILL.md +8 -1
- package/skills/dbcli/reference.md +114 -9
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.
|
|
@@ -1525,7 +1573,7 @@ Opt-in flag trio that persists a **VerificationArtifact JSON** (schema v1) under
|
|
|
1525
1573
|
|
|
1526
1574
|
**Planned vs Result evidence.** `dbcli skill tasks plan safe-backfill-verify --format json` returns a plan containing a `verification` block with `status: "planned"`. That block is the **planned** evidence definition — it describes which check will run. Running `assert --write-verification-artifact` on the actual data produces **result** evidence (`status: "verified"` or `status: "not_verified"`). The two records are distinct; `"planned"` does **not** indicate that verification has run or passed.
|
|
1527
1575
|
|
|
1528
|
-
> **
|
|
1576
|
+
> **Numeric note:** Postgres returns `count(*)` and `sum()` as bigint, which arrives as a string. A numeric expectation is compared numerically, so `value == 0` matches it without a cast. A quoted expectation stays a text comparison: `value == "0"` matches the text, not the number. The `::int` casts in the examples below are harmless and no longer required.
|
|
1529
1577
|
|
|
1530
1578
|
```bash
|
|
1531
1579
|
dbcli assert "SELECT count(*)::int FROM orders WHERE status IS NULL" \
|
|
@@ -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
|
|
@@ -2619,7 +2721,7 @@ dbcli evidence compose --claims ./claims.json --verification ver_abcd --audit 1a
|
|
|
2619
2721
|
|
|
2620
2722
|
| Flag | Purpose | Default |
|
|
2621
2723
|
|---|---|---|
|
|
2622
|
-
| `--claims <file>` | Required JSON file with exactly `subject` and `claims`; every claim has an `id` and plain-language `text`. SQL-shaped text, credentials, error content, and blacklisted identifiers are rejected. | — |
|
|
2724
|
+
| `--claims <file>` | Required JSON file with exactly `subject` and `claims`; every claim has an `id` and plain-language `text`. SQL-shaped text, credentials, error content, and blacklisted identifiers are rejected. A blacklisted identifier has to appear as an identifier — a term like `id` matches `orders.id` but not the word "identifier" — and the refusal names the field, never the matched term. | — |
|
|
2623
2725
|
| `--verification <selector...>` | One or more verification artifact ids, unique prefixes, filenames, or in-bounds paths. | none |
|
|
2624
2726
|
| `--audit <selector...>` | One or more audit ids or unique prefixes (minimum four characters) from the active connection. | none |
|
|
2625
2727
|
| `--receipt <path...>` | Explicit workspace-relative receipt path(s). Receipts contribute safe provenance only; they are not execution approval. | none |
|
|
@@ -2627,8 +2729,11 @@ dbcli evidence compose --claims ./claims.json --verification ver_abcd --audit 1a
|
|
|
2627
2729
|
| `--format <json\|markdown>` | Compose receipt format printed to stdout. | `json` |
|
|
2628
2730
|
|
|
2629
2731
|
At least one `--verification`, `--audit`, or `--receipt` reference is required. The resulting pack
|
|
2630
|
-
|
|
2631
|
-
|
|
2732
|
+
carries a canonical SHA-256 digest over its content, and its `id` is derived from that digest, so
|
|
2733
|
+
composing the same claims and references twice produces the same pack. `createdAt` records when the
|
|
2734
|
+
pack was written and is deliberately outside the digest — it is the one field a restamp can change
|
|
2735
|
+
without breaking validation. Whether a referenced source is still resolvable is reported by
|
|
2736
|
+
`evidence validate`, not stored in the pack.
|
|
2632
2737
|
|
|
2633
2738
|
#### `evidence validate`
|
|
2634
2739
|
|