gateway/tools: truth gate for model-provided SQL (iter 11 CF-1+CF-2)

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<TruthStore> 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) <noreply@anthropic.com>
This commit is contained in:
root 2026-04-24 13:52:29 -05:00
parent de8fb10f52
commit 6532938e85
3 changed files with 33 additions and 0 deletions

View File

@ -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

View File

@ -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<TruthStore>,
}
/// Wraps QueryEngine to provide a simple execute interface for tools.

View File

@ -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;