From 6532938e850fe0029c3ce46d3c4942c8dd24cb40 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 24 Apr 2026 13:52:29 -0500 Subject: [PATCH] gateway/tools: truth gate for model-provided SQL (iter 11 CF-1+CF-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrum iter 11 flagged crates/gateway/src/tools/service.rs with two 95%-confidence critical failures: CF-1: "Direct SQL execution from model-provided parameters without explicit validation or sanitization" (line 68, 95% conf) CF-2: "No permission check performed before executing SQL query; access control is bypassed entirely" (line 102, 90% conf) CF-1 is the real one — same security gap as queryd /sql had before P42-002 (9cc0ceb). Tool invocations build SQL from a template + model-provided params, then state.query_fn.execute(&sql) runs it. No truth-gate check between build and execute meant an adversarial model could emit DROP TABLE / DELETE FROM / TRUNCATE inside a param and bypass queryd's gate by routing through the tool surface instead. Fix mirrors the queryd SQL gate exactly: - ToolState grows an Arc field - main.rs constructs it via truth::sql_query_guard_store() (shared default — same destructive-verb block as queryd) - call_tool evaluates the built SQL against "sql_query" task class BEFORE executing - Any Reject/Block outcome → 403 FORBIDDEN + log_invocation row marked success=false with the rule message CF-2 (access control) is P13-001 territory — needs AccessControl wiring into queryd first, still open. Flagged in memory. Workspace warnings still at 0. Pattern is now: queryd /sql → truth::sql_query_guard_store (9cc0ceb) gateway /tools → truth::sql_query_guard_store (this commit) execution_loop → truth::default_truth_store (51a1aa3) All three surfaces that pipe SQL or spec-shaped data through to the substrate now gate it. Any new SQL-executing surface should follow the same pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/gateway/src/main.rs | 1 + crates/gateway/src/tools/mod.rs | 6 ++++++ crates/gateway/src/tools/service.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/crates/gateway/src/main.rs b/crates/gateway/src/main.rs index ac5f6e3..a88aa38 100644 --- a/crates/gateway/src/main.rs +++ b/crates/gateway/src/main.rs @@ -196,6 +196,7 @@ async fn main() { tools::ToolState { registry: tool_reg, query_fn: tools::QueryExecutor::new(engine.clone()), + truth: std::sync::Arc::new(truth::sql_query_guard_store()), } })) // Phase 38 — Universal API skeleton. Thin OpenAI-compatible diff --git a/crates/gateway/src/tools/mod.rs b/crates/gateway/src/tools/mod.rs index 2f316f6..f96dd93 100644 --- a/crates/gateway/src/tools/mod.rs +++ b/crates/gateway/src/tools/mod.rs @@ -3,12 +3,18 @@ pub mod service; use queryd::context::QueryEngine; use arrow::json::writer::{JsonArray, Writer as JsonWriter}; +use std::sync::Arc; +use truth::TruthStore; /// State for the tool system. #[derive(Clone)] pub struct ToolState { pub registry: registry::ToolRegistry, pub query_fn: QueryExecutor, + /// SQL guard (shared with queryd). Mirrors the queryd /sql truth + /// gate from P42-002 (9cc0ceb) — tools also execute model- + /// originated SQL, need the same destructive-verb block. + pub truth: Arc, } /// Wraps QueryEngine to provide a simple execute interface for tools. diff --git a/crates/gateway/src/tools/service.rs b/crates/gateway/src/tools/service.rs index e5b75d1..ef8e099 100644 --- a/crates/gateway/src/tools/service.rs +++ b/crates/gateway/src/tools/service.rs @@ -92,6 +92,32 @@ async fn call_tool( } }; + // Truth gate — same contract as queryd /sql (P42-002). Rejects + // destructive verbs + empty SQL. Scrum iter 11 CF-1 + CF-2 on this + // file: tools executed model-provided SQL parameters without any + // validation. Close the gap here so the parallel surface has the + // same safety floor as queryd. + let ctx = serde_json::json!({ "sql": sql }); + for outcome in state.truth.evaluate("sql_query", &ctx) { + if outcome.passed { + if let truth::RuleAction::Reject { message } | truth::RuleAction::Block { message } = &outcome.action { + tracing::warn!("tool {name}: SQL blocked by truth gate ({}): {message}", outcome.rule_id); + state.registry.log_invocation(ToolInvocation { + id: format!("inv-{}", chrono::Utc::now().timestamp_millis()), + tool_name: name.clone(), + agent: req.agent.clone(), + params: req.params.clone(), + permission: tool.permission.clone(), + timestamp: chrono::Utc::now(), + success: false, + error: Some(format!("truth gate: {message}")), + rows_returned: None, + }).await; + return Err((StatusCode::FORBIDDEN, message.clone())); + } + } + } + // Execute via query engine let result = state.query_fn.execute(&sql).await;