QueryAgent turns natural language into SQL and executes it. The security design assumes the model's output is untrusted input — the same stance you would take toward user-supplied SQL.
- Destructive SQL — from model error, or from prompt injection: schema comments, table contents and database error messages all flow back into the prompt, and any of them can carry adversarial instructions ("ignore previous instructions and DROP …").
- Data modification via smuggling — multi-statement payloads
(
SELECT 1; DROP TABLE users), comment tricks, CTE wrappers. - Resource exhaustion — runaway queries (cartesian joins, unbounded recursion) starving the database.
- Indexed documents — when a knowledge base is configured, company handbooks flow into the prompt too. This is the easiest of the four for an insider to write to: anyone who can edit a Markdown file in an indexed directory can put "ignore previous instructions" in front of the model.
| layer | mechanism | trusts |
|---|---|---|
1. SQL whitelist (safety.py) |
single SELECT only (CTE allowed); DML/DDL, multi-statement and comment-smuggling rejected by token-level parsing, not regex | nothing the model says |
| 2. Connector limits | per-query timeout + row cap enforced in the driver layer (MAX_EXECUTION_TIME on MySQL, progress-handler deadline on SQLite) |
not the SQL that passed layer 1 |
| 3. Read-only account | documented setup: the agent's DB credentials have SELECT grants only |
not this codebase |
4. Evidence isolation (knowledge/) |
retrieval is scoped by workspace in the query itself; extraction runs with no tools; the model cites by index into an authorised menu and never emits a document identifier | nothing a document says |
Layer 3 is the backstop: even if a parser bug lets a write statement through layers 1–2, the database refuses it. This is why the README insists on a read-only account rather than treating it as optional hardening.
MySQL refuses to execute a query if setting its server execution timeout fails. Client read/write timeouts (30 seconds by default) and bounded pool waits cover stalled connections separately; a client timeout is not proof that the server query was cancelled. SELECT results stream at most the row cap plus one row to detect truncation. A truncated connection is discarded rather than draining its remaining rows into memory.
A row cap is not a scan, CPU or memory budget on the server. Expensive
joins and aggregations can still be costly before producing a row. Since
v0.9 a maintainer can set totals in budget:
(ADR-011): statements per
request, statements and client-measured seconds per subject per day,
requests executing at once, and — on ClickHouse only — the rows one
statement may read, enforced by the engine. A request is admitted before its
first statement or refused with nothing run. MySQL and SQLite have no scan
limit; the per-statement timeout is the only bound there, and configuring
one is refused at load rather than silently ignored. A per-subject allowance
is only as strong as the identity it is keyed on: locally, flow --subject
is the trusted user's own choice, so per-person allowances need an identity
supplied by a host.
The whitelist cannot stop an injected prompt from making the agent run a wrong but valid SELECT (e.g. exfiltrating more columns than the question needed, within the row cap). Mitigations are scope limits, not guarantees: row caps bound volume, the event stream makes every executed SQL visible and auditable, and deployments should grant the read-only account access only to tables the agent legitimately needs.
A confirmed 统计区间 reaches the database as bound parameters
(ADR-009, superseding
the typed compilation of ADR-008). The statement text is assembled from
maintainer fragments in query_mappings.yaml, identifiers validated when
that file loads, and ? placeholders; the dates travel beside it, each
decoded from a rule of the form YYYY-MM-DD..YYYY-MM-DD first. The
question's text, document rules and the user's own conventions are never
part of the statement. Mapping fragments are concatenated as written — the
trust whole-statement mappings always had — so a mapping change is a code
change and should be reviewed as one. The statement, with its placeholders,
passes the layer-1 whitelist before it runs.
What "bound" means depends on the driver: SQLite binds at the engine; PyMySQL and clickhouse-driver escape each value client-side and substitute it. That removes this project's own string building from the path; it is not a server-side prepared statement, and no stronger than the driver's escaper.
Each confirmed run of a structured mapping executes a second statement,
SELECT MAX(time_column) FROM table, generated from the same validated
identifiers, through the same whitelist and executor, and only after the
confirmation holds. It discloses one fact to whoever runs the query — the
date of the newest record in that table — which a deployment granting the
table should already consider readable. With
workflow.freshness_before_confirm: probe (off by default,
ADR-010) the same statement
may also run before confirmation, to warn on the sheet. It is the only
statement that can; it runs with its own short timeout, is charged to the
budget, and is cached per table — not per subject, since the answer is the
same for everyone who may query the table.
A value filter (「广告渠道」) is bound like the dates, and only a value a
maintainer declared under values: can become one; anything else is asked
about, so a question cannot choose what is compared.
历史选择 reads only the acting subject's own confirmed, successful runs in the same workspace; nothing one subject chose is offered to another. Those drafts are business data at rest in the workflow state file, like every other draft — deleting the file forgets them. A remembered choice that adopted a document is re-checked against the subject's current access before it is offered, and one that no longer checks out is dropped without saying which document it was.
Document import is the only filesystem read path in this codebase, and it
reads whatever it finds under a configured source. knowledge.root confines
every source and both sides are resolved, so a symlink inside the root
cannot point out of it — without that, a mis-set source could pull .env or
a credentials file into a model prompt.
What the code guarantees, and what it does not:
- Guaranteed: a rule marked 「文档依据」 cites a chunk that exists, that
this subject was authorised for, and that verbatim contains the quoted
text. The model selects by index into a menu the server built, so it
cannot name a document it was not shown. Extraction is called with no
tools. Executable SQL still comes only from a maintainer mapping. A
document can choose which mapping runs — when its verified quote
contains the words a mapping declares under
enforces:, and the documents agree — but the choice is shown as 「文档依据」 with its citation and still needs the user's confirmation, and the text itself never enters a query. - Not guaranteed: that the quoted text supports the rule stated. Whole entailment is a model judgement, not a boundary. Numbers in the rule must appear in the quote and unrelated citations are filtered by n-gram overlap, but neither detects a rule that quotes a qualifying clause and drops the sentence it qualified. The quote and its location are shown on the confirmation sheet because the final judgement is the reader's. The same holds for correspondence: 「已由所选口径执行」 means the quote contains words the maintainer declared for that reading, not that its meaning was verified. A maintainer who declares broad words (「日期」) makes unrelated sentences correspond; the words are reviewed with the mapping file, and a quote containing two readings' words claims neither.
Semantic retrieval, when enabled, sends document text to the configured embeddings endpoint. It is off unless configured.
By default every chat / ask run writes its full event stream — question,
SQL, observations (including result rows) and token usage — to
.queryagent/traces/*.jsonl. Against a real database that is business data
at rest on the local filesystem. With a knowledge base configured, document
excerpts are part of that too.
Mitigations: a stderr notice on the first write, --no-trace and config
trace: false, a 50-file retention cap, and .queryagent/ in .gitignore
so traces cannot be committed by accident. Reasoning for the default —
including why partial redaction was rejected — is in
ADR-005. On sensitive data, set
trace: false.
API keys are read exclusively from environment variables
(ANTHROPIC_API_KEY / OPENAI_API_KEY / QUERYAGENT_EMBEDDING_API_KEY);
the config loader actively rejects credential-looking keys in every section
of config.yaml, not only the LLM one. Demo database credentials are
throwaway local defaults that exist only in docker-compose.yml.
Open a GitHub issue for non-sensitive reports. For anything sensitive, contact the maintainer directly (see repo profile) before public disclosure.