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>
175 lines
5.8 KiB
Rust
175 lines
5.8 KiB
Rust
use axum::{
|
|
Json, Router,
|
|
extract::{Path, Query, State},
|
|
http::StatusCode,
|
|
response::IntoResponse,
|
|
routing::{get, post},
|
|
};
|
|
use serde::Deserialize;
|
|
|
|
use super::registry::{ToolInvocation, ToolRegistry};
|
|
use crate::tools::ToolState;
|
|
|
|
pub fn router(state: ToolState) -> Router {
|
|
Router::new()
|
|
.route("/", get(list_tools))
|
|
.route("/{name}", get(get_tool))
|
|
.route("/{name}/call", post(call_tool))
|
|
.route("/audit", get(audit_log))
|
|
.with_state(state)
|
|
}
|
|
|
|
/// List all available tools (for LLM tool-use discovery).
|
|
async fn list_tools(State(state): State<ToolState>) -> impl IntoResponse {
|
|
let tools = state.registry.list_tools().await;
|
|
// Return in MCP-compatible format
|
|
let tool_list: Vec<serde_json::Value> = tools.iter().map(|t| {
|
|
serde_json::json!({
|
|
"name": t.name,
|
|
"description": t.description,
|
|
"permission": t.permission,
|
|
"category": t.category,
|
|
"parameters": t.parameters.iter().map(|p| {
|
|
serde_json::json!({
|
|
"name": p.name,
|
|
"type": p.param_type,
|
|
"required": p.required,
|
|
"description": p.description,
|
|
"default": p.default,
|
|
})
|
|
}).collect::<Vec<_>>(),
|
|
})
|
|
}).collect();
|
|
Json(tool_list)
|
|
}
|
|
|
|
/// Get a specific tool definition.
|
|
async fn get_tool(
|
|
State(state): State<ToolState>,
|
|
Path(name): Path<String>,
|
|
) -> impl IntoResponse {
|
|
match state.registry.get_tool(&name).await {
|
|
Some(tool) => Ok(Json(tool)),
|
|
None => Err((StatusCode::NOT_FOUND, format!("tool not found: {name}"))),
|
|
}
|
|
}
|
|
|
|
/// Call a tool — validate params, execute SQL, log invocation.
|
|
#[derive(Deserialize)]
|
|
struct CallRequest {
|
|
params: serde_json::Value,
|
|
agent: String,
|
|
}
|
|
|
|
async fn call_tool(
|
|
State(state): State<ToolState>,
|
|
Path(name): Path<String>,
|
|
Json(req): Json<CallRequest>,
|
|
) -> impl IntoResponse {
|
|
let tool = match state.registry.get_tool(&name).await {
|
|
Some(t) => t,
|
|
None => return Err((StatusCode::NOT_FOUND, format!("tool not found: {name}"))),
|
|
};
|
|
|
|
tracing::info!("tool call: {} by agent '{}' with {:?}", name, req.agent, req.params);
|
|
|
|
// Build SQL from params
|
|
let sql = match ToolRegistry::build_sql(&tool, &req.params) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
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(e.clone()),
|
|
rows_returned: None,
|
|
}).await;
|
|
return Err((StatusCode::BAD_REQUEST, e));
|
|
}
|
|
};
|
|
|
|
// 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;
|
|
|
|
match result {
|
|
Ok((rows, row_count)) => {
|
|
state.registry.log_invocation(ToolInvocation {
|
|
id: format!("inv-{}", chrono::Utc::now().timestamp_millis()),
|
|
tool_name: name.clone(),
|
|
agent: req.agent,
|
|
params: req.params,
|
|
permission: tool.permission,
|
|
timestamp: chrono::Utc::now(),
|
|
success: true,
|
|
error: None,
|
|
rows_returned: Some(row_count),
|
|
}).await;
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"tool": name,
|
|
"rows": rows,
|
|
"row_count": row_count,
|
|
"sql": sql,
|
|
})))
|
|
}
|
|
Err(e) => {
|
|
state.registry.log_invocation(ToolInvocation {
|
|
id: format!("inv-{}", chrono::Utc::now().timestamp_millis()),
|
|
tool_name: name,
|
|
agent: req.agent,
|
|
params: req.params,
|
|
permission: tool.permission,
|
|
timestamp: chrono::Utc::now(),
|
|
success: false,
|
|
error: Some(e.clone()),
|
|
rows_returned: None,
|
|
}).await;
|
|
|
|
Err((StatusCode::INTERNAL_SERVER_ERROR, e))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct AuditQuery {
|
|
limit: Option<usize>,
|
|
}
|
|
|
|
async fn audit_log(
|
|
State(state): State<ToolState>,
|
|
Query(q): Query<AuditQuery>,
|
|
) -> impl IntoResponse {
|
|
let log = state.registry.recent_audit(q.limit.unwrap_or(50)).await;
|
|
Json(log)
|
|
}
|