sql-harness 0.3.3__tar.gz → 0.3.4__tar.gz

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.
Files changed (92) hide show
  1. {sql_harness-0.3.3 → sql_harness-0.3.4}/PKG-INFO +2 -2
  2. {sql_harness-0.3.3 → sql_harness-0.3.4}/SKILL.md +31 -7
  3. {sql_harness-0.3.3 → sql_harness-0.3.4}/docs/AGENTS.md +2 -0
  4. {sql_harness-0.3.3 → sql_harness-0.3.4}/docs/README.md +1 -1
  5. {sql_harness-0.3.3/skills/sql-harness/references → sql_harness-0.3.4/docs}/install.md +5 -2
  6. {sql_harness-0.3.3 → sql_harness-0.3.4}/docs/maintainer.md +16 -4
  7. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/transactions.md +1 -0
  8. {sql_harness-0.3.3 → sql_harness-0.3.4}/pyproject.toml +2 -1
  9. {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/analytics.py +6 -2
  10. {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/cli.py +48 -26
  11. {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/config.py +4 -0
  12. {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/drivers/__init__.py +13 -1
  13. {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/drivers/mysql.py +29 -2
  14. {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/drivers/postgres.py +32 -2
  15. sql_harness-0.3.4/src/sql_harness/drivers/readonly.py +265 -0
  16. {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/drivers/redis.py +6 -1
  17. {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/drivers/sqlite.py +14 -2
  18. {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/drivers/ssh.py +6 -2
  19. {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/helpers.py +14 -0
  20. {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/manager.py +3 -1
  21. sql_harness-0.3.4/src/sql_harness/output.py +309 -0
  22. {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/run.py +14 -13
  23. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_cli.py +127 -1
  24. sql_harness-0.3.4/tests/unit/test_output.py +161 -0
  25. sql_harness-0.3.4/tests/unit/test_readonly.py +185 -0
  26. {sql_harness-0.3.3 → sql_harness-0.3.4}/uv.lock +1 -1
  27. sql_harness-0.3.3/docs/install.md +0 -155
  28. sql_harness-0.3.3/skills/sql-harness/README.txt +0 -8
  29. {sql_harness-0.3.3 → sql_harness-0.3.4}/.env.example +0 -0
  30. {sql_harness-0.3.3 → sql_harness-0.3.4}/.github/ISSUE_TEMPLATE/bug-report.yml +0 -0
  31. {sql_harness-0.3.3 → sql_harness-0.3.4}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  32. {sql_harness-0.3.3 → sql_harness-0.3.4}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
  33. {sql_harness-0.3.3 → sql_harness-0.3.4}/.gitignore +0 -0
  34. {sql_harness-0.3.3 → sql_harness-0.3.4}/AGENTS.md +0 -0
  35. {sql_harness-0.3.3 → sql_harness-0.3.4}/agent-workspace/README.md +0 -0
  36. {sql_harness-0.3.3 → sql_harness-0.3.4}/agent-workspace/agent_helpers.example.py +0 -0
  37. {sql_harness-0.3.3 → sql_harness-0.3.4}/agent-workspace/agent_helpers.py +0 -0
  38. {sql_harness-0.3.3 → sql_harness-0.3.4}/agent-workspace/zones/example_pg/scripts/sh_demo_crud.py +0 -0
  39. {sql_harness-0.3.3 → sql_harness-0.3.4}/agent-workspace/zones/example_pg/skills/sh_demo-schema.md +0 -0
  40. {sql_harness-0.3.3 → sql_harness-0.3.4}/agent-workspace/zones/meta/skills/pool.md +0 -0
  41. {sql_harness-0.3.3 → sql_harness-0.3.4}/agent-workspace/zones/meta/skills/workspace.md +0 -0
  42. {sql_harness-0.3.3 → sql_harness-0.3.4}/docs/connections-toml-example.toml +0 -0
  43. {sql_harness-0.3.3 → sql_harness-0.3.4}/docs/headless-notes.md +0 -0
  44. {sql_harness-0.3.3 → sql_harness-0.3.4}/docs/standalone-install.md +0 -0
  45. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/aggregations-advanced.md +0 -0
  46. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/aggregations.md +0 -0
  47. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/encoding-and-charset.md +0 -0
  48. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/joins-advanced.md +0 -0
  49. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/joins.md +0 -0
  50. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/large-result-sets.md +0 -0
  51. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/migrations.md +0 -0
  52. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/mysql/json-columns.md +0 -0
  53. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/pooling.md +0 -0
  54. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/btree-indexes.md +0 -0
  55. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/indexes-and-explain.md +0 -0
  56. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/json-columns.md +0 -0
  57. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/migrations.md +0 -0
  58. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/plan-reading.md +0 -0
  59. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/slow-queries-joins.md +0 -0
  60. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/specialized-indexes.md +0 -0
  61. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/table-optimization.md +0 -0
  62. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/recursive-ctes-advanced.md +0 -0
  63. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/recursive-ctes.md +0 -0
  64. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/save-run-cycle.md +0 -0
  65. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/schema-introspection.md +0 -0
  66. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/ssh/auth-and-tunnels.md +0 -0
  67. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/ssh/docker-via-ssh.md +0 -0
  68. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/ssh/ssh.md +0 -0
  69. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/timeouts-and-cancellation.md +0 -0
  70. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/window-functions-advanced.md +0 -0
  71. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/window-functions.md +0 -0
  72. {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/zone-skill-auto-surface.md +0 -0
  73. {sql_harness-0.3.3 → sql_harness-0.3.4}/sql-harness +0 -0
  74. {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/__init__.py +0 -0
  75. {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/agent_loader.py +0 -0
  76. {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/paths.py +0 -0
  77. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/__init__.py +0 -0
  78. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/conftest.py +0 -0
  79. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/integration/__init__.py +0 -0
  80. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/integration/test_mysql.py +0 -0
  81. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/integration/test_pg_optimization.py +0 -0
  82. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/integration/test_pgexercises_practice.py +0 -0
  83. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/integration/test_postgres.py +0 -0
  84. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/integration/test_ssh.py +0 -0
  85. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/__init__.py +0 -0
  86. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_agent_loader.py +0 -0
  87. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_analytics.py +0 -0
  88. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_config.py +0 -0
  89. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_helpers.py +0 -0
  90. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_manager.py +0 -0
  91. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_paths.py +0 -0
  92. {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_ssh.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sql-harness
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Single-process SQL + SSH CLI for LLM agents. PostgreSQL/MySQL/SSH via SQLAlchemy + paramiko. Plaintext credentials in one TOML file. Helpers auto-injected into the heredoc namespace.
5
5
  Project-URL: Source, https://github.com/zhaoliuxue/much_bigpy/tree/master/lab/sql_harness
6
6
  Project-URL: Issues, https://github.com/zhaoliuxue/much_bigpy/issues
@@ -37,7 +37,7 @@ uv sync
37
37
  uv run sql-harness init
38
38
 
39
39
  # 3. Add a connection
40
- uv run sql-harness add local_pg --driver postgres --url 'postgresql://postgres:postgres@localhost:5432/postgres'
40
+ uv run sql-harness add --name local_pg --driver postgres --url 'postgresql://postgres:postgres@localhost:5432/postgres'
41
41
 
42
42
  # 4. Test it
43
43
  uv run sql-harness test local_pg
@@ -7,7 +7,7 @@ description: "Always use sql-harness for SQL, SSH, and database operations — q
7
7
 
8
8
  A single-process SQL + SSH CLI for agents. 5 drivers (`postgres`, `mysql`, `sqlite`, `redis`, `ssh`) share one heredoc namespace. Connections live in plaintext in one TOML file.
9
9
 
10
- For setup, read `install.md`. For stuck-point mechanics, see `interaction-skills/` (cross-DB) and `interaction-skills/postgres/` (PG depth).
10
+ For setup, read `install.md` (shipped next to this file; in a source checkout it's `docs/install.md`). For stuck-point mechanics, see `interaction-skills/` (cross-DB) and `interaction-skills/postgres/` (PG depth).
11
11
 
12
12
  ## Reach for sql-harness when...
13
13
 
@@ -26,9 +26,9 @@ For setup, read `install.md`. For stuck-point mechanics, see `interaction-skills
26
26
  ```bash
27
27
  sql-harness --help
28
28
  sql-harness list
29
- sql-harness add <name> --driver postgres --url 'postgresql://user:pw@host/db'
30
- sql-harness add <name> --driver mysql --url 'mysql+pymysql://user:pw@host/db'
31
- sql-harness add <name> --driver ssh --url 'ssh://user@host:22?key=/path/id_ed25519'
29
+ sql-harness add --name <name> --driver postgres --url 'postgresql://user:pw@host/db'
30
+ sql-harness add --name <name> --driver mysql --url 'mysql+pymysql://user:pw@host/db'
31
+ sql-harness add --name <name> --driver ssh --url 'ssh://user@host:22?key=/path/id_ed25519'
32
32
  sql-harness test <name>
33
33
 
34
34
  sql-harness <<'PY'
@@ -71,7 +71,7 @@ Standard SQLAlchemy + PyMySQL. JSON syntax differs from PG — see `interaction-
71
71
 
72
72
  ### SQLite (`sqlite:///path.db`)
73
73
 
74
- Local dev / tests. All standard helpers work. Use `sql-harness add --driver sqlite --url 'sqlite:///<absolute-path>'` (SQLite needs absolute path).
74
+ Local dev / tests. All standard helpers work. Use `sql-harness add --name <name> --driver sqlite --url 'sqlite:///<absolute-path>'` (SQLite needs absolute path).
75
75
 
76
76
  ### Redis (`redis://host:port`)
77
77
 
@@ -103,7 +103,7 @@ See `interaction-skills/ssh/` for: `ssh.md` (helpers + CLI + gotchas), `docker-v
103
103
  | `list_tables(schema=None)` | `list[str]` | Schema recon — always start here |
104
104
  | `describe(table, schema=None)` | `list[dict]` | Columns + types — before writing SQL |
105
105
  | `table(name, schema=None, limit=None)` | `list[dict]` | Quick peek: `SELECT * FROM x LIMIT n` |
106
- | `with_transaction()` | context manager yielding `Connection` | ≥ 2 statements / DDL / atomicity |
106
+ | `with_transaction()` | context manager yielding `Connection` | ≥ 2 DML statements / atomicity (DDL: PG+SQLite only) |
107
107
  | `run_sql_file(path)` | `list[dict]` | Multi-statement `.sql`, one transaction |
108
108
  | `server_version()` | `str` | Round-trip liveness check |
109
109
  | `connection_info()` | `dict` | Current workspace metadata |
@@ -159,7 +159,8 @@ Reached via `apply_skill("pool")` etc. from any active zone — `zones/meta/skil
159
159
 
160
160
  - **Start with `list_tables` + `describe`, not `query`.** Before writing SQL on an unknown DB, run `list_tables(schema=None)` then `describe("table", schema=None)` to learn the columns. Skips the trial-and-error SELECT cycle.
161
161
  - **Set `BH_SQL_ZONE_SKILLS=1` once** so every `use_workspace()` surfaces that zone's per-DSN skill docs as a hint list (10 filenames max). Then `apply_skill(name)` reads the body when relevant.
162
- - **Default to `with_transaction()`** for any write with ≥ 2 statements (insert + update, drop + create, …). Atomic + automatic rollback on exception.
162
+ - **Default to `with_transaction()`** for any write with ≥ 2 statements (insert + update, update + delete, …). Automatic rollback on exception — **for DML**. DDL is a different story: PostgreSQL and SQLite roll back `CREATE`/`DROP`/`ALTER` inside a transaction, **MySQL does not** (it implicitly commits), so a `drop + create` migration that fails halfway leaves you with neither table. Run DDL migrations with a backup or a reversible plan, not on the strength of the transaction. See `interaction-skills/migrations.md`.
163
+ - **Transactions are only real on a transactional engine.** A MySQL table created on a server whose `@@default_storage_engine` is not InnoDB (e.g. MyISAM) ignores `ROLLBACK` *silently* — the write sticks and nothing warns you. Check with `SELECT @@default_storage_engine`, and write `ENGINE=InnoDB` explicitly if you need rollback.
163
164
  - **For PG performance tasks, reach for the 8 PG-only helpers first** (`explain_analyze`, `slow_queries`, `unused_indexes`, `seq_scan_heavy`, `table_stats`, `index_usage_stats`, `missing_indexes_hint`). Don't hand-parse `pg_stat_*` SQL.
164
165
  - **For remote tasks, reach for SSH workspace first.** Don't reach for raw `subprocess` + ssh-key files in agent code — `ssh_exec("systemctl restart myapp")` is one line.
165
166
  - **For "insert returning the new id"**: psycopg supports `INSERT … RETURNING id`; MySQL has no `RETURNING` — use `execute(...)` and read `result.lastrowid`.
@@ -189,6 +190,29 @@ Reached via `apply_skill("pool")` etc. from any active zone — `zones/meta/skil
189
190
  - For tables > 10k rows, use `engine.connect().execution_options(stream_results=True)` and iterate manually — `query()` loads everything into memory.
190
191
  - SSH SFTP subsystem may be disabled on the remote host. `ssh_upload/ssh_download` raise `RuntimeError("SFTP subsystem not available")`; fall back to `tar -czf - | ssh host tar -xzf -` via `ssh_exec`.
191
192
  - Default `ssh_exec` timeout is 30 s. Long-running commands (e.g. `pg_dump`, `tar czf`) need explicit `timeout=`.
193
+ - **Long output is spilled to a file, not printed.** A failed run whose traceback is long, and any `ssh exec` / `ssh run-script` output past a few KB, are written to `$BH_SQL_HOME/tmp/output/` and replaced by a pointer line naming the path, line count and size. That is deliberate — read the file when you actually need it. `BH_SQL_TRACEBACK=1` forces the raw traceback to stderr instead. The full traceback always also lands in `$BH_SQL_HOME/sql-harness.log` (NDJSON), whatever the terminal shows.
194
+
195
+ ## Read-only connections (`read_only = true`)
196
+
197
+ For a sensitive database, mark the connection read-only. Two rails are installed at the driver layer, both attached to the **engine** rather than to `query()`/`execute()` — so `conn.execute()`, `run_sql_file()` and any path that skips the helpers are covered too.
198
+
199
+ ```toml
200
+ [[connections]]
201
+ name = "prod_ro"
202
+ driver = "mysql"
203
+ url = "mysql+pymysql://ro_user@host:3306/proddb"
204
+ read_only = true
205
+ ```
206
+
207
+ ```bash
208
+ sql-harness add --name prod_ro --driver mysql --url '...' --read-only
209
+ ```
210
+
211
+ What it refuses: anything whose verb is not `SELECT`/`SHOW`/`DESCRIBE`/`EXPLAIN`/`TABLE`/`VALUES`/`PRAGMA`, plus transaction control. **Unrecognised statements are refused, not permitted** — that is why it is an allow-list: a deny-list misses `CALL`, `DO`, `LOAD DATA`, `HANDLER` and every future statement type. It also handles the dodges a naive keyword match falls for: `/*!50000 DROP */` (MySQL executes those), `SELECT 1; DROP TABLE t`, `WITH x AS (...) DELETE`, `EXPLAIN ANALYZE DELETE` (PostgreSQL executes it), and `#` — which is a comment in MySQL but the XOR operator in PostgreSQL, so treating it as a comment there would hide a write. On MySQL it additionally refuses `INTO OUTFILE`/`INTO DUMPFILE`/`FOR UPDATE`/`LOCK IN SHARE MODE`; on PostgreSQL, `SELECT ... INTO` (creates a table) and the `FOR ...` lock variants.
212
+
213
+ Where the backend has a session setting, it is applied on **every new pooled connection**: `SET SESSION TRANSACTION READ ONLY` (MySQL) / `SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY` (PostgreSQL). That rail is enforced by the server, so it catches anything that slips past the statement guard (e.g. a stored procedure).
214
+
215
+ **This is a guard rail, not a security boundary.** Both rails are escapable from the same session — a caller can run `SET SESSION TRANSACTION READ WRITE` and carry on, and any client-side check is pattern matching rather than a parser. The only hard boundary is the database account: use `GRANT SELECT ON db.* TO ...` for a database you genuinely must not write to, and treat everything above as defence in depth on top of it. SQLite has no session rail at all; for a file database the real boundary is the URL (`sqlite:///file:path?mode=ro&uri=true`). SSH workspaces ignore the flag — a remote shell is not a statement stream.
192
216
 
193
217
  ## Domain / table skills
194
218
 
@@ -12,10 +12,12 @@ Core code lives in `lab/sql_harness/src/sql_harness/`:
12
12
  - `config.py` — TOML schema + ConnectionConfig / ConnectionsConfig dataclasses
13
13
  - `paths.py` — XDG-style state directory resolution
14
14
  - `drivers/` — backend abstractions (postgres, mysql, redis stub, sqlite for tests)
15
+ - `drivers/readonly.py` — read-only statement guard shared by the SQL drivers: comment/literal-stripping, allow-list verb check, and `install_guard()` which attaches both rails to an engine. Read its module docstring before relying on it — it is a guard rail, not a security boundary
15
16
  - `manager.py` — SqlHarness class: holds engines, workspaces, skills registry
16
17
  - `helpers.py` — heredoc helpers (auto-imported)
17
18
  - `agent_loader.py` — loads `$BH_SQL_AGENT_WORKSPACE/agent_helpers.py`
18
19
  - `analytics.py` — event bus (observer) + JSON stats + NDJSON execution log: CLI call counts, doc-read tracking, command/error history; `BH_SQL_ANALYTICS` (default ON), view with `sql-harness stats`
20
+ - `output.py` — terminal output policy: UTF-8 stream setup, long-output spill to `$BH_SQL_HOME/tmp/output/`, concise error rendering; `BH_SQL_TRACEBACK=1` forces raw tracebacks
19
21
  - `run.py` — heredoc entry; delegates to CLI otherwise
20
22
  - `cli.py` — argparse subparser CLI (18 subcommands)
21
23
 
@@ -14,7 +14,7 @@ uv sync
14
14
  uv run sql-harness init
15
15
 
16
16
  # 3. Add a connection
17
- uv run sql-harness add local_pg --driver postgres --url 'postgresql://postgres:postgres@localhost:5432/postgres'
17
+ uv run sql-harness add --name local_pg --driver postgres --url 'postgresql://postgres:postgres@localhost:5432/postgres'
18
18
 
19
19
  # 4. Test it
20
20
  uv run sql-harness test local_pg
@@ -27,7 +27,8 @@ Writes `~/.config/sql-harness/connections.toml` (or `$BH_SQL_HOME/connections.to
27
27
  ## 3. Add your first connection
28
28
 
29
29
  ```bash
30
- uv run sql-harness add my_pg \
30
+ uv run sql-harness add \
31
+ --name my_pg \
31
32
  --driver postgres \
32
33
  --url 'postgresql://user:pw@localhost:5432/db' \
33
34
  --description "My dev DB"
@@ -89,7 +90,9 @@ password = "${env:IOT_SSH_PASSWORD}" # standalone secret — keeps the URL clea
89
90
 
90
91
  `${env:VAR}` placeholders inside `url` and `password` are expanded at load time from the process environment — keep prod secrets out of the file.
91
92
 
92
- Optional per-connection keys: `description`, `password`, `pool_size`, `pool_recycle`, `pre_ping`, `echo`, `application_name`.
93
+ Optional per-connection keys: `description`, `password`, `read_only`, `pool_size`, `pool_recycle`, `pre_ping`, `echo`, `application_name`.
94
+
95
+ `read_only = true` refuses writes on that connection — a statement allow-list on every statement the engine sends, plus the server-side session read-only setting where the backend has one. Use it for sensitive databases, but note it is a **guard rail, not a security boundary**: the session setting is escapable from the same session, and any client-side check is pattern matching. Pair it with a `SELECT`-only database account, which is the actual boundary. See `SKILL.md` → "Read-only connections".
93
96
 
94
97
  SSH password precedence (highest first): password embedded in `url` > the `password` key > `$BH_SSH_PASSWORD`. A connection that sets a password uses password auth exclusively — local `~/.ssh` keys are not consulted (see `interaction-skills/ssh/auth-and-tunnels.md`).
95
98
 
@@ -17,14 +17,26 @@ hand-synced copies. `pyproject.toml` `[tool.hatch.build.targets.wheel.force-incl
17
17
  maps the repo docs into the wheel:
18
18
 
19
19
  - `SKILL.md` → `sql_harness/_skills/SKILL.md`
20
+ - `docs/install.md` → `sql_harness/_skills/install.md`
20
21
  - `interaction-skills` → `sql_harness/_skills/interaction-skills`
21
22
  - `agent-workspace/zones/meta/skills` → `sql_harness/_skills/zones-meta-skills`
22
23
 
23
24
  The root `SKILL.md` is the **single source of truth** (v0.3.0 consolidation —
24
25
  the old `skills/sql-harness/SKILL.md` and `src/sql_harness/SKILL.md` copies
25
- were removed). After editing it, just `uv build` again. `sql-harness skill`
26
- reads `sql_harness/_skills/SKILL.md` from the installed package and falls
27
- back to the repo-root `SKILL.md` in a source checkout.
26
+ were removed; v0.3.4 removed the last hand-synced copy,
27
+ `skills/sql-harness/references/install.md`). After editing it, just
28
+ `uv build` again. `sql-harness skill` reads `sql_harness/_skills/SKILL.md`
29
+ from the installed package and falls back to the repo-root `SKILL.md` in a
30
+ source checkout.
31
+
32
+ `SKILL.md` references `install.md` as a sibling file — that only resolves
33
+ because `docs/install.md` is force-included into the bundle as
34
+ `_skills/install.md`. Dropping it from the force-include list breaks the
35
+ reference for every `skill install` user.
36
+
37
+ `skill install` needs a real (non-editable) wheel install to work: it reads
38
+ `sql_harness/_skills/`, which only exists after `uv build`. In a bare source
39
+ checkout `_bundled_skills_root()` returns `None` and the command exits 2.
28
40
 
29
41
  ## Build outputs
30
42
 
@@ -44,7 +56,7 @@ The wheel contains the package modules + the `_skills/` doc bundle
44
56
  ```bash
45
57
  # standalone venv
46
58
  uv sync
47
- uv run pytest tests/unit -q # 80 tests must pass
59
+ uv run pytest tests/unit -q # 133 tests must pass
48
60
  uv run sql-harness --help # CLI works
49
61
  uv run sql-harness skill # SKILL.md emit works
50
62
  ```
@@ -55,3 +55,4 @@ for attempt in range(3):
55
55
  - `text("SELECT ...").execution_options(...)` is per-statement; transaction-level options belong on the `SET` line.
56
56
  - Nested `with_transaction()` will create a SAVEPOINT (PostgreSQL) or fail (MySQL). Don't nest.
57
57
  - A failed statement inside a transaction does NOT auto-rollback the whole transaction in some dialects — call `conn.rollback()` explicitly if you've swallowed the exception.
58
+ - **A non-transactional table ignores `ROLLBACK` silently.** On MySQL, check `SELECT @@default_storage_engine` before trusting a rollback: if it is not InnoDB, any table created without an explicit `ENGINE=InnoDB` accepts `BEGIN`/`ROLLBACK` and discards them — the row is still there afterwards and nothing errors. Reach for `with_transaction()` on such tables and you get the *appearance* of atomicity with none of the substance.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sql-harness"
3
- version = "0.3.3"
3
+ version = "0.3.4"
4
4
  description = "Single-process SQL + SSH CLI for LLM agents. PostgreSQL/MySQL/SSH via SQLAlchemy + paramiko. Plaintext credentials in one TOML file. Helpers auto-injected into the heredoc namespace."
5
5
  readme = "docs/README.md"
6
6
  license = { text = "MIT" }
@@ -50,6 +50,7 @@ packages = ["src/sql_harness"]
50
50
  # shipping its SKILL.md in the wheel, but extended to the whole doc bundle.
51
51
  # Paths are relative to this pyproject.toml (lab/sql_harness/).
52
52
  "SKILL.md" = "sql_harness/_skills/SKILL.md"
53
+ "docs/install.md" = "sql_harness/_skills/install.md"
53
54
  "interaction-skills" = "sql_harness/_skills/interaction-skills"
54
55
  "agent-workspace/zones/meta/skills" = "sql_harness/_skills/zones-meta-skills"
55
56
 
@@ -48,8 +48,12 @@ CLI_INVOKED = "cli.invoked" # payload: {"subcommand": str}
48
48
  DOC_READ = "doc.read" # payload: {"doc": str, "via": str}
49
49
  COMMAND_EXECUTED = "command.executed"
50
50
  # payload: {"argv": list[str], "code": str|None, "ok": bool,
51
- # "exit_code": int, "error": str|None} (argv = CLI args incl. subcommand;
52
- # code = heredoc body when run via stdin; error = message/traceback)
51
+ # "exit_code": int, "error": str|None}
52
+ # argv = CLI args including the subcommand; [] in heredoc mode
53
+ # code = the heredoc body; None in CLI mode
54
+ # error = the FULL traceback when the run died on an uncaught exception,
55
+ # else None. stderr shows a compressed form (see output.py), so this
56
+ # log is the only complete channel — it keeps everything.
53
57
 
54
58
  _OFF_VALUES = {"0", "off", "false", "no"}
55
59
  DOC_READS_CAP = 500
@@ -32,6 +32,7 @@ import shutil
32
32
  import subprocess
33
33
  import sys
34
34
  import time
35
+ import traceback
35
36
  from importlib import resources as importlib_resources
36
37
  from pathlib import Path
37
38
  from typing import Callable
@@ -47,6 +48,7 @@ from .config import (
47
48
  save as save_config,
48
49
  )
49
50
  from .manager import SqlHarness
51
+ from .output import render_exception, set_utf8_streams, spill
50
52
  from .paths import (
51
53
  config_file,
52
54
  ensure_private_dir,
@@ -84,6 +86,7 @@ def cmd_list(_args, harness: SqlHarness) -> int:
84
86
  "url": c.masked_url(),
85
87
  "description": c.description,
86
88
  "password_set": bool(c.password),
89
+ "read_only": c.read_only,
87
90
  "pool_size": c.pool.size,
88
91
  })
89
92
  _emit({"default_workspace": cfg.default_workspace, "connections": rows})
@@ -113,6 +116,7 @@ def cmd_add(args, harness: SqlHarness) -> int:
113
116
 
114
117
  description = args.description or ""
115
118
  password = getattr(args, "password", None) or ""
119
+ read_only = bool(getattr(args, "read_only", False))
116
120
  pool = PoolConfig(size=args.pool_size or cfg.pool_defaults.size)
117
121
 
118
122
  cfg.connections.append(
@@ -122,11 +126,18 @@ def cmd_add(args, harness: SqlHarness) -> int:
122
126
  url=url,
123
127
  description=description,
124
128
  password=password,
129
+ read_only=read_only,
125
130
  pool=pool,
126
131
  )
127
132
  )
128
133
  save_config(cfg, config_file())
129
- _emit({"added": name, "driver": driver, "url": _mask(url), "password_set": bool(password)})
134
+ _emit({
135
+ "added": name,
136
+ "driver": driver,
137
+ "url": _mask(url),
138
+ "password_set": bool(password),
139
+ "read_only": read_only,
140
+ })
130
141
  return 0
131
142
 
132
143
 
@@ -176,6 +187,7 @@ def cmd_show(args, harness: SqlHarness) -> int:
176
187
  "description": c.description,
177
188
  "password_set": bool(c.password),
178
189
  "password": c.masked_password(),
190
+ "read_only": c.read_only,
179
191
  "pool": {
180
192
  "size": c.pool.size,
181
193
  "recycle": c.pool.recycle,
@@ -296,17 +308,15 @@ def cmd_skill(args, _harness: SqlHarness) -> int:
296
308
  if body is None:
297
309
  print(
298
310
  "error: packaged SKILL.md not found (expected at "
299
- "skills/sql-harness/SKILL.md relative to the package)",
311
+ "sql_harness/_skills/SKILL.md, or the repo-root SKILL.md "
312
+ "in a source checkout)",
300
313
  file=sys.stderr,
301
314
  )
302
315
  return 2
303
- # Force UTF-8 on stdout so non-ASCII skill bodies emit cleanly on Windows
304
- # (default GBK/CP936 codepage can't encode symbols like ⇄).
316
+ # stdout is already UTF-8 (set_utf8_streams() runs at the top of main()),
317
+ # which is what lets non-ASCII skill bodies emit cleanly on Windows —
318
+ # the default GBK/CP936 codepage can't encode symbols like ⇄.
305
319
  out = sys.stdout
306
- try:
307
- out.reconfigure(encoding="utf-8", errors="replace")
308
- except (AttributeError, OSError):
309
- pass
310
320
  out.write(body)
311
321
  if not body.endswith("\n"):
312
322
  out.write("\n")
@@ -594,13 +604,9 @@ def cmd_save(args, _harness: SqlHarness) -> int:
594
604
  conn, code = _require_connection_or_fail(args)
595
605
  if code:
596
606
  return code
597
- # Read stdin as UTF-8 on Windows (default locale is GBK/CP936, which
598
- # mangles non-ASCII bytes into surrogates that write_text rejects).
599
- # Mirrors run.py's heredoc-mode stdin handling.
600
- try:
601
- sys.stdin.reconfigure(encoding="utf-8", errors="replace")
602
- except (AttributeError, OSError):
603
- pass
607
+ # stdin is already UTF-8 (set_utf8_streams() runs at the top of main()):
608
+ # on Windows the default GBK/CP936 locale mangles non-ASCII bytes into
609
+ # surrogates that write_text rejects. Mirrors run.py's heredoc mode.
604
610
  body = sys.stdin.read()
605
611
  if not body.strip():
606
612
  print("error: stdin is empty; nothing to save", file=sys.stderr)
@@ -668,11 +674,11 @@ def cmd_run(args, harness: SqlHarness) -> int:
668
674
  return 0
669
675
  except SystemExit as e:
670
676
  return int(e.code) if e.code is not None else 0
671
- except Exception:
672
- import traceback
677
+ # Real failures propagate to main(), which stores the full traceback in the
678
+ # analytics log and renders the short form to stderr. Catching here would
679
+ # mean a saved-script crash left no traceback anywhere — the frames already
680
+ # carry the script's real path, so the report names the failing line.
673
681
 
674
- traceback.print_exc()
675
- return 1
676
682
 
677
683
 
678
684
  def cmd_scripts(args, _harness: SqlHarness) -> int:
@@ -853,9 +859,11 @@ def cmd_ssh(args, harness: SqlHarness) -> int:
853
859
 
854
860
  if sub == "exec":
855
861
  result = _h.ssh_exec(args.command, timeout=args.timeout)
856
- sys.stdout.write(result["stdout"])
862
+ # Remote output length is unbounded (cat of a big file, a chatty log);
863
+ # spill it so a long command doesn't flood the caller's context.
864
+ sys.stdout.write(spill(result["stdout"], "ssh-stdout"))
857
865
  if result["stderr"]:
858
- sys.stderr.write(result["stderr"])
866
+ sys.stderr.write(spill(result["stderr"], "ssh-stderr"))
859
867
  return 0 if result["ok"] else result["exit_code"]
860
868
 
861
869
  if sub == "upload":
@@ -870,9 +878,9 @@ def cmd_ssh(args, harness: SqlHarness) -> int:
870
878
 
871
879
  if sub == "run-script":
872
880
  result = _h.ssh_run_script(args.local, args.remote_dir, args.interpreter)
873
- sys.stdout.write(result["stdout"])
881
+ sys.stdout.write(spill(result["stdout"], "ssh-stdout"))
874
882
  if result["stderr"]:
875
- sys.stderr.write(result["stderr"])
883
+ sys.stderr.write(spill(result["stderr"], "ssh-stderr"))
876
884
  return 0 if result["ok"] else result["exit_code"]
877
885
 
878
886
  if sub == "info":
@@ -906,6 +914,11 @@ def _build_parser() -> argparse.ArgumentParser:
906
914
  "--password",
907
915
  help="standalone secret (SSH password); keeps the URL credential-free",
908
916
  )
917
+ p_add.add_argument(
918
+ "--read-only",
919
+ action="store_true",
920
+ help="refuse writes on this connection (statement guard + session read-only)",
921
+ )
909
922
  p_add.add_argument("--pool-size", type=int)
910
923
 
911
924
  p_edit = sub.add_parser("edit", help="edit connections.toml in $EDITOR")
@@ -1006,6 +1019,12 @@ def _build_parser() -> argparse.ArgumentParser:
1006
1019
 
1007
1020
 
1008
1021
  def main(argv: list[str]) -> int:
1022
+ # Before anything can print: force UTF-8 on the standard streams so data
1023
+ # that legitimately contains non-GBK characters (Chinese descriptions in
1024
+ # connections.toml, CJK paths, remote host output) reports instead of
1025
+ # crashing the process that was trying to report it.
1026
+ set_utf8_streams()
1027
+
1009
1028
  parser = _build_parser()
1010
1029
  args = parser.parse_args(argv)
1011
1030
  if not args.cmd:
@@ -1044,12 +1063,15 @@ def main(argv: list[str]) -> int:
1044
1063
  {"argv": argv, "code": None, "ok": rc == 0, "exit_code": rc, "error": None},
1045
1064
  )
1046
1065
  return rc
1047
- except Exception as e:
1066
+ except Exception as exc:
1067
+ # The analytics log keeps the full traceback (machine-readable record);
1068
+ # the terminal gets the short form. Same split as heredoc mode.
1048
1069
  emit(
1049
1070
  COMMAND_EXECUTED,
1050
1071
  {"argv": argv, "code": None, "ok": False, "exit_code": 1,
1051
- "error": f"{type(e).__name__}: {e}"},
1072
+ "error": "".join(traceback.format_exception(exc))},
1052
1073
  )
1053
- raise
1074
+ sys.stderr.write(render_exception(exc, label=f"cli-{args.cmd}"))
1075
+ return 1
1054
1076
  finally:
1055
1077
  harness.close_all()
@@ -69,6 +69,7 @@ class ConnectionConfig:
69
69
  url: str
70
70
  description: str = ""
71
71
  password: str = "" # standalone secret (SSH password)
72
+ read_only: bool = False # refuse writes: see drivers/readonly.py
72
73
  pool: PoolConfig = field(default_factory=PoolConfig)
73
74
  application_name: str = "sql-harness"
74
75
 
@@ -161,6 +162,8 @@ def save(cfg: ConnectionsConfig, path: Path) -> None:
161
162
  lines.append(f'description = "{_toml_str(c.description)}"')
162
163
  if c.password:
163
164
  lines.append(f'password = "{_toml_str(c.password)}"')
165
+ if c.read_only:
166
+ lines.append("read_only = true")
164
167
  if c.pool.size != cfg.pool_defaults.size:
165
168
  lines.append(f"pool_size = {c.pool.size}")
166
169
  if c.pool.recycle != cfg.pool_defaults.recycle:
@@ -218,6 +221,7 @@ def _from_dict(raw: dict[str, Any]) -> ConnectionsConfig:
218
221
  url=_expand_env(str(entry["url"])),
219
222
  description=str(entry.get("description", "")),
220
223
  password=_expand_env(str(entry.get("password", ""))),
224
+ read_only=bool(entry.get("read_only", False)),
221
225
  pool=pool,
222
226
  application_name=str(entry.get("application_name", "sql-harness")),
223
227
  )
@@ -17,12 +17,24 @@ class Driver(Protocol):
17
17
 
18
18
  name: str
19
19
 
20
- def make_engine(self, url: str, pool: "PoolConfig", password: str | None = None) -> Engine:
20
+ def make_engine(
21
+ self,
22
+ url: str,
23
+ pool: "PoolConfig",
24
+ password: str | None = None,
25
+ read_only: bool = False,
26
+ ) -> Engine:
21
27
  """Create a SQLAlchemy Engine with the given pool config.
22
28
 
23
29
  `password` is the connection's standalone secret (ConnectionConfig.password).
24
30
  Drivers whose URL already carries credentials (SQL backends) ignore it;
25
31
  SSH uses it when no password is embedded in the URL.
32
+
33
+ `read_only` installs that dialect's read-only rails (see
34
+ `drivers/readonly.py`): a statement allow-list on every
35
+ `before_cursor_execute`, plus the server-side session setting where the
36
+ backend has one. Drivers with no notion of statements (SSH, Redis)
37
+ accept it for API parity.
26
38
  """
27
39
 
28
40
  def list_tables(self, engine: Engine, schema: str | None) -> list[str]:
@@ -4,17 +4,44 @@ from __future__ import annotations
4
4
 
5
5
  from sqlalchemy import Engine, create_engine, inspect, text
6
6
 
7
+ from .readonly import install_guard
8
+
9
+ # Dangerous even inside an otherwise read-only statement: file writes and row
10
+ # locks. MySQL's `#` is a comment and `\'` escapes inside strings.
11
+ _MYSQL_FORBIDDEN = frozenset(
12
+ {
13
+ "INTO OUTFILE",
14
+ "INTO DUMPFILE",
15
+ "FOR UPDATE",
16
+ "FOR SHARE",
17
+ "LOCK IN SHARE MODE",
18
+ }
19
+ )
20
+
7
21
 
8
22
  class MysqlDriver:
9
23
  name = "mysql"
10
24
 
11
- def make_engine(self, url: str, pool, password: str | None = None) -> Engine:
25
+ def make_engine(
26
+ self, url: str, pool, password: str | None = None, read_only: bool = False
27
+ ) -> Engine:
12
28
  # `password` is ignored: credentials live in the URL for SQL backends.
13
29
  if not url.startswith("mysql+pymysql://"):
14
30
  raise ValueError(
15
31
  f"MySQL URL must start with mysql+pymysql:// (got {url[:30]!r})"
16
32
  )
17
- return create_engine(url, **pool.sqlalchemy_kwargs())
33
+ engine = create_engine(url, **pool.sqlalchemy_kwargs())
34
+ if read_only:
35
+ install_guard(
36
+ engine,
37
+ hash_comments=True,
38
+ backslash_escapes=True,
39
+ extra_forbidden=_MYSQL_FORBIDDEN,
40
+ # Server-enforced rail: MySQL then rejects DML *and* DDL with
41
+ # 1792 "Cannot execute statement in a READ ONLY transaction".
42
+ session_sql="SET SESSION TRANSACTION READ ONLY",
43
+ )
44
+ return engine
18
45
 
19
46
  def list_tables(self, engine: Engine, schema: str | None) -> list[str]:
20
47
  insp = inspect(engine)
@@ -4,11 +4,28 @@ from __future__ import annotations
4
4
 
5
5
  from sqlalchemy import Engine, create_engine, inspect, text
6
6
 
7
+ from .readonly import install_guard
8
+
9
+ # `SELECT ... INTO t` creates a table in PostgreSQL (unlike MySQL, where INTO
10
+ # means OUTFILE). Spaced so `FROM into_log` is not a false positive. The FOR
11
+ # variants take row locks.
12
+ _PG_FORBIDDEN = frozenset(
13
+ {
14
+ " INTO ",
15
+ "FOR UPDATE",
16
+ "FOR NO KEY UPDATE",
17
+ "FOR SHARE",
18
+ "FOR KEY SHARE",
19
+ }
20
+ )
21
+
7
22
 
8
23
  class PostgresDriver:
9
24
  name = "postgres"
10
25
 
11
- def make_engine(self, url: str, pool, password: str | None = None) -> Engine:
26
+ def make_engine(
27
+ self, url: str, pool, password: str | None = None, read_only: bool = False
28
+ ) -> Engine:
12
29
  # `password` is ignored: credentials live in the URL for SQL backends.
13
30
  # Accept all three SQLAlchemy-recognized PostgreSQL URL prefixes:
14
31
  # postgresql+psycopg:// (explicit dialect — preferred)
@@ -26,7 +43,20 @@ class PostgresDriver:
26
43
  f"PostgreSQL URL must start with postgres:// or postgresql:// "
27
44
  f"(got {url[:30]!r})"
28
45
  )
29
- return create_engine(url, **pool.sqlalchemy_kwargs())
46
+ engine = create_engine(url, **pool.sqlalchemy_kwargs())
47
+ if read_only:
48
+ install_guard(
49
+ engine,
50
+ # `#` is the bitwise-XOR operator in PostgreSQL, not a comment —
51
+ # treating it as one would swallow real code and hide a write.
52
+ hash_comments=False,
53
+ backslash_escapes=False,
54
+ extra_forbidden=_PG_FORBIDDEN,
55
+ # Server-enforced rail: writes then fail with
56
+ # "cannot execute ... in a read-only transaction".
57
+ session_sql="SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY",
58
+ )
59
+ return engine
30
60
 
31
61
  def list_tables(self, engine: Engine, schema: str | None) -> list[str]:
32
62
  insp = inspect(engine)