Recon deny-list + DB-error sanitization for the SQL guard - #120
Recon deny-list + DB-error sanitization for the SQL guard#120sandeep-agami wants to merge 2 commits into
Conversation
0319246 to
bd84345
Compare
c63692f to
27cbff4
Compare
There was a problem hiding this comment.
Pull request overview
Adds a new “recon / metadata” safety gate to the SQL guardrail pipeline and prevents operational DB error text from leaking across the assistant boundary by sanitizing refusals while storing raw details server-side in the guardrail audit trail.
Changes:
- Introduces
sql_guard.check_no_reconand wires it at the executor chokepoint and tool-layer precheck to refuse catalog/introspection/fingerprinting queries asrecon. - Sanitizes DB operational failures into value-free refusal messages via
_classify_db_error, while capturing raw stderr/messages into a newguardrail_audit.error_detailcolumn (with jsonl fallback). - Extends guardrail refusal vocabulary and expands/updates test corpora to pin both security and false-positive behavior.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/test_sql_guard.py | Adds recon deny-list corpus (must-refuse + must-pass) and executor e2e recon refusal assertion. |
| tests/test_guardrail.py | Updates refusal-kind drift test to include new recon and refined operational kinds. |
| tests/test_guardrail_audit.py | Verifies raw operational error text is stored only in audit (DB/jsonl) and never in the response envelope; adds pre-013 degrade test. |
| tests/test_execute_sql_envelope.py | Replaces exit-code-only behavior with stderr classification + sanitization assertions across multiple dialect wordings. |
| tests/test_contracts.py | Updates GuardrailAuditRecord roundtrip sample to include error_detail. |
| tests/test_ah012_executor_seam.py | Ensures injected in-process executor path also sanitizes operational errors (no raw detail in refusal reason). |
| plugins/agami/lib/sql_guard.py | Vendors check_no_recon and recon regex sets into plugin library copy. |
| plugins/agami/lib/guardrail.py | Updates documented REFUSAL_KINDS to include new operational kinds. |
| plugins/agami/lib/execute_sql.py | Wires recon gate into executor guarded pipeline (plugin library copy). |
| packages/agami-core/src/tools.py | Adds recon precheck, implements _classify_db_error, threads error_detail to audit only, and sanitizes in-process path parity. |
| packages/agami-core/src/store.py | Adds Store.rollback() to support retrying inserts after Postgres transaction aborts. |
| packages/agami-core/src/sql_guard.py | Implements core check_no_recon deny-list and regex matching over neutralized SQL. |
| packages/agami-core/src/model_store.py | Adds error_detail audit insert with fallback for pre-013 schemas (with rollback). |
| packages/agami-core/src/guardrail.py | Extends REFUSAL_KINDS with network, column_not_found, table_not_found. |
| packages/agami-core/src/execute_sql.py | Wires recon gate into core executor guarded pipeline. |
| packages/agami-core/src/contracts.py | Extends GuardrailAuditRecord contract with error_detail. |
| migrations/core/013_guardrail_audit_error_detail.sql | Adds guardrail_audit.error_detail column for server-side raw driver text retention. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| except Exception: | ||
| # Pre-013 schema (the error_detail column isn't there yet, e.g. new code against an | ||
| # un-migrated DB): DON'T drop the audit row — write it without the raw detail. Roll back | ||
| # first so a Postgres aborted-transaction doesn't fail the retry. | ||
| self._store.rollback() |
bd84345 to
17fe585
Compare
Two hardening gates at the shared execution chokepoint (execute_guarded), so both the subprocess wire and the in-process handler inherit them identically. 1. Recon / metadata deny-list. sql_guard.check_no_recon refuses server-fingerprinting and system-catalog introspection (version(), current_user, information_schema, pg_* relations) as a distinct `recon` refusal — wired right after the read-only guard, a hard gate NOT bypassable via --no-safety. 2. DB-error sanitization. A driver failure can echo schema / column / value names (or a DSN) in its message. Both surfaces now classify an operational failure into a VALUE-FREE refusal (_classify_db_error → fixed _ERROR_KINDS reason + remediation) and route the RAW driver text to the server-side audit trail ONLY, via a new guardrail_audit.error_detail column (migration 013). The subprocess path does this in _refusal_from_stderr; the in-process path now does the same in _run_in_process (returns (Refusal, error_detail)) — closing the leak the AH-012 seam had surfaced by riding exc.msg on the reason. Rebased onto the reconciled ACE-038 seam: the recon gate raises the typed GuardRefused; the two AH-012 seam tests that asserted the old raw-message-in-reason behavior now assert the sanitized, value-free contract (parity with the subprocess wire). Spec: ACE-039 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…3 case only Copilot review: DbActivitySink.record_guardrail_audit retried the INSERT on ANY exception, then re-ran it without error_detail. A first-INSERT failure for a NON-schema reason (a deadlock, a constraint violation, a dropped connection) would spuriously trigger the fallback — masking the real DB error AND silently dropping error_detail. Narrow the retry to the missing-error_detail-column signature only (sqlite "no column", Postgres "does not exist" / UndefinedColumn); any other failure re-raises to the best-effort caller (which now warns once, per the ACE-035 review fix). Test pins that a non-schema error propagates on the first attempt — one execute call, no rollback, original error re-raised — while the existing pre-013 degrade test still passes. Spec: ACE-039 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
27cbff4 to
733028f
Compare
|
Superseded by #176, which ports this to This PR cannot merge to The port is a port of the mechanism, not of this diff. What changed on the way (all recorded in ACE-039's
Closing rather than merging, per the governance remediation plan §4. |
Summary
Closes two information-leak channels that remain open even under read-only + object-scope:
Recon / metadata deny-list — a new
sql_guard.check_no_recongate refuses queries that fingerprint the server (version(),current_user,inet_server_addr()), enumerate or dump the system catalog (information_schema,pg_catalog, anypg_*relation — includingpg_statssampled VALUES andpg_authidpassword hashes), read catalog DDL (pg_get_viewdef/pg_get_functiondef), or probe object existence (has_table_privilege,to_regclass). The read-only role can't revoke catalog access on most engines, so this is the intended app-side backstop. A distinctreconrefusal, wired at the single executor chokepoint (execute_sql.py::main) + a fail-fast tool-layer pre-check, so both transports inherit it.DB-error sanitization — a raw driver error returned to the caller leaks schema, column names, or data values across the assistant boundary. The tool layer now classifies an operational failure into a value-free message (rich
kindmirroringdb_error_classifier.md), and the raw detail goes only to a newguardrail_audit.error_detailaudit column, keyed byaudit_id.Changes
sql_guard.py:check_no_recon+ data-driven recon sets, regex over the same neutralized SQL ascheck_read_only(no second parser).pg_is a reserved relation/function prefix (mirrors the Redshiftstl_/…prefixes); qualified columns (t.pg_class) pass via a(?<!\.)lookbehind. Fails closed when the SQL can't be neutralized.execute_sql.py/tools.py: wire the recon gate;_classify_db_error+_refusal_from_stderrreturns(Refusal, error_detail); the raw stderr is threaded to the audit row only.guardrail.py: addnetwork/column_not_found/table_not_foundtoREFUSAL_KINDS(+ the drift test now enumerates every emitted kind).model_store.py/store.py/ migration013: theerror_detailcolumn + a graceful INSERT degrade (survive a pre-013 schema instead of dropping the audit row) +Store.rollback.Verification
pg_leak closures + neutralization bypasses (comment/case/quoted-identifier/set-op/subquery); a false-positive corpus pins the legit look-alikes (bareversion/user/schemacolumns,t.pg_class, tokens in literals/comments, window fns/CTEs) with the accepted residuals pinned explicitly.error_detail, absent from the response).pg_stats/pg_authid/pg_get_viewdef/has_table_privilege/inet_server_addr()→recon; a qualifiedo.pg_classcolumn returns data).Review
Ran the Agami review panel (correctness · silent-failure · test-quality · High-lane security). Error-sanitization sign-off satisfied; the panel found real primary-engine recon false-negatives (the
pg_catalog relations + introspection functions) and a pre-013 audit-row-drop — all fixed here, with the corpus expanded to prove them closed.Checklist
Spec: ACE-039execute_sql/tool_execute_sqlchokepointplugins/agami/libin sync (dev.py sync-lib; drift-checked)Stacked on #115; retarget to
mainonce #111 / #112 / #115 merge.Spec: ACE-039
🤖 Generated with Claude Code