Documentation
¶
Overview ¶
Package safety implements the Tier 3 statement allowlist that gates every cluster-bound command in crdb-sql (today: explain, simulate, execute). Defense-in-depth at the MCP/CLI layer: even if the downstream cluster's role would permit a write, a SELECT-only Mode rejects the statement before any pgwire round-trip.
The package is split into three concerns:
mode.go — the Mode enum (read_only, safe_write, full_access)
and ParseMode for flag/parameter validation.
allowlist.go — Check, the pure AST classifier that decides whether
a statement is permitted under a given Mode and
Operation.
envelope.go — Envelope, the helper that converts a Violation into
the structured output.Error agents consume.
allowlist.go has no dependency on internal/output, so the classification logic stays unit-testable without dragging in the CLI envelope. envelope.go is the single bridge between the two.
Design doc reference: §Safety Model (read_only is the default, safe_write and full_access are opt-in escalations). Issue #21 wired read_only end-to-end; issues #29, #151, #152, and #167 wired safe_write/full_access for OpExecute and OpExplain (where #167 folded the former OpExplainDDL admission rules into OpExplain so explain_sql auto-dispatches DDL to EXPLAIN (DDL, SHAPE)). OpSimulate still reports "not yet implemented" for safe_write/full_access — wiring it is follow-up work.
Index ¶
Constants ¶
const DefaultMode = ModeReadOnly
DefaultMode is the safety mode applied when a caller does not set one. Every Tier 3 surface (CLI flag, MCP parameter) defaults to ModeReadOnly — explicit opt-in is required for any path that could reach a write.
Variables ¶
This section is empty.
Functions ¶
func Envelope ¶
Envelope converts a Violation into the structured output.Error that CLI and MCP surfaces emit. Centralising the conversion guarantees that the two surfaces produce byte-identical error shapes for the same rejection — agents can rely on the Code, Severity, and Context keys regardless of how they invoked the tool.
Context keys: tag, mode, operation, reason. These mirror Violation's fields but use the same lowercase wire tokens already used elsewhere in the envelope.
Suggestions: when the violation can be unblocked by escalating the safety mode (read_only on either op), a single Suggestion entry points the agent at the higher-mode escape hatch. The Range is zero because the suggestion is a flag value, not a SQL edit; agents that only know how to apply byte-range edits will skip it harmlessly.
func MaybeInjectLimit ¶
MaybeInjectLimit returns sql with an injected LIMIT clause when the input is a single bare SELECT without a LIMIT. The boolean reports whether an injection happened so the caller can surface it in the result envelope (transparency: agents must know the cluster did not return all rows).
The rewriter is conservative — it only fires when:
- maxRows > 0 (zero or negative means "unlimited", so skip).
- sql parses to exactly one statement (multi-statement batches are left alone; it's not obvious which one to bound).
- the statement is *tree.Select whose result is unbounded (no existing Count and no LIMIT ALL — see selectIsBounded). Note that tree.Limit holds both Count and Offset, so a SELECT with OFFSET but no LIMIT still has a non-nil Limit pointer; the bounded check looks at Count specifically rather than the pointer's nil-ness.
Anything else — DML, DDL, parse errors, EXPLAIN wrappers — returns the input unchanged with injected=false. Callers that already ran safety.Check can treat the error path as unreachable; we still surface parser errors rather than silently swallowing them so a caller skipping Check won't get mysterious behaviour.
See MaybeInjectLimitParsed for callers (cmd/exec.go, internal/mcp/tools/execute.go) that already ran parser.Parse and want to skip a second client-side parse.
func MaybeInjectLimitParsed ¶
func MaybeInjectLimitParsed(stmts statements.Statements, maxRows int) (string, bool)
MaybeInjectLimitParsed is the parsed-input variant of MaybeInjectLimit. Callers that already invoked parser.Parse use it to avoid a second parse. Mirrors summarize.Parsed / sqlformat.FormatParsed in shape: the parsed-input variant drops the parse-error return since the parse already succeeded upstream.
Return contract: on injection (rewritten, true). On no-injection — including the maxRows<=0, multi-statement, non-Select, and already-bounded cases — ("", false). Callers MUST keep their own SQL string and fall back to it on false; the parsed variant deliberately does not round-trip the AST back through tree.AsStringWithFlags on the no-injection path so it cannot reformat or drop comments. Nil-string-on-false is loud-by-design: a caller that writes `rewritten, _ = MaybeInjectLimitParsed(...)` would dispatch an empty query rather than fail-silently to the original input, surfacing the contract violation immediately.
Ownership: stmts[0].AST is mutated in place when injection fires (the slice itself is not retained past return). The same AST cannot be safely re-fed to a downstream consumer that expects the pre-injection shape, so run version.Inspect / safety.CheckParsed on the AST first, then call MaybeInjectLimitParsed last.
Types ¶
type Mode ¶
type Mode string
Mode names the safety policy applied to a Tier 3 command. Values are the lowercase strings agents pass on the wire so the same token works across the CLI --mode flag and the MCP tool parameter.
const ( ModeReadOnly Mode = "read_only" ModeSafeWrite Mode = "safe_write" ModeFullAccess Mode = "full_access" )
Mode values. ModeReadOnly is the default for every Tier 3 command; the other two are recognised by ParseMode and admitted by Check for OpExecute (#29) and OpExplain (#151, #152, #167 — OpExplain auto-dispatches DDL to EXPLAIN (DDL, SHAPE) under safe_write/ full_access). For OpSimulate, safe_write and full_access still report "not yet implemented".
func ParseMode ¶
ParseMode validates a user-supplied mode token and returns the canonical Mode. The empty string maps to DefaultMode so callers can pass a flag value through unconditionally without needing to special-case "unset". Any other unrecognised value produces an error that names the valid choices, so the message a user sees on a typo is actionable on its own.
safe_write and full_access parse successfully for every Op even though Check rejects them for OpSimulate today — the split keeps the flag-parsing layer stable so the only churn when those modes land for OpSimulate is inside Check.
type Operation ¶
type Operation int
Operation identifies the cluster-bound surface that is about to run the user's SQL. The allowlist applies a different rule per (Mode, Operation) pair. OpExplain wraps a single statement in the right EXPLAIN flavor (plain `EXPLAIN` for SELECT/DML, `EXPLAIN (DDL, SHAPE)` for DDL); read_only rejects DDL there, safe_write admits it. OpSimulate dispatches each statement through a non-executing EXPLAIN flavour, so it admits DDL/DML/SELECT under read_only without ever reaching cluster execution. OpExecute, in contrast, admits the read-only set under read_only, adds DML under safe_write, and admits anything that parses under full_access — the full per-mode matrix in the design doc.
Operation values. New surfaces extend this enum; the existing rules stay untouched.
type Violation ¶
type Violation struct {
// Tag is the cockroachdb-parser StatementTag for the offending
// statement (e.g. "DROP TABLE", "INSERT", "SELECT"). Stable across
// CRDB versions for any given statement type. Empty for the
// empty-input defensive case (no statement to tag).
Tag string
// Reason is a short human-readable explanation of why the
// statement was rejected (e.g. "writes data", "modifies schema",
// "expected DDL"). Embedded into the rendered Message; agents
// should branch on Kind/Mode/Operation/Tag rather than Reason.
Reason string
// Mode is the safety mode that was in effect when the violation
// was raised. Lets agents recognise that escalating to a higher
// mode would unblock the call.
Mode Mode
// Op is the cluster-bound surface that triggered the check. Lets
// agents distinguish "explain rejected DROP TABLE under read_only"
// from "execute rejected INSERT under read_only", which point at
// different fixes.
Op Operation
// Kind is the structural reason for the rejection. Used by
// envelope.suggestionsFor to pick the smallest mode escalation
// that would admit the call (or to skip the suggestion entirely
// when no escalation helps).
Kind ViolationKind
}
Violation is the structured payload returned by Check when a statement is rejected. It carries everything an agent (or the envelope helper) needs to render an actionable error without reparsing the input.
Lifecycle: built once by Check, consumed once by Envelope (for the CLI/MCP path) or by tests. No long-lived state.
func Check ¶
Check parses sql and decides whether every statement in it is permitted under (mode, op). It is the single decision point for the allowlist: every Tier 3 surface (CLI, MCP) calls Check before opening a connection.
The check is pure — no I/O, no cluster access — so it is cheap to run on every request and easy to unit-test. The same parser used elsewhere in crdb-sql (parser.Parse) is used here, so the classification cannot drift from what downstream consumers see.
Return contract (the first three cases are mutually exclusive — Check never returns both a non-nil *Violation and a non-nil error):
- (nil, nil) — sql parses to one or more statements and every one is permitted.
- (nil, err) — sql failed to parse. The caller should surface this as a parse-error diagnostic (diag.FromParseError) rather than as a safety violation: the input is malformed, not denied. The error is the raw parser error so SQLSTATE and position survive.
- (*Violation, nil) — sql parses but is denied. Either the first offending statement triggers a rule (multi-statement inputs short-circuit on the first reject) or the batch is empty (a defensive case so empty input cannot bypass the gate).
Multi-statement inputs surface the first violation. This matches the "fail closed" discipline: if any statement in a batch would be rejected, the batch is rejected. Agents that care which statement failed can read Violation.Tag and re-issue individually.
func CheckParsed ¶
func CheckParsed(mode Mode, op Operation, stmts statements.Statements) *Violation
CheckParsed is the parsed-input variant of Check. Callers that already invoked parser.Parse (e.g. to also run version.Inspect on the same AST) use it to avoid a second parse.
Return contract: nil when every statement in stmts is permitted under (mode, op), or *Violation describing the first offending statement (multi-statement inputs short-circuit on the first reject). The empty-batch defensive case from Check is preserved so a caller that hands in an empty Statements slice still gets the "no statements parsed" sentinel rather than a silent pass.
type ViolationKind ¶
type ViolationKind int
ViolationKind labels the structural reason a statement was rejected, separate from the human-readable Reason text. Code that needs to act on the rejection (e.g. envelope.suggestionsFor picking the smallest mode that would admit the call) branches on Kind so the decision is not coupled to specific Reason wording.
const ( // KindOther covers rejections that do not need fine-grained // classification — currently the empty-input defensive case, the // unknown-mode programmer-error case, and the unknown-Operation // programmer-error case in classifyReadOnly. None can be // unblocked by escalating mode, so suggestionsFor emits no // escalation hint for them. KindOther ViolationKind = iota + 1 // KindWrite labels statements rejected because they would mutate // data (INSERT/UPDATE/UPSERT/DELETE/TRUNCATE). Escalating to // safe_write unblocks these. KindWrite // KindSchema labels statements rejected because they would // mutate schema (CREATE/ALTER/DROP). Escalating to full_access // unblocks these — safe_write does not. KindSchema // KindPrivilege labels privilege and identity statements // (GRANT/REVOKE, CREATE/DROP/ALTER ROLE, ownership and default- // privilege changes). The classic SQL Data Control Language set. // Escalating to full_access unblocks these — safe_write rejects // privilege management even though the parser also tags GRANT // and friends as schema-modifying. KindPrivilege // KindClusterAdmin labels statements that the parser also tags // TypeDCL but that aren't privilege/role changes — cluster // configuration (SET CLUSTER SETTING, SET TRACING), zone // configuration (ALTER ... CONFIGURE ZONE), and tenant lifecycle // (CREATE/DROP/ALTER TENANT). Treated as a separate Kind from // KindPrivilege so the rejection Reason names what the user is // actually doing rather than misleading them about a privilege // change. Escalates to full_access. KindClusterAdmin // KindNestedExplain labels EXPLAIN/EXPLAIN ANALYZE wrappers // rejected to prevent the inner statement from sneaking past // CanWriteData / CanModifySchema. Mode escalation does not help // — the user must pass the inner statement directly. KindNestedExplain // KindUnimplemented labels (mode, op) pairs that the package // recognises at the flag layer but does not yet wire (today: // safe_write/full_access for OpSimulate only). The fix is for // the upstream feature to land, not for the user to escalate. KindUnimplemented // KindBadOpInput labels rejections caused by the user passing the // wrong shape of input for the operation — currently TCL/DCL // statements to OpSimulate, which has no EXPLAIN route for them. KindBadOpInput )
ViolationKind values. Start at one so the zero value is invalid; Check always sets a meaningful kind on every Violation it produces.