P42-002: wire truth gate into queryd /sql + /paged SQL paths
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "journal event verified live (total_events_created 0→1 after probe)."
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "journal event verified live (total_events_created 0→1 after probe)."
The scrum master flagged crates/queryd/src/service.rs across iters 3-5
with the same finding: "raw SQL forwarded to DataFusion without schema
or policy gate; violates PRD §42-002 truth enforcement." Confidence
79-95%, gradient tier auto/dry_run. Applier couldn't touch it — the fix
is larger than 6 lines and crosses crate boundaries.
Hand-fix lands the missing enforcement point:
- truth: new RuleCondition::FieldContainsAny { field, needles } with
case-insensitive substring matching. 4 new unit tests cover the
positive, negative, missing-field, and empty-needles paths.
- truth: sql_query_guard_store() helper returns a baseline store that
rejects destructive verbs (DROP/TRUNCATE/DELETE FROM) and empty SQL.
- queryd: QueryState grows an Arc<TruthStore>; default router() loads
sql_query_guard_store; new router_with_truth(engine, store) lets
tests inject a custom store.
- queryd: sql_policy_check() runs truth.evaluate("sql_query", ctx)
before hitting DataFusion. Reject/Block actions on matched
conditions short-circuit to HTTP 403 with the rule's message.
Both /sql and /paged gated.
- queryd: 7 new tests cover block/allow/case-insensitive/false-
positive scenarios. "SELECT deleted_at FROM t" must NOT be rejected
(substring match is narrow: "delete from", not "delete").
Total: 28 truth tests green (was 24), 7 new queryd policy tests green.
Workspace baseline warnings unchanged at 11.
This is a signal-driven fix the mechanical pipeline couldn't produce
but the scrum master kept asking for. Closes one of four LOOPING files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,11 @@ pub enum RuleCondition {
|
||||
FieldMismatch { field: String, value: String },
|
||||
FieldEmpty { field: String },
|
||||
FieldGreater { field: String, threshold: i64 },
|
||||
// Case-insensitive substring scan — true if the field value contains
|
||||
// ANY of `needles`. Added for SQL/command guards where rules of the
|
||||
// form "sql must not contain DROP/DELETE/TRUNCATE" need to express
|
||||
// enforcement as a passing precondition being absent.
|
||||
FieldContainsAny { field: String, needles: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -124,6 +129,15 @@ fn evaluate_condition(cond: &RuleCondition, ctx: &serde_json::Value) -> bool {
|
||||
.map(|n| n > *threshold)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
RuleCondition::FieldContainsAny { field, needles } => {
|
||||
match field_as_string(ctx, field) {
|
||||
None => false,
|
||||
Some(s) => {
|
||||
let haystack = s.to_ascii_lowercase();
|
||||
needles.iter().any(|n| haystack.contains(&n.to_ascii_lowercase()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +161,49 @@ fn field_as_string(ctx: &serde_json::Value, path: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Minimal SQL guard — rejects destructive verbs (DROP/TRUNCATE/DELETE).
|
||||
/// queryd/src/service.rs loads this into its `QueryState` and evaluates
|
||||
/// every `/sql` request against it before hitting the DataFusion engine.
|
||||
/// This is the P42-002 enforcement point flagged across scrum iters 3-5
|
||||
/// ("raw SQL forwarded without schema or policy gate").
|
||||
///
|
||||
/// Intentionally narrow: it's a safety net, not a full SQL parser. If
|
||||
/// callers need richer AST-aware enforcement they should extend this with
|
||||
/// structured rules rather than new needles.
|
||||
pub fn sql_query_guard_store() -> TruthStore {
|
||||
let mut store = TruthStore::new();
|
||||
store.add_rule(TruthRule {
|
||||
id: "no-destructive-sql".to_string(),
|
||||
task_class: "sql_query".to_string(),
|
||||
description: "SQL must not contain destructive verbs".to_string(),
|
||||
condition: RuleCondition::FieldContainsAny {
|
||||
field: "sql".to_string(),
|
||||
needles: vec![
|
||||
"drop table".to_string(),
|
||||
"drop schema".to_string(),
|
||||
"drop database".to_string(),
|
||||
"truncate".to_string(),
|
||||
"delete from".to_string(),
|
||||
],
|
||||
},
|
||||
action: RuleAction::Reject {
|
||||
message: "destructive SQL rejected by truth.sql_query_guard".to_string(),
|
||||
},
|
||||
});
|
||||
store.add_rule(TruthRule {
|
||||
id: "sql-not-empty".to_string(),
|
||||
task_class: "sql_query".to_string(),
|
||||
description: "SQL must not be empty".to_string(),
|
||||
condition: RuleCondition::FieldEmpty {
|
||||
field: "sql".to_string(),
|
||||
},
|
||||
action: RuleAction::Reject {
|
||||
message: "empty SQL rejected".to_string(),
|
||||
},
|
||||
});
|
||||
store
|
||||
}
|
||||
|
||||
pub fn default_truth_store() -> TruthStore {
|
||||
let mut store = TruthStore::new();
|
||||
|
||||
@@ -520,4 +577,67 @@ mod tests {
|
||||
let actions = s.check("t");
|
||||
assert_eq!(actions.len(), 3, "check returns one action per rule regardless of condition");
|
||||
}
|
||||
|
||||
fn sql_guard_store() -> TruthStore {
|
||||
let mut s = TruthStore::new();
|
||||
s.add_rule(TruthRule {
|
||||
id: "no-destructive".into(),
|
||||
task_class: "sql_query".into(),
|
||||
description: "SQL must not contain destructive verbs".into(),
|
||||
condition: RuleCondition::FieldContainsAny {
|
||||
field: "sql".into(),
|
||||
needles: vec![
|
||||
"drop table".into(),
|
||||
"drop schema".into(),
|
||||
"truncate".into(),
|
||||
"delete from".into(),
|
||||
],
|
||||
},
|
||||
action: RuleAction::Reject {
|
||||
message: "destructive SQL rejected".into(),
|
||||
},
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_contains_any_matches_case_insensitively() {
|
||||
let s = sql_guard_store();
|
||||
let ctx = serde_json::json!({"sql": "SELECT * FROM t; DROP TABLE users;"});
|
||||
let o = s.evaluate("sql_query", &ctx);
|
||||
assert!(o[0].passed, "condition holds when needle present (case-insensitive)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_contains_any_is_false_when_no_needle_matches() {
|
||||
let s = sql_guard_store();
|
||||
let ctx = serde_json::json!({"sql": "SELECT count(*) FROM workers"});
|
||||
let o = s.evaluate("sql_query", &ctx);
|
||||
assert!(!o[0].passed, "benign SELECT should not match destructive needles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_contains_any_false_when_field_missing() {
|
||||
let s = sql_guard_store();
|
||||
let ctx = serde_json::json!({});
|
||||
let o = s.evaluate("sql_query", &ctx);
|
||||
assert!(!o[0].passed, "missing field → condition cannot hold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_contains_any_empty_needles_list_never_matches() {
|
||||
let mut s = TruthStore::new();
|
||||
s.add_rule(TruthRule {
|
||||
id: "empty".into(),
|
||||
task_class: "x".into(),
|
||||
description: "".into(),
|
||||
condition: RuleCondition::FieldContainsAny {
|
||||
field: "sql".into(),
|
||||
needles: vec![],
|
||||
},
|
||||
action: RuleAction::Pass,
|
||||
});
|
||||
let o = s.evaluate("x", &serde_json::json!({"sql": "anything"}));
|
||||
assert!(!o[0].passed, "no needles → any::<bool> is false");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user