feat(server): /query structured ai_context + operator-controlled raw-SQL logging - #173
Conversation
The POST /query endpoint takes final SQL with literal values inlined and
logs the full statement at INFO as an audit trail. Because parameter
values live inside the SQL string, that audit line also logs the values,
which then propagate to any OTLP collector / log sink.
This spec proposes accepting a {name}-templated SQL plus a separate
params object (reusing the pipeline handler's injection-safe
substitution) so the endpoint logs the template without values, plus an
optional caller-supplied `purpose` field logged as structured context.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…storage Add a "Why parameterize" section stressing SQL injection, PII/secret protection, type accuracy, and eliminating ad-hoc escaping/serialization as the justification for a separate params channel over merely redacting the log. Make explicit that this version stores only the query template; param values never reach any log, trace, metric, or store. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
95902d0 to
5025343
Compare
Rather than overload one endpoint, keep /query for raw final SQL as an
admin/console-only surface whose SQL is never logged, and add a new
/parameterized_query for agents that takes a {name} template + params +
purpose. Only the template and purpose are logged; param values never
are. purpose exists only on the new endpoint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs: narrow scope to purpose field + operator raw-SQL log config
Drop parameterization and the /parameterized_query endpoint. The endpoint
keeps taking raw final SQL; add an optional purpose field, and gate raw-SQL
logging behind an opt-in operator config that writes to a local file (the
operator's responsibility to secure in this OSS build). Default: raw SQL is
not emitted into the general log/trace stream.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(server): add /query purpose field + operator-controlled raw-SQL log
Implements docs/superpowers/specs/2026-07-24-query-purpose-and-raw-log-design.md.
- /query no longer logs raw SQL: the INFO audit line drops `sql` and keeps
a value-free marker (purpose, max_rows, kind, timing); the DEBUG SQL lines
are removed. Raw SQL (which may inline secrets/PII) is no longer emitted to
logs/traces or OTLP by default.
- Add optional `purpose` field to QueryRequest (<= 2000 chars, else 400
parameter_validation_error) so callers/agents document intent.
- Add `--query-log <path>` operator flag: a new query_log::QueryLog appends
each executed statement (raw sql, purpose, max_rows, timestamp) as one JSON
line to a local file the operator secures. Off by default.
Tests: query_log unit tests (append behavior) and query_http integration
tests (purpose accepted / over-cap rejected / file records raw SQL + purpose).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cleanup
feba951 to
a15485d
Compare
…ion_id) Evolve the single /query endpoint so caller intent is carried in an optional, extensible ai_context JSON object instead of a flat purpose string. When present it must be an object with required non-empty purpose and session_id (groups queries per agent session); other keys are free-form under an overall size cap. Application/console queries omit it. Supersedes the flat purpose field from the 2026-07-24 spec. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements docs/superpowers/specs/2026-07-28-query-ai-context-design.md. Replace the flat optional `purpose` string on POST /query with an optional `ai_context` JSON object. When present it must be an object carrying two required non-empty strings: `purpose` (<=2000 chars) and `session_id` (<=200 chars, groups queries from one agent session); other keys are free-form and the whole object must serialize to <=4096 bytes. Any violation -> 400 parameter_validation_error. Omitting ai_context is valid (application/console queries), so the change is backward compatible. The value-free INFO audit marker and the opt-in --query-log file now record the full ai_context object in place of purpose; raw SQL is still never logged in the general stream. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BtXin
left a comment
There was a problem hiding this comment.
Requesting changes because the current implementation does not yet provide the confidentiality or durability guarantees described by this PR.
-
Sensitive SQL literals still reach the general tracing/OTLP stream at DEBUG. Removing the handler's explicit
sqlfields is not sufficient: the server enables DataFusion analyzer/optimizer/physical-plan tracing, and those plans contain literal values. On this commit, runningSELECT 'TOP_SECRET_PR173' AS secretwith DEBUG tracing emittedProjection: Utf8("TOP_SECRET_PR173")andProjectionExec: expr=[TOP_SECRET_PR173 ...]repeatedly. The complete statement may be absent, but the secret/PII values motivating this change are still exported. Please redact literals or enforce a query execution/logging configuration that cannot emit value-bearing plans, and add a regression test that captures DEBUG/OTLP output and asserts a sentinel literal is absent. -
The query-log write blocks Tokio request threads.
QueryLog::recordholds astd::sync::Mutex<File>and performs synchronous filesystem I/O directly from the async handler. A slow or network-mounted path can stall runtime workers, while concurrent requests block behind the same mutex. The persistence path needs to be asynchronous or isolated behind a dedicated writer. -
ai_context: nullbypasses the documented validation. Because the field isOption<serde_json::Value>, both a missing field and explicit JSONnulldeserialize toNone. I addednullto the exact-head HTTP test and it returned 200 instead of the documented 400parameter_validation_error. Please preserve field presence during deserialization and add this case to the test suite. -
A newly created raw-query log is not private by default.
OpenOptions::create(true)uses normal process permissions; with the common022umask the file is typically 0644, readable by other local users. Any storage containing raw SQL, secrets, or PII needs secure creation/access permissions by default. -
Use proper durable storage for the query audit log instead of an append-only JSONL file. Please back the audit log with an embedded persistent store such as SQLite, RocksDB, or LMDB. SQLite is likely the lowest-complexity option because this repository already uses it. The implementation should provide:
- durable, transactional insertion before execution;
- a stable record ID and timestamps;
- SQL,
ai_context,session_id,max_rows, and statement kind; - an execution outcome/status updated after completion or failure;
- indexes for session/time lookup;
- explicit retention/rotation behavior;
- secure file/database permissions;
- defined failure semantics.
When auditing is enabled, startup or migration failures should not silently disable it, and a failed pre-execution audit write should not allow an unrecorded query to run.
The dedicated audit store can remain opt-in, but once enabled it needs to be trustworthy, durable, and queryable rather than best-effort.
…it store Addresses review on #173: neither the confidentiality nor the durability guarantee the PR described actually held. Confidentiality — removing the handler's `sql` field left literals reaching the trace/OTLP stream, because DataFusion reprints them inside plans logged at DEBUG. `logging::build_env_filter` now pins every plan-printing target (`datafusion*`, `sqlparser`, and the server's datafusion-tracing spans, newly given the explicit `skardi_query_plan` target) to INFO, dropping any RUST_LOG directive that would lower them. `SKARDI_ALLOW_PLAN_VALUE_LOGGING=1` lifts it. Enforcing at the filter covers emitters added by future DataFusion versions, which per-site redaction would not. Durability — the JSONL sink is replaced by a SQLite ledger (`--query-audit-db`, `--query-audit-retention-days`) on tokio-rusqlite, the same backend as the jobs ledger: - async, so no filesystem I/O on Tokio workers and no mutex across a write; - WAL + synchronous=FULL, committed before execution; - created 0600 (before SQLite touches it, so the umask never applies); - record id, timestamps, sql, ai_context, session_id, max_rows, kind, status, row_count, error; indexed on (session_id, created_at), created_at, status; - fail-closed: open/migrate errors abort startup, a failed pre-execution write returns 503 and the statement does not run, and rows left `started` by a crash reconcile to `unknown`. `ai_context: null` now deserializes as present-but-malformed instead of collapsing to absent, so it returns 400 as documented. Tests: tests/query_plan_logging.rs captures subscriber output for a real query and asserts a sentinel literal is absent under RUST_LOG=debug, trace, and targeted datafusion directives, with a positive control proving the harness sees plan output. Plus audit-store unit tests (round trip, orphan reconcile, retention, 0600, reopen) and HTTP tests for success/failure records, the fail-closed 503, and the null rejection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — all five points were real. Pushed 1. Sensitive literals at DEBUG — fixed at the filter, not per-siteYou're right that dropping the handler's I chose enforcement over redaction. Redacting means patching or wrapping upstream plan
The datafusion-tracing instrumentations needed a change to be coverable at all: both macros default Escape hatch: Regression test — Also re-ran your live repro end to end. 2 + 4 + 5. JSONL sink replaced by a durable SQLite ledgerAgreed the file was the wrong primitive — fixing the blocking write and the mode bits would still have left no outcome, no id, no index, no transaction, no retention. CLI: Against your checklist:
Live check of a recorded row: {"id":"18c77aa13df20516-0","created_at":"2026-07-31T20:43:30.223+00:00","finished_at":"2026-07-31T20:43:30.232+00:00",
"sql":"SELECT 'TOP_SECRET_PR173' AS secret","ai_context":"{\"purpose\":\"repro finding 1\",\"session_id\":\"sess-repro\"}",
"session_id":"sess-repro","max_rows":1000,"statement_kind":"Query","status":"succeeded","row_count":1,"error":null}3.
|
Context
POST /query(#158) takes final SQL with literal values inlined and logged the full statement at INFO (sql = %request.sql). Since that's atracingfield, it was also a values log that fans out to any OTLP collector / log sink — exposing any secret/PII passed as a literal.What this PR does
Adds the design specs and their implementation. Changes to the single
POST /queryendpoint:ai_contextobject (optional). Application/console queries omit it. When present it must be a JSON object carrying two required non-empty strings —purpose(≤ 2000 chars, why the query runs) andsession_id(≤ 200 chars, groups queries from one agent session) — plus any free-form keys the caller wants. The whole object must serialize to ≤ 4096 bytes. Recorded for observability, never executed; any violation →parameter_validation_error. This replaces the flatpurposestring explored earlier on the branch, and leaves room for callers to design their own query context over time.max_rows,kind, andai_context, never the SQL text. Rejection/execution error paths log the reason but not the statement.--query-log <path>; the server appends one JSON line per executed statement (timestamp,sql,ai_context,max_rows) to that local file. Off by default. Because the file holds raw SQL (possibly secrets/PII), securing/rotating/retaining it is the operator's responsibility. It is the only sink that writes query text, so enabling it never leaks SQL to external collectors by accident.Implementation
crates/server/src/query_log.rs— append-onlyQueryLogsink (JSON Lines, mutex-guardedFile);recordtakes theai_contextobject. Write failures are logged and swallowed so a broken audit file never fails the query.crates/server/src/query_handlers.rs—ai_contextfield withvalidate_ai_context/validate_context_string(object shape, requiredpurpose/session_id, size caps), value-free audit marker, records to the query log before execution.crates/server/src/config.rs—--query-logCLI arg.crates/server/src/server.rs— opens the sink at startup and threads it intoAppState.docs/superpowers/specs/2026-07-24-query-purpose-and-raw-log-design.md(raw-SQL logging + original flatpurpose) anddocs/superpowers/specs/2026-07-28-query-ai-context-design.md(evolvespurposeinto the structuredai_context).docs/server.mdupdated. The docs also record the rejected alternatives explored earlier on this branch (SQL parameterization; a separate/parameterized_queryendpoint; a requiredai_context).Tests
crates/server/tests/query_http.rscoversai_contextaccepted (with free-form keys) / omitted, and every validation path (not an object, missingpurpose, missingsession_id, empty/over-longpurpose, over-longsession_id, over-size object), plus the operator query-log file (raw SQL +ai_contextrecorded when configured; nothing written when not).query_log.rshas unit tests for append/one-line-per-record.