diff --git a/drift v3/crates/drift-engine/src/candidate_command.rs b/drift v3/crates/drift-engine/src/candidate_command.rs index 9317685d..52ecdd28 100644 --- a/drift v3/crates/drift-engine/src/candidate_command.rs +++ b/drift v3/crates/drift-engine/src/candidate_command.rs @@ -105,6 +105,15 @@ pub fn infer_candidates(request: CandidateRequest) -> CandidateResult { "forbidden_imports": forbidden_imports, "applies_to_file_roles": ["api_route"] }); + let evidence_refs = combined_evidence_refs( + &request.scan.scan_id, + &data_imports, + &graph_data_imports, + &file_hashes, + "supporting", + ); + let counterexample_refs = Vec::new(); + let evidence_fingerprint = evidence_fingerprint(&evidence_refs); candidates.push(EngineCandidate { candidate_id: candidate_id( &request.repo.repo_id, @@ -124,8 +133,9 @@ pub fn infer_candidates(request: CandidateRequest) -> CandidateResult { .to_string(), scope, matcher, + requires: None, suggested_severity: "error".to_string(), - suggested_enforcement_mode: "block".to_string(), + suggested_enforcement_mode: "warn".to_string(), enforcement_capability: "deterministic_check".to_string(), confidence_label: "high".to_string(), scoring: scoring( @@ -140,14 +150,10 @@ pub fn infer_candidates(request: CandidateRequest) -> CandidateResult { "import_resolution".to_string(), "route_detection".to_string(), ], - evidence_refs: combined_evidence_refs( - &request.scan.scan_id, - &data_imports, - &graph_data_imports, - &file_hashes, - "supporting", - ), - counterexample_refs: Vec::new(), + evidence_refs, + counterexample_refs, + reason_not_blocking: "candidate_not_accepted".to_string(), + evidence_fingerprint, }); } @@ -172,6 +178,20 @@ pub fn infer_candidates(request: CandidateRequest) -> CandidateResult { }, "applies_to_file_roles": ["api_route"] }); + let evidence_refs = evidence_refs( + &request.scan.scan_id, + &service_imports, + &file_hashes, + "supporting", + ); + let counterexample_refs = combined_evidence_refs( + &request.scan.scan_id, + &data_imports, + &graph_data_imports, + &file_hashes, + "counterexample", + ); + let evidence_fingerprint = evidence_fingerprint(&evidence_refs); candidates.push(EngineCandidate { candidate_id: candidate_id(&request.repo.repo_id, "api_route_requires_service_delegation", &matcher), candidate_version: 1, @@ -190,6 +210,7 @@ pub fn infer_candidates(request: CandidateRequest) -> CandidateResult { }.to_string(), scope, matcher, + requires: None, suggested_severity: "warning".to_string(), suggested_enforcement_mode: "warn".to_string(), enforcement_capability: "heuristic_check".to_string(), @@ -206,11 +227,21 @@ pub fn infer_candidates(request: CandidateRequest) -> CandidateResult { "import_resolution".to_string(), "graph_stream".to_string(), ], - evidence_refs: evidence_refs(&request.scan.scan_id, &service_imports, &file_hashes, "supporting"), - counterexample_refs: combined_evidence_refs(&request.scan.scan_id, &data_imports, &graph_data_imports, &file_hashes, "counterexample"), + evidence_refs, + counterexample_refs, + reason_not_blocking: "candidate_not_accepted".to_string(), + evidence_fingerprint, }); } + candidates.extend(security_candidates( + &request, + &api_route_files, + scope_file_count, + &file_hashes, + &graph_fingerprint, + )); + let mut stats = engine_stats( 0, 0, @@ -255,6 +286,824 @@ fn is_data_access_source(source: &str) -> bool { || lower.contains("data-access") } +fn security_candidates( + request: &CandidateRequest, + api_route_files: &BTreeSet<&str>, + scope_file_count: usize, + file_hashes: &BTreeMap<&str, &str>, + graph_fingerprint: &str, +) -> Vec { + let mut candidates = Vec::new(); + let route_scope = json!({ + "path_globs": ["**/app/api/**/route.ts", "**/app/api/**/route.tsx", "**/pages/api/**/*.ts"], + "file_roles": ["api_route"] + }); + + for (symbol, facts) in grouped_route_facts(request, api_route_files, "symbol_called") + .into_iter() + .filter(|(symbol, facts)| facts.len() >= 2 && is_auth_candidate_symbol(symbol)) + { + let matcher = json!({ + "kind": "api_route_requires_auth_helper", + "required_calls": [symbol], + "applies_to_file_roles": ["api_route"] + }); + let requires = json!({ + "auth_helpers": [{ + "guard_id": format!("auth:{symbol}"), + "symbol": symbol, + "import": import_source_for_symbol(request, &facts[0].file_path, &symbol) + }], + "dominates": ["data_operation", "response"] + }); + candidates.push(security_candidate_from_facts(SecurityCandidateInput { + request, + kind: "api_route_requires_auth_helper", + statement: format!("API routes appear to use `{symbol}` as an auth helper."), + rationale: "Detected repeated auth-like helper calls in API routes.", + scope: route_scope.clone(), + matcher, + requires: Some(requires), + suggested_severity: "warning", + enforcement_capability: "deterministic_check", + confidence_label: "medium", + facts, + scope_file_count, + file_hashes, + graph_fingerprint, + heuristic_id: "security-auth-helper-usage-v1", + required_capabilities: &["syntax_facts", "security_auth"], + })); + } + + push_request_validation_candidates(RequestValidationCandidateInput { + candidates: &mut candidates, + request, + api_route_files, + scope_file_count, + file_hashes, + graph_fingerprint, + route_scope: &route_scope, + fact_kind: "symbol_called", + symbol_filter: is_validation_candidate_symbol, + }); + let middleware_facts = route_facts(request, api_route_files, "middleware_protects_route"); + if !middleware_facts.is_empty() { + let route_paths = unique_json_strings(&middleware_facts, "route_path"); + let middleware_ids = unique_json_strings(&middleware_facts, "middleware_id"); + let matcher = json!({ + "kind": "middleware_must_cover_routes", + "route_paths": route_paths, + "middleware_ids": middleware_ids, + "applies_to_file_roles": ["api_route"] + }); + candidates.push(security_candidate_from_facts(SecurityCandidateInput { + request, + kind: "middleware_must_cover_routes", + statement: "API routes appear to rely on middleware protection.".to_string(), + rationale: "Detected static middleware-to-route protection facts.", + scope: route_scope.clone(), + matcher, + requires: Some(json!({})), + suggested_severity: "warning", + enforcement_capability: "deterministic_check", + confidence_label: "medium", + facts: middleware_facts, + scope_file_count, + file_hashes, + graph_fingerprint, + heuristic_id: "security-middleware-protection-v1", + required_capabilities: &["syntax_facts", "middleware_coverage"], + })); + } + + for (symbol, facts) in + grouped_route_facts(request, api_route_files, "request_validation_called") + .into_iter() + .filter(|(_, facts)| facts.len() >= 2) + { + let matcher = json!({ + "kind": "api_route_requires_request_validation", + "applies_to_file_roles": ["api_route"], + "methods": ["POST", "PUT", "PATCH", "DELETE"] + }); + let requires = json!({ + "input_sources": ["body", "query", "params"], + "sinks": ["data_operation", "response"], + "validators": [{ + "validator_id": format!("validator:{symbol}"), + "symbol": symbol, + "import": import_source_for_symbol(request, &facts[0].file_path, &symbol) + }], + "schemas": [], + "allow_throwing_parse": true, + "allow_safe_parse_success_guard": true + }); + candidates.push(security_candidate_from_facts(SecurityCandidateInput { + request, + kind: "api_route_requires_request_validation", + statement: format!( + "Mutation API routes appear to validate request input with `{symbol}`." + ), + rationale: "Detected repeated request validation facts.", + scope: route_scope.clone(), + matcher, + requires: Some(requires), + suggested_severity: "warning", + enforcement_capability: "deterministic_check", + confidence_label: "medium", + facts, + scope_file_count, + file_hashes, + graph_fingerprint, + heuristic_id: "security-request-validation-v1", + required_capabilities: &["syntax_facts", "request_validation"], + })); + } + + push_guard_candidate(GuardCandidateInput { + candidates: &mut candidates, + request, + api_route_files, + scope_file_count, + file_hashes, + graph_fingerprint, + route_scope: &route_scope, + fact_kind: "authorization_guard_called", + candidate_kind: "api_route_requires_authorization", + requires_key: "authorization_helpers", + capability: "authorization", + heuristic_id: "security-authorization-helper-v1", + symbol_filter: always_candidate_symbol, + requires_module_key: false, + }); + push_guard_candidate(GuardCandidateInput { + candidates: &mut candidates, + request, + api_route_files, + scope_file_count, + file_hashes, + graph_fingerprint, + route_scope: &route_scope, + fact_kind: "symbol_called", + candidate_kind: "api_route_requires_authorization", + requires_key: "authorization_helpers", + capability: "authorization", + heuristic_id: "security-authorization-helper-v1", + symbol_filter: is_authorization_candidate_symbol, + requires_module_key: false, + }); + push_guard_candidate(GuardCandidateInput { + candidates: &mut candidates, + request, + api_route_files, + scope_file_count, + file_hashes, + graph_fingerprint, + route_scope: &route_scope, + fact_kind: "tenant_guard_called", + candidate_kind: "api_route_requires_tenant_scope", + requires_key: "tenant_helpers", + capability: "tenant_scope", + heuristic_id: "security-tenant-helper-v1", + symbol_filter: always_candidate_symbol, + requires_module_key: false, + }); + push_guard_candidate(GuardCandidateInput { + candidates: &mut candidates, + request, + api_route_files, + scope_file_count, + file_hashes, + graph_fingerprint, + route_scope: &route_scope, + fact_kind: "symbol_called", + candidate_kind: "api_route_requires_tenant_scope", + requires_key: "tenant_helpers", + capability: "tenant_scope", + heuristic_id: "security-tenant-helper-v1", + symbol_filter: is_tenant_candidate_symbol, + requires_module_key: false, + }); + push_serializer_candidate(SerializerCandidateInput { + candidates: &mut candidates, + request, + api_route_files, + scope_file_count, + file_hashes, + graph_fingerprint, + route_scope: &route_scope, + fact_kind: "serializer_called", + symbol_filter: always_candidate_symbol, + }); + push_serializer_candidate(SerializerCandidateInput { + candidates: &mut candidates, + request, + api_route_files, + scope_file_count, + file_hashes, + graph_fingerprint, + route_scope: &route_scope, + fact_kind: "symbol_called", + symbol_filter: is_serializer_candidate_symbol, + }); + + let sensitive_facts = route_facts(request, api_route_files, "sensitive_field_declared"); + if !sensitive_facts.is_empty() { + let fields = sensitive_facts + .iter() + .map(|fact| { + json!({ + "field_path": json_string_field(fact, "field_path").unwrap_or_else(|| fact.name.clone()), + "classification": json_string_field(fact, "classification").unwrap_or_else(|| "internal".to_string()), + "source": "candidate" + }) + }) + .collect::>(); + let matcher = json!({ + "kind": "api_route_forbids_sensitive_response_fields", + "applies_to_file_roles": ["api_route"] + }); + candidates.push(security_candidate_from_facts(SecurityCandidateInput { + request, + kind: "api_route_forbids_sensitive_response_fields", + statement: + "API responses appear to include sensitive fields that need an accepted policy." + .to_string(), + rationale: "Detected candidate sensitive response field facts.", + scope: route_scope.clone(), + matcher, + requires: Some(json!({ "sensitive_response_fields": fields })), + suggested_severity: "warning", + enforcement_capability: "deterministic_check", + confidence_label: "low", + facts: sensitive_facts, + scope_file_count, + file_hashes, + graph_fingerprint, + heuristic_id: "security-sensitive-field-v1", + required_capabilities: &["syntax_facts", "sensitive_response"], + })); + } + + push_guard_candidate(GuardCandidateInput { + candidates: &mut candidates, + request, + api_route_files, + scope_file_count, + file_hashes, + graph_fingerprint, + route_scope: &route_scope, + fact_kind: "parameterized_sql_used", + candidate_kind: "api_route_forbids_raw_sql_without_params", + requires_key: "raw_sql_safe_wrappers", + capability: "raw_sql", + heuristic_id: "security-raw-sql-safe-wrapper-v1", + symbol_filter: always_candidate_symbol, + requires_module_key: false, + }); + for (symbol, facts) in grouped_route_facts(request, api_route_files, "symbol_called") + .into_iter() + .filter(|(symbol, facts)| facts.len() >= 2 && is_ssrf_candidate_symbol(symbol)) + { + let matcher = json!({ + "kind": "api_route_forbids_untrusted_ssrf", + "required_calls": [symbol], + "applies_to_file_roles": ["api_route"] + }); + let requires = json!({ + "outbound_url_allowlist_helpers": [{ + "helper_id": format!("ssrf:{symbol}"), + "symbol": symbol, + "module": import_source_for_symbol(request, &facts[0].file_path, &symbol) + }] + }); + candidates.push(security_candidate_from_facts(SecurityCandidateInput { + request, + kind: "api_route_forbids_untrusted_ssrf", + statement: format!( + "API routes appear to use `{symbol}` as an outbound URL allowlist helper." + ), + rationale: "Detected repeated SSRF allowlist-like helper calls.", + scope: route_scope.clone(), + matcher, + requires: Some(requires), + suggested_severity: "warning", + enforcement_capability: "deterministic_check", + confidence_label: "medium", + facts, + scope_file_count, + file_hashes, + graph_fingerprint, + heuristic_id: "security-ssrf-allowlist-v1", + required_capabilities: &["syntax_facts", "outbound_request_facts"], + })); + } + push_guard_candidate(GuardCandidateInput { + candidates: &mut candidates, + request, + api_route_files, + scope_file_count, + file_hashes, + graph_fingerprint, + route_scope: &route_scope, + fact_kind: "csrf_guard_called", + candidate_kind: "api_route_requires_csrf_for_mutation", + requires_key: "csrf_helpers", + capability: "csrf", + heuristic_id: "security-csrf-helper-v1", + symbol_filter: always_candidate_symbol, + requires_module_key: true, + }); + push_guard_candidate(GuardCandidateInput { + candidates: &mut candidates, + request, + api_route_files, + scope_file_count, + file_hashes, + graph_fingerprint, + route_scope: &route_scope, + fact_kind: "symbol_called", + candidate_kind: "api_route_requires_csrf_for_mutation", + requires_key: "csrf_helpers", + capability: "csrf", + heuristic_id: "security-csrf-helper-v1", + symbol_filter: is_csrf_candidate_symbol, + requires_module_key: true, + }); + push_guard_candidate(GuardCandidateInput { + candidates: &mut candidates, + request, + api_route_files, + scope_file_count, + file_hashes, + graph_fingerprint, + route_scope: &route_scope, + fact_kind: "rate_limit_guard_called", + candidate_kind: "api_route_requires_rate_limit", + requires_key: "rate_limit_helpers", + capability: "rate_limit", + heuristic_id: "security-rate-limit-helper-v1", + symbol_filter: always_candidate_symbol, + requires_module_key: true, + }); + push_guard_candidate(GuardCandidateInput { + candidates: &mut candidates, + request, + api_route_files, + scope_file_count, + file_hashes, + graph_fingerprint, + route_scope: &route_scope, + fact_kind: "symbol_called", + candidate_kind: "api_route_requires_rate_limit", + requires_key: "rate_limit_helpers", + capability: "rate_limit", + heuristic_id: "security-rate-limit-helper-v1", + symbol_filter: is_rate_limit_candidate_symbol, + requires_module_key: true, + }); + + let cors_facts = route_facts(request, api_route_files, "cors_policy_declared"); + if !cors_facts.is_empty() { + let allowed_origins = cors_facts + .iter() + .filter_map(|fact| cors_origin_field(fact)) + .filter(|origin| origin != "*") + .collect::>() + .into_iter() + .collect::>(); + let allow_credentials = cors_facts + .iter() + .any(|fact| cors_credentials_field(fact).unwrap_or(false)); + let matcher = json!({ + "kind": "api_route_cors_must_match_policy", + "applies_to_file_roles": ["api_route"] + }); + candidates.push(security_candidate_from_facts(SecurityCandidateInput { + request, + kind: "api_route_cors_must_match_policy", + statement: "API routes appear to declare a static CORS policy.".to_string(), + rationale: "Detected static CORS policy facts.", + scope: route_scope, + matcher, + requires: Some(json!({ + "allowed_origins": allowed_origins, + "allow_credentials": allow_credentials + })), + suggested_severity: "warning", + enforcement_capability: "deterministic_check", + confidence_label: "medium", + facts: cors_facts, + scope_file_count, + file_hashes, + graph_fingerprint, + heuristic_id: "security-cors-policy-v1", + required_capabilities: &["syntax_facts", "cors_policy_facts"], + })); + } + + candidates +} + +struct SecurityCandidateInput<'a> { + request: &'a CandidateRequest, + kind: &'a str, + statement: String, + rationale: &'a str, + scope: Value, + matcher: Value, + requires: Option, + suggested_severity: &'a str, + enforcement_capability: &'a str, + confidence_label: &'a str, + facts: Vec<&'a CheckFact>, + scope_file_count: usize, + file_hashes: &'a BTreeMap<&'a str, &'a str>, + graph_fingerprint: &'a str, + heuristic_id: &'a str, + required_capabilities: &'a [&'a str], +} + +struct GuardCandidateInput<'a> { + candidates: &'a mut Vec, + request: &'a CandidateRequest, + api_route_files: &'a BTreeSet<&'a str>, + scope_file_count: usize, + file_hashes: &'a BTreeMap<&'a str, &'a str>, + graph_fingerprint: &'a str, + route_scope: &'a Value, + fact_kind: &'a str, + candidate_kind: &'a str, + requires_key: &'a str, + capability: &'a str, + heuristic_id: &'a str, + symbol_filter: fn(&str) -> bool, + requires_module_key: bool, +} + +struct SerializerCandidateInput<'a> { + candidates: &'a mut Vec, + request: &'a CandidateRequest, + api_route_files: &'a BTreeSet<&'a str>, + scope_file_count: usize, + file_hashes: &'a BTreeMap<&'a str, &'a str>, + graph_fingerprint: &'a str, + route_scope: &'a Value, + fact_kind: &'a str, + symbol_filter: fn(&str) -> bool, +} + +struct RequestValidationCandidateInput<'a> { + candidates: &'a mut Vec, + request: &'a CandidateRequest, + api_route_files: &'a BTreeSet<&'a str>, + scope_file_count: usize, + file_hashes: &'a BTreeMap<&'a str, &'a str>, + graph_fingerprint: &'a str, + route_scope: &'a Value, + fact_kind: &'a str, + symbol_filter: fn(&str) -> bool, +} + +fn security_candidate_from_facts(input: SecurityCandidateInput<'_>) -> EngineCandidate { + let evidence_refs = evidence_refs( + &input.request.scan.scan_id, + &input.facts, + input.file_hashes, + "supporting", + ); + let evidence_fingerprint = evidence_fingerprint(&evidence_refs); + let covered_files = unique_fact_file_count(&input.facts); + EngineCandidate { + candidate_id: candidate_id(&input.request.repo.repo_id, input.kind, &input.matcher), + candidate_version: 1, + kind: input.kind.to_string(), + rule_id: input.kind.to_string(), + rule_version: drift_engine::DRIFT_ENGINE_VERSION.to_string(), + matcher_schema_version: "convention.matcher.v1".to_string(), + matcher_fingerprint: stable_hash_json(&input.matcher), + scope_fingerprint: stable_hash_json(&input.scope), + graph_fingerprint: input.graph_fingerprint.to_string(), + statement: input.statement, + rationale: input.rationale.to_string(), + scope: input.scope, + matcher: input.matcher, + requires: input.requires, + suggested_severity: input.suggested_severity.to_string(), + suggested_enforcement_mode: "warn".to_string(), + enforcement_capability: input.enforcement_capability.to_string(), + confidence_label: input.confidence_label.to_string(), + scoring: scoring( + evidence_refs.len(), + 0, + input.scope_file_count, + covered_files, + input.heuristic_id, + ), + required_capabilities: input + .required_capabilities + .iter() + .map(|capability| (*capability).to_string()) + .collect(), + evidence_refs, + counterexample_refs: Vec::new(), + reason_not_blocking: "candidate_not_accepted".to_string(), + evidence_fingerprint, + } +} + +fn push_guard_candidate(input: GuardCandidateInput<'_>) { + for (symbol, facts) in + grouped_route_facts(input.request, input.api_route_files, input.fact_kind) + .into_iter() + .filter(|(symbol, facts)| facts.len() >= 2 && (input.symbol_filter)(symbol)) + { + let matcher = json!({ + "kind": input.candidate_kind, + "required_calls": [symbol], + "applies_to_file_roles": ["api_route"] + }); + let import_source = import_source_for_symbol(input.request, &facts[0].file_path, &symbol); + let helper = if input.requires_module_key { + json!({ + "helper_id": format!("{}:{symbol}", input.capability), + "symbol": symbol, + "module": import_source + }) + } else { + json!({ + "helper_id": format!("{}:{symbol}", input.capability), + "symbol": symbol, + "import": import_source + }) + }; + let requires = json!({ + input.requires_key: [helper] + }); + input + .candidates + .push(security_candidate_from_facts(SecurityCandidateInput { + request: input.request, + kind: input.candidate_kind, + statement: format!( + "API routes appear to use `{symbol}` for {}.", + input.capability + ), + rationale: "Detected repeated security helper facts.", + scope: input.route_scope.clone(), + matcher, + requires: Some(requires), + suggested_severity: "warning", + enforcement_capability: "deterministic_check", + confidence_label: "medium", + facts, + scope_file_count: input.scope_file_count, + file_hashes: input.file_hashes, + graph_fingerprint: input.graph_fingerprint, + heuristic_id: input.heuristic_id, + required_capabilities: &["syntax_facts"], + })); + } +} + +fn push_request_validation_candidates(input: RequestValidationCandidateInput<'_>) { + for (symbol, facts) in + grouped_route_facts(input.request, input.api_route_files, input.fact_kind) + .into_iter() + .filter(|(symbol, facts)| facts.len() >= 2 && (input.symbol_filter)(symbol)) + { + let matcher = json!({ + "kind": "api_route_requires_request_validation", + "applies_to_file_roles": ["api_route"], + "methods": ["POST", "PUT", "PATCH", "DELETE"] + }); + let requires = json!({ + "input_sources": ["body", "query", "params"], + "sinks": ["data_operation", "response"], + "validators": [{ + "validator_id": format!("validator:{symbol}"), + "symbol": symbol, + "import": import_source_for_symbol(input.request, &facts[0].file_path, &symbol) + }], + "schemas": [], + "allow_throwing_parse": true, + "allow_safe_parse_success_guard": true + }); + input + .candidates + .push(security_candidate_from_facts(SecurityCandidateInput { + request: input.request, + kind: "api_route_requires_request_validation", + statement: format!( + "Mutation API routes appear to validate request input with `{symbol}`." + ), + rationale: "Detected repeated request validation helper calls.", + scope: input.route_scope.clone(), + matcher, + requires: Some(requires), + suggested_severity: "warning", + enforcement_capability: "deterministic_check", + confidence_label: "medium", + facts, + scope_file_count: input.scope_file_count, + file_hashes: input.file_hashes, + graph_fingerprint: input.graph_fingerprint, + heuristic_id: "security-request-validation-v1", + required_capabilities: &["syntax_facts", "request_validation"], + })); + } +} + +fn push_serializer_candidate(input: SerializerCandidateInput<'_>) { + for (symbol, facts) in + grouped_route_facts(input.request, input.api_route_files, input.fact_kind) + .into_iter() + .filter(|(symbol, facts)| facts.len() >= 2 && (input.symbol_filter)(symbol)) + { + let matcher = json!({ + "kind": "api_route_forbids_sensitive_response_fields", + "required_calls": [symbol], + "applies_to_file_roles": ["api_route"] + }); + let import_source = import_source_for_symbol(input.request, &facts[0].file_path, &symbol) + .unwrap_or_else(|| "unknown".to_string()); + let requires = json!({ + "response_serializers": [{ + "serializer_id": format!("serializer:{symbol}"), + "import_source": import_source, + "imported_name": symbol, + "local_name": symbol, + "policy": "denylist", + "filtered_fields": ["password", "token", "apiToken", "accessToken", "refreshToken"] + }] + }); + input + .candidates + .push(security_candidate_from_facts(SecurityCandidateInput { + request: input.request, + kind: "api_route_forbids_sensitive_response_fields", + statement: format!("API routes appear to serialize responses with `{symbol}`."), + rationale: "Detected repeated response serializer-like helper calls.", + scope: input.route_scope.clone(), + matcher, + requires: Some(requires), + suggested_severity: "warning", + enforcement_capability: "deterministic_check", + confidence_label: "medium", + facts, + scope_file_count: input.scope_file_count, + file_hashes: input.file_hashes, + graph_fingerprint: input.graph_fingerprint, + heuristic_id: "security-response-serializer-v1", + required_capabilities: &["syntax_facts", "sensitive_response"], + })); + } +} + +fn route_facts<'a>( + request: &'a CandidateRequest, + api_route_files: &BTreeSet<&str>, + kind: &str, +) -> Vec<&'a CheckFact> { + request + .scan + .facts + .iter() + .filter(|fact| fact.kind == kind && api_route_files.contains(fact.file_path.as_str())) + .collect() +} + +fn grouped_route_facts<'a>( + request: &'a CandidateRequest, + api_route_files: &BTreeSet<&str>, + kind: &str, +) -> BTreeMap> { + let mut grouped: BTreeMap> = BTreeMap::new(); + for fact in route_facts(request, api_route_files, kind) { + grouped.entry(fact.name.clone()).or_default().push(fact); + } + grouped +} + +fn is_auth_candidate_symbol(symbol: &str) -> bool { + let lower = symbol.to_ascii_lowercase(); + !is_serializer_candidate_symbol(symbol) + && (lower.contains("auth") + || lower.contains("session") + || lower.contains("login") + || matches!( + lower.as_str(), + "requireuser" | "getuser" | "getcurrentuser" | "currentuser" + )) +} + +fn is_validation_candidate_symbol(symbol: &str) -> bool { + let lower = symbol.to_ascii_lowercase(); + lower.contains("validate") + || lower.contains("validator") + || lower == "parsebody" + || lower == "parseinput" + || lower == "safeparse" +} + +fn is_authorization_candidate_symbol(symbol: &str) -> bool { + let lower = symbol.to_ascii_lowercase(); + lower.contains("authorize") + || lower.contains("permission") + || lower.contains("requirepermission") + || lower.contains("requirerole") + || lower.starts_with("can") +} + +fn is_tenant_candidate_symbol(symbol: &str) -> bool { + let lower = symbol.to_ascii_lowercase(); + lower.contains("tenant") || lower.contains("scopeproject") || lower.contains("scopeorg") +} + +fn is_serializer_candidate_symbol(symbol: &str) -> bool { + let lower = symbol.to_ascii_lowercase(); + lower.starts_with("serialize") + || lower.contains("serializer") + || lower.contains("redact") + || lower.contains("sanitize") +} + +fn is_csrf_candidate_symbol(symbol: &str) -> bool { + symbol.to_ascii_lowercase().contains("csrf") +} + +fn is_rate_limit_candidate_symbol(symbol: &str) -> bool { + let lower = symbol.to_ascii_lowercase(); + lower.contains("ratelimit") + || lower.contains("rate_limit") + || lower.contains("throttle") + || lower.contains("limiter") +} + +fn is_ssrf_candidate_symbol(symbol: &str) -> bool { + let lower = symbol.to_ascii_lowercase(); + (lower.contains("allow") && lower.contains("url")) + || lower.contains("allowlist") + || lower.contains("sanitizeurl") + || lower.contains("safeurl") +} + +fn always_candidate_symbol(_: &str) -> bool { + true +} + +fn import_source_for_symbol( + request: &CandidateRequest, + file_path: &str, + symbol: &str, +) -> Option { + request.scan.facts.iter().find_map(|fact| { + if fact.kind == "import_used" && fact.file_path == file_path && fact.name == symbol { + fact.value.clone() + } else { + None + } + }) +} + +fn json_value(fact: &CheckFact) -> Option { + serde_json::from_str(fact.value.as_deref()?).ok() +} + +fn json_string_field(fact: &CheckFact, field: &str) -> Option { + json_value(fact)? + .get(field)? + .as_str() + .map(ToOwned::to_owned) +} + +fn cors_origin_field(fact: &CheckFact) -> Option { + let value = json_value(fact)?; + value + .get("origin") + .or_else(|| value.get("origins")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +fn cors_credentials_field(fact: &CheckFact) -> Option { + let value = json_value(fact)?; + value + .get("allow_credentials") + .or_else(|| value.get("credentials")) + .and_then(Value::as_bool) +} + +fn unique_json_strings(facts: &[&CheckFact], field: &str) -> Vec { + facts + .iter() + .filter_map(|fact| json_string_field(fact, field)) + .collect::>() + .into_iter() + .collect() +} + fn is_service_source(source: &str) -> bool { let lower = source.to_ascii_lowercase(); lower.contains("/service") @@ -508,7 +1357,11 @@ fn evidence_refs( facts .iter() .map(|fact| { - let import_source = fact.value.clone(); + let import_source = if fact.kind == "import_used" { + fact.value.clone() + } else { + None + }; EngineCandidateEvidenceRef { id: format!("evidence_ref_{}", &stable_hash(&fact_key(fact))[..16]), kind: kind.to_string(), @@ -565,6 +1418,25 @@ fn fact_key(fact: &CheckFact) -> String { ) } +fn evidence_fingerprint(refs: &[EngineCandidateEvidenceRef]) -> String { + stable_hash(&format!( + "{}", + json!( + refs.iter() + .map(|reference| json!({ + "id": reference.id, + "file_path": reference.file_path, + "start_line": reference.start_line, + "end_line": reference.end_line, + "symbol": reference.symbol, + "fact_ids": reference.fact_ids, + "file_hash": reference.file_hash + })) + .collect::>() + ) + )) +} + fn candidate_id(repo_id: &str, kind: &str, matcher: &Value) -> String { format!( "candidate_{}", diff --git a/drift v3/crates/drift-engine/src/protocol.rs b/drift v3/crates/drift-engine/src/protocol.rs index 8635a881..0197b482 100644 --- a/drift v3/crates/drift-engine/src/protocol.rs +++ b/drift v3/crates/drift-engine/src/protocol.rs @@ -279,6 +279,8 @@ pub struct EngineCandidate { pub rationale: String, pub scope: Value, pub matcher: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub requires: Option, pub suggested_severity: String, pub suggested_enforcement_mode: String, pub enforcement_capability: String, @@ -287,6 +289,8 @@ pub struct EngineCandidate { pub required_capabilities: Vec, pub evidence_refs: Vec, pub counterexample_refs: Vec, + pub reason_not_blocking: String, + pub evidence_fingerprint: String, } #[derive(Debug, Clone, Serialize)] diff --git a/drift v3/crates/drift-engine/tests/candidate_inference.rs b/drift v3/crates/drift-engine/tests/candidate_inference.rs index 2335f4b8..42a55f93 100644 --- a/drift v3/crates/drift-engine/tests/candidate_inference.rs +++ b/drift v3/crates/drift-engine/tests/candidate_inference.rs @@ -1,6 +1,9 @@ use std::{ + fs, io::Write, + path::Path, process::{Command, Stdio}, + time::{SystemTime, UNIX_EPOCH}, }; use serde_json::{Value, json}; @@ -56,7 +59,7 @@ fn infer_candidates_emits_governance_free_candidate_proposals() { assert!(candidates.iter().any(|candidate| { candidate["kind"] == "api_route_no_direct_data_access" && candidate["enforcement_capability"] == "deterministic_check" - && candidate["suggested_enforcement_mode"] == "block" + && candidate["suggested_enforcement_mode"] == "warn" && candidate.get("status").is_none() })); assert!(candidates.iter().any(|candidate| { @@ -281,6 +284,165 @@ fn infer_candidates_ignores_repo_fixture_routes_when_repo_root_is_not_the_fixtur ); } +#[test] +fn infer_candidates_emits_security_phase_candidates_as_non_blocking_elections() { + let route_a = "app/api/users/route.ts"; + let route_b = "app/api/projects/route.ts"; + let facts = json!([ + { "kind": "file_role_detected", "file_path": route_a, "name": "api_route", "start_line": 1, "end_line": 5 }, + { "kind": "file_role_detected", "file_path": route_b, "name": "api_route", "start_line": 1, "end_line": 5 }, + { "kind": "import_used", "file_path": route_a, "name": "requireUser", "value": "@/auth", "start_line": 1, "end_line": 1 }, + { "kind": "import_used", "file_path": route_b, "name": "requireUser", "value": "@/auth", "start_line": 1, "end_line": 1 }, + { "kind": "symbol_called", "file_path": route_a, "name": "requireUser", "start_line": 4, "end_line": 4 }, + { "kind": "symbol_called", "file_path": route_b, "name": "requireUser", "start_line": 4, "end_line": 4 }, + { "kind": "request_validation_called", "file_path": route_a, "name": "validateBody", "start_line": 5, "end_line": 5 }, + { "kind": "request_validation_called", "file_path": route_b, "name": "validateBody", "start_line": 5, "end_line": 5 }, + { "kind": "authorization_guard_called", "file_path": route_a, "name": "requireRole", "start_line": 6, "end_line": 6 }, + { "kind": "authorization_guard_called", "file_path": route_b, "name": "requireRole", "start_line": 6, "end_line": 6 }, + { "kind": "tenant_guard_called", "file_path": route_a, "name": "scopeTenant", "start_line": 7, "end_line": 7 }, + { "kind": "tenant_guard_called", "file_path": route_b, "name": "scopeTenant", "start_line": 7, "end_line": 7 }, + { "kind": "serializer_called", "file_path": route_a, "name": "serializeUser", "start_line": 8, "end_line": 8 }, + { "kind": "serializer_called", "file_path": route_b, "name": "serializeUser", "start_line": 8, "end_line": 8 }, + { "kind": "parameterized_sql_used", "file_path": route_a, "name": "safeQuery", "start_line": 9, "end_line": 9 }, + { "kind": "parameterized_sql_used", "file_path": route_b, "name": "safeQuery", "start_line": 9, "end_line": 9 }, + { "kind": "symbol_called", "file_path": route_a, "name": "allowlistedUrl", "start_line": 9, "end_line": 9 }, + { "kind": "symbol_called", "file_path": route_b, "name": "allowlistedUrl", "start_line": 9, "end_line": 9 }, + { "kind": "csrf_guard_called", "file_path": route_a, "name": "requireCsrf", "start_line": 10, "end_line": 10 }, + { "kind": "csrf_guard_called", "file_path": route_b, "name": "requireCsrf", "start_line": 10, "end_line": 10 }, + { "kind": "rate_limit_guard_called", "file_path": route_a, "name": "rateLimit", "start_line": 11, "end_line": 11 }, + { "kind": "rate_limit_guard_called", "file_path": route_b, "name": "rateLimit", "start_line": 11, "end_line": 11 }, + { "kind": "cors_policy_declared", "file_path": route_a, "name": "cors", "value": "{\"origin\":\"https://app.example.com\",\"allow_credentials\":true}", "start_line": 12, "end_line": 12 }, + { "kind": "sensitive_field_declared", "file_path": route_a, "name": "password", "value": "{\"field_path\":\"password\",\"classification\":\"credential\"}", "start_line": 13, "end_line": 13 } + ]); + let request = json!({ + "repo": { "repo_id": "repo_abc" }, + "graph": { "graph_nodes": [], "graph_edges": [], "graph_evidence": [] }, + "scan": { + "scan_id": "scan_abc", + "file_snapshots": [ + { "file_path": route_a, "content_hash": "a".repeat(64), "byte_size": 120, "indexed": true }, + { "file_path": route_b, "content_hash": "b".repeat(64), "byte_size": 120, "indexed": true } + ], + "facts": facts + } + }); + + let payload = run_infer_candidates(request); + let candidates = payload["candidates"].as_array().expect("candidates"); + for expected in [ + "api_route_requires_auth_helper", + "api_route_requires_request_validation", + "api_route_requires_authorization", + "api_route_requires_tenant_scope", + "api_route_forbids_sensitive_response_fields", + "api_route_forbids_raw_sql_without_params", + "api_route_forbids_untrusted_ssrf", + "api_route_requires_csrf_for_mutation", + "api_route_requires_rate_limit", + "api_route_cors_must_match_policy", + ] { + let candidate = candidates + .iter() + .find(|candidate| candidate["kind"] == expected) + .unwrap_or_else(|| panic!("missing {expected}: {payload:#?}")); + assert_eq!(candidate["suggested_enforcement_mode"], "warn"); + assert_eq!(candidate["reason_not_blocking"], "candidate_not_accepted"); + assert!( + candidate["requires"].is_object(), + "missing requires for {expected}" + ); + assert!( + candidate["evidence_fingerprint"] + .as_str() + .is_some_and(|value| !value.is_empty()), + "missing evidence fingerprint for {expected}: {candidate:#?}" + ); + } +} + +#[test] +fn scan_repo_then_infer_candidates_covers_phase7_security_candidate_families() { + let repo_root = temp_repo_root("phase7-candidate-coverage"); + write_phase7_candidate_fixture(&repo_root); + + let scan = run_scan_repo(&repo_root); + let request = json!({ + "repo": { "repo_id": "repo_phase7" }, + "graph": { "graph_nodes": [], "graph_edges": [], "graph_evidence": [] }, + "scan": { + "scan_id": "scan_phase7", + "file_snapshots": scan["file_snapshots"].clone(), + "facts": scan["facts"].clone(), + } + }); + let payload = run_infer_candidates(request); + let candidates = payload["candidates"].as_array().expect("candidates"); + + for expected in [ + "api_route_requires_auth_helper", + "middleware_must_cover_routes", + "api_route_requires_request_validation", + "api_route_requires_authorization", + "api_route_requires_tenant_scope", + "api_route_forbids_sensitive_response_fields", + "api_route_forbids_raw_sql_without_params", + "api_route_forbids_untrusted_ssrf", + "api_route_requires_csrf_for_mutation", + "api_route_requires_rate_limit", + "api_route_cors_must_match_policy", + ] { + let candidate = candidates + .iter() + .find(|candidate| candidate["kind"] == expected) + .unwrap_or_else(|| panic!("missing {expected}: {payload:#?}")); + assert_eq!(candidate["suggested_enforcement_mode"], "warn"); + assert_eq!(candidate["reason_not_blocking"], "candidate_not_accepted"); + assert!( + candidate["requires"].is_object(), + "missing requires for {expected}" + ); + } + + let cors = candidates + .iter() + .find(|candidate| candidate["kind"] == "api_route_cors_must_match_policy") + .expect("cors candidate"); + assert_eq!( + cors["requires"]["allowed_origins"], + json!(["https://app.example.com"]) + ); + + let ssrf = candidates + .iter() + .find(|candidate| candidate["kind"] == "api_route_forbids_untrusted_ssrf") + .expect("ssrf candidate"); + assert_eq!( + ssrf["requires"]["outbound_url_allowlist_helpers"][0]["module"], + "@/server/url" + ); + + let csrf = candidates + .iter() + .find(|candidate| candidate["kind"] == "api_route_requires_csrf_for_mutation") + .expect("csrf candidate"); + assert_eq!( + csrf["requires"]["csrf_helpers"][0]["module"], + "@/server/csrf" + ); + + let serializer = candidates + .iter() + .find(|candidate| candidate["kind"] == "api_route_forbids_sensitive_response_fields") + .and_then(|candidate| candidate["requires"]["response_serializers"].as_array()) + .and_then(|serializers| serializers.first()) + .expect("serializer candidate"); + assert_eq!(serializer["import_source"], "@/server/serializers"); + assert_eq!(serializer["imported_name"], "serializeUser"); + assert_eq!(serializer["policy"], "denylist"); + + fs::remove_dir_all(repo_root).ok(); +} + fn run_infer_candidates(request: Value) -> Value { let mut child = Command::new(env!("CARGO_BIN_EXE_drift-engine")) .arg("infer-candidates") @@ -303,6 +465,95 @@ fn run_infer_candidates(request: Value) -> Value { serde_json::from_slice(&output.stdout).expect("json output") } +fn run_scan_repo(repo_root: &Path) -> Value { + let output = Command::new(env!("CARGO_BIN_EXE_drift-engine")) + .arg("scan-repo") + .arg(repo_root) + .arg("--format") + .arg("json") + .arg("--repo-id") + .arg("repo_phase7") + .arg("--scan-id") + .arg("scan_phase7") + .output() + .expect("run scan-repo"); + assert!( + output.status.success(), + "scan failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("scan json") +} + +fn temp_repo_root(prefix: &str) -> std::path::PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos(); + let path = std::env::temp_dir().join(format!("{prefix}-{}-{suffix}", std::process::id())); + fs::create_dir_all(&path).expect("create temp repo"); + path +} + +fn write_phase7_candidate_fixture(repo_root: &Path) { + fs::write( + repo_root.join("middleware.ts"), + r#" +import { NextResponse } from "next/server"; + +export async function middleware() { + return NextResponse.next(); +} + +export const config = { + matcher: ["/api/:path*"], +}; +"#, + ) + .expect("write middleware"); + + for name in ["users", "projects"] { + let dir = repo_root.join(format!("app/api/{name}")); + fs::create_dir_all(&dir).expect("create route dir"); + fs::write( + dir.join("route.ts"), + r#" +import { requireUser } from "@/server/auth"; +import { validateBody } from "@/server/validation"; +import { requireRole } from "@/server/authorization"; +import { scopeTenant } from "@/server/tenant"; +import { serializeUser } from "@/server/serializers"; +import { safeUrl } from "@/server/url"; +import { requireCsrf } from "@/server/csrf"; +import { rateLimit } from "@/server/rate-limit"; +import { db } from "@/server/db"; + +export async function POST(request: Request) { + const body = await request.json(); + const user = await requireUser(); + const input = validateBody(body); + requireRole(user, "admin"); + const tenantId = scopeTenant(user); + await requireCsrf(request); + await rateLimit(request); + const target = safeUrl(input.callbackUrl); + await fetch(target); + await db.query("select * from users where id = $1", [input.id]); + const row = { id: input.id, password: "redacted", tenantId }; + const safe = serializeUser(row); + return Response.json(safe, { + headers: { + "Access-Control-Allow-Origin": "https://app.example.com", + "Access-Control-Allow-Credentials": "true" + } + }); +} +"#, + ) + .expect("write route"); + } +} + fn graph_node(id: &str, kind: &str, label: &str, metadata: Value) -> Value { json!({ "id": id, diff --git a/drift v3/docs/architecture/security-phase7-coverage-ledger.md b/drift v3/docs/architecture/security-phase7-coverage-ledger.md new file mode 100644 index 00000000..be6c0954 --- /dev/null +++ b/drift v3/docs/architecture/security-phase7-coverage-ledger.md @@ -0,0 +1,59 @@ +# Security Phase 7 Coverage Ledger + +Scope: Phase 7 of `docs/architecture/security-boundary-enforcement-100-tdd.md`. + +Phase 7 is complete only when candidate inference is useful without becoming enforcement. Coverage here means spec coverage: every required candidate family, election rule, and validation guard has a live test or gate. + +## Requirement Coverage + +| Requirement | Proof | +| --- | --- | +| Auth helper candidate | `crates/drift-engine/tests/candidate_inference.rs::infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` | +| Middleware protection candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` | +| Validation helper candidate | `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` | +| Tenant helper candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` | +| Serializer candidate | `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` | +| Sensitive field candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` | +| SQL safe wrapper candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` | +| SSRF allowlist/sanitizer candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` | +| CSRF helper candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` | +| Rate-limit helper candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` | +| CORS policy candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` | +| Candidates default to non-blocking mode | Rust candidate tests assert `suggested_enforcement_mode = warn` and `reason_not_blocking = candidate_not_accepted` for every Phase 7 security family. | +| Candidate cannot produce blocking finding until accepted | `crates/drift-engine/tests/candidate_inference.rs::infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; Phase 1-6 `candidate_only_*_does_not_block` Rust tests remain part of the gate. | +| Accepted candidate becomes Rust contract input | `packages/cli/test/cli.test.ts::accepts a candidate, materializes a repo contract, and audits the action`; Phase 1-6 check tests validate accepted contracts through Rust proof. | +| Rejected candidate is not re-proposed without new evidence | `packages/cli/test/cli.test.ts::does not re-propose a rejected candidate without new evidence` | +| Blocking heuristic contracts are rejected | `packages/cli/test/cli.test.ts::rejects contract validate when a blocking convention is not deterministic`; `rejects imported blocking security contracts backed by candidate sensitive fields` | +| Output includes evidence counts, confidence, suggested contract/mode, and reason not blocking | `crates/drift-engine/tests/candidate_inference.rs` candidate payload assertions; `pnpm --filter @drift/cli test` covers CLI rendering and import/accept surfaces. | + +## Gate Commands + +Run these from the repository root before calling Phase 7 covered: + +```bash +cargo test -p drift-engine --test candidate_inference +cargo test -p drift-engine security_ +cargo test -p drift-engine --test security_phase6 +cargo test -p drift-engine +pnpm --filter @drift/core test +pnpm --filter @drift/engine-contract test +pnpm --filter @drift/storage test +pnpm --filter @drift/query test +pnpm --filter @drift/cli test +pnpm --filter @drift/mcp test +pnpm test:e2e +pnpm typecheck +cargo fmt --all -- --check +cargo clippy -p drift-engine --all-targets -- -D warnings +git diff --check +``` + +## Boundary + +Phase 7 does not make heuristic evidence enforceable. The expected lifecycle remains: + +```text +scan facts -> infer candidate -> human/agent election -> accepted repo contract -> Rust proof/check +``` + +Phase 8 output and MCP UX are intentionally out of scope except where existing CLI tests prove candidates can be listed, accepted, rejected, imported, and validated without bypassing elections. diff --git a/drift v3/packages/cli/src/commands/contract.ts b/drift v3/packages/cli/src/commands/contract.ts index 6314cfb8..cde85f3b 100644 --- a/drift v3/packages/cli/src/commands/contract.ts +++ b/drift v3/packages/cli/src/commands/contract.ts @@ -40,8 +40,9 @@ export function validateContract(storage: SqliteDriftStorage, parsed: ParsedArgs throw new Error(`Policy denied contract validate: ${policy.reason}`); } RepoContractSchema.parse(contract); + const validationReasons = contractValidationReasons(contract); const payload = { - valid: true, + valid: validationReasons.length === 0, repo_id: repoId, policy, governance: preflightGovernance(), @@ -50,9 +51,14 @@ export function validateContract(storage: SqliteDriftStorage, parsed: ParsedArgs schema_version: contract.contract_schema_version, supported_schema_version: DRIFT_CONTRACT_SCHEMA_VERSION, convention_count: contract.conventions.length, - agent_contract_count: contract.agent_contracts?.length ?? 0 + agent_contract_count: contract.agent_contracts?.length ?? 0, + compatibility: { + compatible: validationReasons.length === 0, + reasons: validationReasons + } }; return { + exitCode: validationReasons.length === 0 ? 0 : 1, payload: parsed.flags.has("json") ? payload : formatContractValidationText(payload) }; } @@ -202,7 +208,8 @@ export function importContractDryRun( !safeCommandsUnique ? "duplicate_safe_commands" : undefined, !riskyAreaIdsUnique ? "duplicate_risky_area_ids" : undefined, !deniedGlobsUnique ? "duplicate_denied_globs" : undefined, - !rejectedInferencesUnique ? "duplicate_rejected_inferences" : undefined + !rejectedInferencesUnique ? "duplicate_rejected_inferences" : undefined, + ...contractValidationReasons(contract) ].filter((reason): reason is string => Boolean(reason)); const compatibility = { compatible: compatibilityReasons.length === 0, @@ -545,6 +552,39 @@ export function removeContractWaiver( }; } +function contractValidationReasons(contract: RepoContract): string[] { + const reasons = new Set(); + for (const convention of contract.conventions) { + if ( + convention.enforcement_mode === "block" && + convention.enforcement_capability !== "deterministic_check" + ) { + reasons.add("blocking_non_deterministic_convention"); + } + if ( + convention.enforcement_mode === "block" && + convention.kind === "api_route_forbids_sensitive_response_fields" && + candidateSensitiveFields(convention.requires).length > 0 + ) { + reasons.add("candidate_sensitive_fields_blocking"); + } + } + return [...reasons].sort(); +} + +function candidateSensitiveFields(requires: Record | undefined): unknown[] { + const fields = requires?.sensitive_response_fields; + if (!Array.isArray(fields)) { + return []; + } + return fields.filter((field) => + typeof field === "object" && + field !== null && + "source" in field && + (field as { source?: unknown }).source === "candidate" + ); +} + function approvedFileHashForPath( repoRoot: string, path: string diff --git a/drift v3/packages/cli/src/commands/conventions.ts b/drift v3/packages/cli/src/commands/conventions.ts index 7eed1022..92784e4d 100644 --- a/drift v3/packages/cli/src/commands/conventions.ts +++ b/drift v3/packages/cli/src/commands/conventions.ts @@ -5,7 +5,7 @@ import { CommandPayload,ParsedArgs } from "../app/command-types.js"; import { actorFlag,hasAnyFlag,optionalConventionKindFlag,optionalConventionStatusFlag,optionalEnforcementCapabilityFlag,optionalEnforcementModeFlag,optionalNonEmptyFlag,optionalNonNegativeIntegerFlag,optionalPositiveIntegerFlag,optionalSeverityFlag,requiredFlag,requiredNonEmptyFlag,stringFlag } from "../args/flag-readers.js"; import { assertCandidateRepoMatchesParsed,resolveRepoId } from "../args/repo-flags.js"; import { contractSummary,materializeRepoContract } from "../domain/contract-materialization.js"; -import { acceptConventionCandidate,conventionCandidateEditNextCommands,conventionCandidateListNextCommands,conventionCandidateReviewItem,conventionCandidateShowNextCommands,conventionCandidateSummary,exceptionNextCommands,rejectedConventionNextCommands } from "../domain/convention-candidates.js"; +import { acceptConventionCandidate,conventionCandidateEditNextCommands,conventionCandidateListNextCommands,conventionCandidateReviewItem,conventionCandidateShowNextCommands,conventionCandidateSummary,exceptionNextCommands,rejectedConventionNextCommands,rejectedInferenceForCandidate } from "../domain/convention-candidates.js"; import { auditEvent,mutationGovernance,preflightGovernance } from "../domain/governance.js"; import { exceptionIdForConvention,hashStable } from "../domain/identifiers.js"; import { orderConventionCandidatesForReview,paginateConventionCandidates,paginationSummary } from "../domain/pagination.js"; @@ -181,17 +181,46 @@ export function rejectCandidate( const rejected = { ...candidate, status: "rejected" as const }; - storage.upsertConventionCandidate(rejected); - storage.appendAuditEvent(auditEvent({ - id: `audit_event_reject_${candidate.id}_${now}`, - repoId: candidate.repo_id, - actor, - action: "election_rejected", - targetType: "candidate", - targetId: candidate.id, - metadata: { reason }, - createdAt: now - })); + const contractId = storage.getRepoContract(candidate.repo_id)?.id ?? `contract_${candidate.repo_id}`; + storage.transaction(() => { + storage.upsertConventionCandidate(rejected); + const existingContract = storage.getRepoContract(candidate.repo_id) ?? + materializeRepoContract(storage, candidate.repo_id, contractId, now); + const rejection = rejectedInferenceForCandidate(candidate, { + reason, + rejectedBy: actor, + rejectedAt: now + }); + const rejectedKey = JSON.stringify({ + candidate_id: rejection.candidate_id, + evidence_fingerprint: rejection.evidence_fingerprint ?? null + }); + const nextRejected = [ + ...existingContract.rejected_inferences.filter((entry) => JSON.stringify({ + candidate_id: entry.candidate_id, + evidence_fingerprint: entry.evidence_fingerprint ?? null + }) !== rejectedKey), + rejection + ]; + storage.upsertRepoContract({ + ...existingContract, + rejected_inferences: nextRejected, + updated_at: now + }); + storage.appendAuditEvent(auditEvent({ + id: `audit_event_reject_${candidate.id}_${now}`, + repoId: candidate.repo_id, + actor, + action: "election_rejected", + targetType: "candidate", + targetId: candidate.id, + metadata: { + reason, + evidence_fingerprint: rejection.evidence_fingerprint ?? null + }, + createdAt: now + })); + }); return { candidate: rejected, diff --git a/drift v3/packages/cli/src/commands/support.ts b/drift v3/packages/cli/src/commands/support.ts index 29ed2e38..f7924625 100644 --- a/drift v3/packages/cli/src/commands/support.ts +++ b/drift v3/packages/cli/src/commands/support.ts @@ -17,6 +17,8 @@ export function supportBundle(storage: SqliteDriftStorage, parsed: ParsedArgs) { const audit = storage.verifyAuditChain(repoId); const latestScan = storage.listScanManifests(repoId).find((scan) => scan.status === "completed") ?? null; const backups = storage.listBackupManifests(repoId); + const contract = storage.getRepoContract(repoId); + const candidates = storage.listConventionCandidates(repoId); return { response_schema: "drift.support.bundle.v1", @@ -40,6 +42,7 @@ export function supportBundle(storage: SqliteDriftStorage, parsed: ParsedArgs) { "repo_identity_hashes", "migration_compatibility", "scan_counts", + "candidate_election_counts", "audit_integrity", "backup_count" ], @@ -86,6 +89,12 @@ export function supportBundle(storage: SqliteDriftStorage, parsed: ParsedArgs) { event_count: audit.event_count, head_event_hash: audit.head_event_hash }, + elections: { + candidate_count: candidates.filter((candidate) => candidate.status === "candidate").length, + accepted_count: candidates.filter((candidate) => candidate.status === "accepted").length, + rejected_count: candidates.filter((candidate) => candidate.status === "rejected").length, + rejected_inference_count: contract?.rejected_inferences.length ?? 0 + }, backups: { count: backups.length } diff --git a/drift v3/packages/cli/src/domain/convention-candidates.ts b/drift v3/packages/cli/src/domain/convention-candidates.ts index a842e77e..54ce596e 100644 --- a/drift v3/packages/cli/src/domain/convention-candidates.ts +++ b/drift v3/packages/cli/src/domain/convention-candidates.ts @@ -1,4 +1,4 @@ -import { DRIFT_CONTRACT_SCHEMA_VERSION,type AcceptedConvention,type ConventionCandidate,type ConventionStatus,type EnforcementMode,type EvidenceRef,type FactRecord,type RepoContract,type Severity } from "@drift/core"; +import { DRIFT_CONTRACT_SCHEMA_VERSION,type AcceptedConvention,type ConventionCandidate,type ConventionStatus,type EnforcementMode,type EvidenceRef,type FactRecord,type RejectedInference,type RepoContract,type Severity } from "@drift/core"; import type { SqliteDriftStorage } from "@drift/storage"; import { join } from "node:path"; import { fileLooksLikeDataAccess,resolveImportTarget } from "../engine/import-resolution.js"; @@ -82,6 +82,7 @@ export function acceptConventionCandidate( rationale: candidate.rationale, scope: candidate.scope, matcher: candidate.matcher, + requires: candidate.requires, severity, enforcement_mode: mode, enforcement_capability: candidate.enforcement_capability, @@ -197,6 +198,63 @@ export function acceptDefaultCandidate( ).accepted; } +export function candidateEvidenceFingerprint(candidate: ConventionCandidate): string { + return candidate.evidence_fingerprint ?? hashStable(JSON.stringify({ + id: candidate.id, + kind: candidate.kind, + matcher: candidate.matcher, + evidence: candidate.evidence_refs.map((ref) => ({ + id: ref.id, + file_path: ref.file_path, + start_line: ref.start_line ?? null, + end_line: ref.end_line ?? null, + symbol: ref.symbol ?? null, + fact_ids: ref.fact_ids, + file_hash: ref.file_hash + })) + })); +} + +export function rejectedInferenceForCandidate( + candidate: ConventionCandidate, + input: { reason: string; rejectedBy: string; rejectedAt: string } +): RejectedInference { + return { + candidate_id: candidate.id, + evidence_fingerprint: candidateEvidenceFingerprint(candidate), + matcher_fingerprint: candidate.matcher_fingerprint, + scope_fingerprint: candidate.scope_fingerprint, + reason: input.reason, + rejected_by: input.rejectedBy, + rejected_at: input.rejectedAt + }; +} + +export function candidateMatchesRejectedInference( + candidate: ConventionCandidate, + rejection: RejectedInference +): boolean { + return rejection.candidate_id === candidate.id && + (!rejection.evidence_fingerprint || + rejection.evidence_fingerprint === candidateEvidenceFingerprint(candidate)); +} + +export function filterRejectedConventionCandidates( + storage: SqliteDriftStorage, + repoId: string, + candidates: ConventionCandidate[] +): ConventionCandidate[] { + const contractRejections = storage.getRepoContract(repoId)?.rejected_inferences ?? []; + return candidates.filter((candidate) => { + if (contractRejections.some((rejection) => candidateMatchesRejectedInference(candidate, rejection))) { + return false; + } + const existing = storage.getConventionCandidate(candidate.id); + return !(existing?.status === "rejected" && + candidateEvidenceFingerprint(existing) === candidateEvidenceFingerprint(candidate)); + }); +} + export function conventionCandidateSummary( allCandidates: ConventionCandidate[], filteredCandidates: ConventionCandidate[], @@ -237,6 +295,8 @@ export function conventionCandidateReviewItem(candidate: ConventionCandidate): { evidence_ref_count: number; counterexample_ref_count: number; first_evidence: Pick | null; + reason_not_blocking: ConventionCandidate["reason_not_blocking"] | null; + evidence_fingerprint: string | null; } { const firstEvidence = candidate.evidence_refs[0] ?? null; return { @@ -256,6 +316,8 @@ export function conventionCandidateReviewItem(candidate: ConventionCandidate): { heuristic_id: candidate.scoring.heuristic_id, evidence_ref_count: candidate.evidence_refs.length, counterexample_ref_count: candidate.counterexample_refs.length, + reason_not_blocking: candidate.reason_not_blocking ?? null, + evidence_fingerprint: candidate.evidence_fingerprint ?? null, first_evidence: firstEvidence ? { file_path: firstEvidence.file_path, @@ -359,6 +421,15 @@ export function inferConventionCandidates(input: { kind: "supporting", facts: dataImports }); + const dataMatcher = { + kind: "api_route_no_direct_data_access" as const, + forbidden_imports: forbiddenImports, + applies_to_file_roles: ["api_route" as const] + }; + const dataScope = { + path_globs: ["**/app/api/**/route.ts", "**/app/api/**/route.tsx", "**/pages/api/**/*.ts"], + file_roles: ["api_route" as const] + }; candidates.push({ id: `candidate_${hashStable(`${input.repoId}:api_route_no_direct_data_access:${forbiddenImports.join(",")}`).slice(0, 16)}`, repo_id: input.repoId, @@ -366,17 +437,11 @@ export function inferConventionCandidates(input: { kind: "api_route_no_direct_data_access", statement: "API routes should not import data-access clients directly.", rationale: "Detected API route imports that look like database/data-access clients.", - scope: { - path_globs: ["**/app/api/**/route.ts", "**/app/api/**/route.tsx", "**/pages/api/**/*.ts"], - file_roles: ["api_route"] - }, - matcher: { - kind: "api_route_no_direct_data_access", - forbidden_imports: forbiddenImports, - applies_to_file_roles: ["api_route"] - }, + scope: dataScope, + matcher: dataMatcher, + requires: { forbidden_imports: forbiddenImports }, suggested_severity: "error", - suggested_enforcement_mode: "block", + suggested_enforcement_mode: "warn", enforcement_capability: "deterministic_check", confidence_label: "high", scoring: { @@ -388,6 +453,11 @@ export function inferConventionCandidates(input: { }, evidence_refs: dataEvidence, counterexample_refs: [], + matcher_fingerprint: hashStable(JSON.stringify(dataMatcher)), + scope_fingerprint: hashStable(JSON.stringify(dataScope)), + evidence_fingerprint: hashStable(JSON.stringify(dataEvidence)), + required_capabilities: ["syntax_facts", "import_resolution", "route_detection"], + reason_not_blocking: "candidate_not_accepted", status: "candidate", created_at: input.now }); @@ -407,6 +477,17 @@ export function inferConventionCandidates(input: { kind: "counterexample", facts: dataImports }); + const serviceMatcher = { + kind: "api_route_requires_service_delegation" as const, + allowed_delegate_imports: delegateImports.length > 0 + ? delegateImports + : ["**/services/**", "**/server/**", "**/data-access/**"], + applies_to_file_roles: ["api_route" as const] + }; + const serviceScope = { + path_globs: ["**/app/api/**/route.ts", "**/app/api/**/route.tsx", "**/pages/api/**/*.ts"], + file_roles: ["api_route" as const] + }; candidates.push({ id: `candidate_${hashStable(`${input.repoId}:api_route_requires_service_delegation:${delegateImports.join(",") || "default"}`).slice(0, 16)}`, repo_id: input.repoId, @@ -416,17 +497,8 @@ export function inferConventionCandidates(input: { rationale: serviceImports.length > 0 ? "Detected API route imports from service modules." : "Detected direct data-access imports; service delegation should be reviewed before enforcement.", - scope: { - path_globs: ["**/app/api/**/route.ts", "**/app/api/**/route.tsx", "**/pages/api/**/*.ts"], - file_roles: ["api_route"] - }, - matcher: { - kind: "api_route_requires_service_delegation", - allowed_delegate_imports: delegateImports.length > 0 - ? delegateImports - : ["**/services/**", "**/server/**", "**/data-access/**"], - applies_to_file_roles: ["api_route"] - }, + scope: serviceScope, + matcher: serviceMatcher, suggested_severity: "warning", suggested_enforcement_mode: "warn", enforcement_capability: "heuristic_check", @@ -440,6 +512,11 @@ export function inferConventionCandidates(input: { }, evidence_refs: serviceEvidence, counterexample_refs: dataCounterexamples, + matcher_fingerprint: hashStable(JSON.stringify(serviceMatcher)), + scope_fingerprint: hashStable(JSON.stringify(serviceScope)), + evidence_fingerprint: hashStable(JSON.stringify(serviceEvidence)), + required_capabilities: ["syntax_facts", "import_resolution", "graph_stream"], + reason_not_blocking: "candidate_not_accepted", status: "candidate", created_at: input.now }); diff --git a/drift v3/packages/cli/src/domain/scan-status.ts b/drift v3/packages/cli/src/domain/scan-status.ts index a3eef206..6ba12ea9 100644 --- a/drift v3/packages/cli/src/domain/scan-status.ts +++ b/drift v3/packages/cli/src/domain/scan-status.ts @@ -12,7 +12,7 @@ import { buildFactGraphArtifact } from "../engine/fact-graph.js"; import { walkIndexableFiles } from "../engine/ts-fallback-scanner.js"; import { fileContentHash } from "../io/file-hash.js"; import { gitOutput } from "../io/git.js"; -import { inferConventionCandidates } from "./convention-candidates.js"; +import { filterRejectedConventionCandidates,inferConventionCandidates } from "./convention-candidates.js"; import { auditEvent,preflightGovernance } from "./governance.js"; import { hashStable,scanFingerprint } from "./identifiers.js"; import { repoRecordForRoot } from "./repo-paths.js"; @@ -95,7 +95,7 @@ export async function runScanRepo(storage: SqliteDriftStorage, input: ScanRepoIn repoRoot, reuseManifestPath: reuseManifest?.path }); - const candidates = scanData.engineSource === "rust" + const inferredCandidates = scanData.engineSource === "rust" ? await inferConventionCandidatesFromEngine({ repoId: repo.id, scanId, @@ -109,6 +109,7 @@ export async function runScanRepo(storage: SqliteDriftStorage, input: ScanRepoIn facts: scanData.facts, now }); + const candidates = filterRejectedConventionCandidates(storage, repo.id, inferredCandidates); const scan: ScanManifest = { id: scanId, repo_id: repo.id, diff --git a/drift v3/packages/cli/src/engine/engine-candidates.ts b/drift v3/packages/cli/src/engine/engine-candidates.ts index ab305ad4..cfe8f77e 100644 --- a/drift v3/packages/cli/src/engine/engine-candidates.ts +++ b/drift v3/packages/cli/src/engine/engine-candidates.ts @@ -51,6 +51,7 @@ export async function inferConventionCandidatesFromEngine(input: { rationale: candidate.rationale, scope: candidate.scope, matcher: candidate.matcher, + requires: candidate.requires, suggested_severity: candidate.suggested_severity, suggested_enforcement_mode: candidate.suggested_enforcement_mode, enforcement_capability: candidate.enforcement_capability, @@ -58,6 +59,12 @@ export async function inferConventionCandidatesFromEngine(input: { scoring: candidate.scoring, evidence_refs: candidate.evidence_refs, counterexample_refs: candidate.counterexample_refs, + matcher_fingerprint: candidate.matcher_fingerprint, + scope_fingerprint: candidate.scope_fingerprint, + graph_fingerprint: candidate.graph_fingerprint, + evidence_fingerprint: candidate.evidence_fingerprint, + required_capabilities: candidate.required_capabilities, + reason_not_blocking: candidate.reason_not_blocking, status: "candidate", created_at: input.now }) diff --git a/drift v3/packages/cli/src/formatters/conventions.ts b/drift v3/packages/cli/src/formatters/conventions.ts index 5f3ee7b2..30bfa2a7 100644 --- a/drift v3/packages/cli/src/formatters/conventions.ts +++ b/drift v3/packages/cli/src/formatters/conventions.ts @@ -25,6 +25,7 @@ export function formatConventionCandidatesText(payload: { ` Status: ${candidate.status}`, ` Capability: ${candidate.enforcement_capability}`, ` Suggested: ${candidate.suggested_severity}/${candidate.suggested_enforcement_mode}`, + ` Not blocking: ${candidate.reason_not_blocking ?? "n/a"}`, ` Confidence: ${candidate.confidence_label}`, ` Evidence refs: ${candidate.evidence_refs.length}; counterexamples: ${candidate.counterexample_refs.length}`, ` Statement: ${candidate.statement}`, @@ -67,6 +68,7 @@ export function formatConventionCandidateText(payload: { `Status: ${candidate.status}`, `Capability: ${candidate.enforcement_capability}`, `Suggested: ${candidate.suggested_severity}/${candidate.suggested_enforcement_mode}`, + `Not blocking: ${candidate.reason_not_blocking ?? "n/a"}`, `Confidence: ${candidate.confidence_label}`, `Scope: ${candidate.scope.path_globs.join(", ") || "none"}`, `File roles: ${candidate.scope.file_roles?.join(", ") || "none"}`, diff --git a/drift v3/packages/cli/test/cli.test.ts b/drift v3/packages/cli/test/cli.test.ts index 315214da..50cd48b8 100644 --- a/drift v3/packages/cli/test/cli.test.ts +++ b/drift v3/packages/cli/test/cli.test.ts @@ -50,6 +50,7 @@ async function seedDatabase(): Promise { forbidden_imports: ["@/lib/prisma"], applies_to_file_roles: ["api_route"] }, + requires: { forbidden_imports: ["@/lib/prisma"] }, suggested_severity: "error", suggested_enforcement_mode: "block", enforcement_capability: "deterministic_check", @@ -63,6 +64,12 @@ async function seedDatabase(): Promise { }, evidence_refs: [], counterexample_refs: [], + matcher_fingerprint: "matcher_fp", + scope_fingerprint: "scope_fp", + graph_fingerprint: "graph_fp", + evidence_fingerprint: "evidence_fp", + required_capabilities: ["syntax_facts", "import_resolution"], + reason_not_blocking: "candidate_not_accepted", status: "candidate", created_at: "2026-05-10T00:00:01.000Z" }); @@ -454,7 +461,7 @@ describe("drift CLI convention review", () => { expect(payload.runtime).toMatchObject({ cli_version: "0.1.0", core_version: "0.1.0", - supported_sqlite_schema_version: 23, + supported_sqlite_schema_version: 24, storage_driver: "sqlite" }); expect(payload.v1_scope).toMatchObject({ @@ -941,6 +948,65 @@ describe("drift CLI convention review", () => { storage.close(); }); + it("does not re-propose a rejected candidate without new evidence", async () => { + const dir = await mkdtemp(join(tmpdir(), "drift-rejected-candidate-rescan-")); + tempDirs.push(dir); + const repoRoot = join(dir, "repo"); + const stateRoot = join(dir, "state"); + await mkdir(join(repoRoot, "apps/web/app/api/users"), { recursive: true }); + await writeFile( + join(repoRoot, "apps/web/app/api/users/route.ts"), + [ + "import { prisma } from \"@/lib/prisma\";", + "", + "export async function GET() {", + " return Response.json(await prisma.user.findMany());", + "}", + "" + ].join("\n") + ); + + const first = await runCli([ + "scan", + "--repo-root", repoRoot, + "--state-root", stateRoot, + "--now", "2026-05-10T00:00:10.000Z", + "--json" + ]); + const firstPayload = JSON.parse(first.stdout); + const directCandidate = firstPayload.candidates.find( + (candidate: { kind: string }) => candidate.kind === "api_route_no_direct_data_access" + ); + if (!directCandidate) { + throw new Error("expected direct data access candidate"); + } + + const rejected = await runCli([ + "--db", firstPayload.database_path, + "conventions", "reject", + directCandidate.id, + "--repo", firstPayload.repo.id, + "--reason", "false inference", + "--confirm", + "--json" + ]); + expect(rejected.exitCode).toBe(0); + + const second = await runCli([ + "scan", + "--repo-root", repoRoot, + "--state-root", stateRoot, + "--now", "2026-05-10T00:00:20.000Z", + "--json" + ]); + const secondPayload = JSON.parse(second.stdout); + + expect(second.exitCode).toBe(0); + expect(secondPayload.candidates.some( + (candidate: { id: string }) => candidate.id === directCandidate.id + )).toBe(false); + }); + it("rejects scan repo roots that are files", async () => { const dir = await mkdtemp(join(tmpdir(), "drift-scan-file-root-")); tempDirs.push(dir); @@ -2445,7 +2511,7 @@ describe("drift CLI convention review", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Drift doctor"); - expect(result.stdout).toContain("Runtime: Drift CLI 0.1.0, SQLite schema 23"); + expect(result.stdout).toContain("Runtime: Drift CLI 0.1.0, SQLite schema 24"); expect(result.stdout).toContain("V1 scope: local-first CLI, TypeScript API route layering"); expect(result.stdout).toContain("TS/JS files: 1 indexable file"); expect(result.stdout).toContain("API routes: 1 API route file"); @@ -2526,7 +2592,7 @@ describe("drift CLI convention review", () => { typescript_adapter_version: "0.1.0", rule_engine_version: "0.1.0", contract_schema_version: 1, - supported_sqlite_schema_version: 23, + supported_sqlite_schema_version: 24, storage_driver: "sqlite" }); expect(payload.engine).toMatchObject({ @@ -2544,7 +2610,7 @@ describe("drift CLI convention review", () => { deferred: ["desktop_ui", "cloud_sync", "python_adapter", "duplicate_helper_detection"] }); expect(payload.state_summary).toMatchObject({ - supported_schema_version: 23 + supported_schema_version: 24 }); expect(payload.state_summary).toMatchObject({ exists: true, @@ -2882,7 +2948,7 @@ describe("drift CLI convention review", () => { typescript_adapter_version: "0.1.0", rule_engine_version: "0.1.0", contract_schema_version: 1, - supported_sqlite_schema_version: 23, + supported_sqlite_schema_version: 24, storage_driver: "sqlite" }); expect(payload.engine).toMatchObject({ @@ -3120,7 +3186,7 @@ describe("drift CLI convention review", () => { machine_contract_versions: { schema_version: "drift.machine_contract_versions.v1", cli_version: "0.1.0", - storage_schema_version: 23, + storage_schema_version: 24, factgraph_schema_version: "factgraph.v2" } }); @@ -3210,7 +3276,7 @@ describe("drift CLI convention review", () => { blocking_count: 1, machine_contract_versions: expect.objectContaining({ schema_version: "drift.machine_contract_versions.v1", - storage_schema_version: 23 + storage_schema_version: 24 }) }); expect(storage.listFindings("repo_abc")[0]?.title).toBe("API route imports data access directly"); @@ -7411,7 +7477,7 @@ describe("drift CLI convention review", () => { expect(payload.summary).toMatchObject({ write_intent: true, artifact_exists: true, - schema_version: 23 + schema_version: 24 }); expect(payload.review_item).toMatchObject({ id: payload.manifest.id, @@ -7421,7 +7487,7 @@ describe("drift CLI convention review", () => { }); expect(payload.manifest).toMatchObject({ repo_id: "repo_abc", - schema_version: 23, + schema_version: 24, created_at: "2026-05-10T00:00:04.000Z" }); expect(payload.manifest.backup_path).toContain(backupDir); @@ -7646,7 +7712,7 @@ describe("drift CLI convention review", () => { id: backup[0], repo_id: "repo_abc", repo_fingerprint: "repo-fp", - schema_version: 23, + schema_version: 24, source_database_path: databasePath, backup_path: `/tmp/${backup[0]}.sqlite`, checksum_sha256: "a".repeat(64), @@ -7698,7 +7764,7 @@ describe("drift CLI convention review", () => { id: "backup_valid", repo_id: "repo_abc", repo_fingerprint: "repo-fp", - schema_version: 23, + schema_version: 24, source_database_path: databasePath, backup_path: validPath, checksum_sha256: validChecksum, @@ -7709,7 +7775,7 @@ describe("drift CLI convention review", () => { id: "backup_missing", repo_id: "repo_abc", repo_fingerprint: "repo-fp", - schema_version: 23, + schema_version: 24, source_database_path: databasePath, backup_path: join(dir, "missing.sqlite"), checksum_sha256: "b".repeat(64), @@ -7720,7 +7786,7 @@ describe("drift CLI convention review", () => { id: "backup_mismatch", repo_id: "repo_abc", repo_fingerprint: "repo-fp", - schema_version: 23, + schema_version: 24, source_database_path: databasePath, backup_path: mismatchPath, checksum_sha256: mismatchChecksum, @@ -7981,7 +8047,7 @@ describe("drift CLI convention review", () => { surface: "artifact" }, checksum_matches: true, - schema_version: 23 + schema_version: 24 }); expect(JSON.parse(verified.stdout).summary).toMatchObject({ valid: true, @@ -8162,7 +8228,7 @@ describe("drift CLI convention review", () => { valid: false, repo_id: "repo_abc", schema_supported: false, - schema_version: 23, + schema_version: 24, unsupported_migrations: ["004_unknown_future_schema"] }); }); @@ -8391,7 +8457,7 @@ describe("drift CLI convention review", () => { repo_id: "repo_abc", backup_path: backupPath, restored_database_path: targetDatabasePath, - schema_version: 23 + schema_version: 24 }); expect(payload.governance).toMatchObject({ read_only: false, @@ -8432,7 +8498,7 @@ describe("drift CLI convention review", () => { backup_path: backupPath, checksum_sha256: payload.restore.checksum_sha256, checksum_matches: true, - schema_version: 23, + schema_version: 24, graph_stale: payload.restore.graph_stale, requires_rescan: payload.restore.requires_rescan, staleness_reason: payload.restore.staleness_reason @@ -9098,7 +9164,7 @@ describe("drift CLI convention review", () => { relevant_file_count: 3, risky_area_count: 1, finding_count: 1, - blocking_finding_count: 1, + blocking_finding_count: 0, required_check_count: 1, safe_command_count: 0, baseline_active_count: 1, @@ -9128,7 +9194,7 @@ describe("drift CLI convention review", () => { }); expect(payload.conventions[0]).toMatchObject({ kind: "api_route_no_direct_data_access", - enforcement_mode: "block", + enforcement_mode: "warn", enforcement_capability: "deterministic_check" }); expect(payload.agent_contract_packet).toMatchObject({ @@ -9423,7 +9489,13 @@ describe("drift CLI convention review", () => { runtime: expect.any(Object), engine: expect.any(Object), migrations: expect.any(Object), - audit: expect.any(Object) + audit: expect.any(Object), + elections: { + candidate_count: 0, + accepted_count: 0, + rejected_count: 0, + rejected_inference_count: 0 + } } }); expect(JSON.stringify(payload)).not.toContain("Route imports prisma directly"); @@ -12285,8 +12357,14 @@ describe("drift CLI convention review", () => { const storage = openDriftStorage({ databasePath }); storage.migrate(); expect(storage.getConventionCandidate("candidate_no_direct_db")?.status).toBe("accepted"); - expect(storage.listAcceptedConventions("repo_abc")[0]?.severity).toBe("warning"); - expect(storage.getRepoContract("repo_abc")?.conventions[0]?.enforcement_mode).toBe("warn"); + expect(storage.listAcceptedConventions("repo_abc")[0]).toMatchObject({ + severity: "warning", + requires: { forbidden_imports: ["@/lib/prisma"] } + }); + expect(storage.getRepoContract("repo_abc")?.conventions[0]).toMatchObject({ + enforcement_mode: "warn", + requires: { forbidden_imports: ["@/lib/prisma"] } + }); expect(storage.listAuditEvents("repo_abc")[0]?.action).toBe("election_accepted"); storage.close(); }); @@ -12596,7 +12674,13 @@ describe("drift CLI convention review", () => { const storage = openDriftStorage({ databasePath }); storage.migrate(); expect(storage.getConventionCandidate("candidate_no_direct_db")?.status).toBe("rejected"); - expect(storage.listAuditEvents("repo_abc")[0]?.metadata).toEqual({ reason: "false inference" }); + expect(storage.listAuditEvents("repo_abc")[0]?.metadata).toMatchObject({ reason: "false inference" }); + expect(storage.getRepoContract("repo_abc")?.rejected_inferences[0]).toMatchObject({ + candidate_id: "candidate_no_direct_db", + evidence_fingerprint: "evidence_fp", + reason: "false inference", + rejected_by: "geoff" + }); storage.close(); }); @@ -12908,6 +12992,83 @@ describe("drift CLI convention review", () => { storage.close(); }); + it("rejects contract validate when a blocking convention is not deterministic", async () => { + const { databasePath } = await seedAcceptedDatabase(); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + const contract = storage.getRepoContract("repo_abc")!; + storage.upsertRepoContract({ + ...contract, + conventions: contract.conventions.map((convention) => ({ + ...convention, + enforcement_capability: "heuristic_check" + })) + }); + storage.close(); + + const result = await runCli([ + "--db", databasePath, + "contract", "validate", + "--repo", "repo_abc", + "--json" + ]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + valid: false, + compatibility: { + compatible: false, + reasons: ["blocking_non_deterministic_convention"] + } + }); + }); + + it("rejects imported blocking security contracts backed by candidate sensitive fields", async () => { + const { databasePath } = await seedAcceptedDatabase(); + const dir = await mkdtemp(join(tmpdir(), "drift-contract-candidate-sensitive-")); + tempDirs.push(dir); + const contractPath = join(dir, "contract.json"); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + const contract = storage.getRepoContract("repo_abc")!; + await writeFile(contractPath, JSON.stringify({ + ...contract, + conventions: contract.conventions.map((convention) => ({ + ...convention, + kind: "api_route_forbids_sensitive_response_fields", + matcher: { + kind: "api_route_forbids_sensitive_response_fields", + applies_to_file_roles: ["api_route"] + }, + requires: { + sensitive_response_fields: [{ + field_path: "user.email", + classification: "pii", + source: "candidate" + }] + } + })) + }, null, 2)); + storage.close(); + + const result = await runCli([ + "--db", databasePath, + "contract", "import", + contractPath, + "--repo", "repo_abc", + "--dry-run", + "--json" + ]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + compatibility: { + compatible: false, + reasons: ["candidate_sensitive_fields_blocking"] + } + }); + }); + it("guards contract export artifact paths and overwrites", async () => { const databasePath = await seedDatabase(); await runCli([ diff --git a/drift v3/packages/core/src/domain.ts b/drift v3/packages/core/src/domain.ts index f9fd36dd..925d9423 100644 --- a/drift v3/packages/core/src/domain.ts +++ b/drift v3/packages/core/src/domain.ts @@ -94,6 +94,13 @@ export interface ConventionMatcher { required_calls?: string[]; allowed_delegate_imports?: string[]; applies_to_file_roles?: FileRole[]; + file_roles?: FileRole[]; + path_globs?: string[]; + route_paths?: string[]; + methods?: string[]; + protection_kinds?: string[]; + middleware_ids?: string[]; + matcher_fact_ids?: string[]; } export type EnforcementCapability = @@ -639,6 +646,7 @@ export interface ConventionCandidate { rationale?: string; scope: ConventionScope; matcher: ConventionMatcher; + requires?: Record; suggested_severity: Severity; suggested_enforcement_mode: EnforcementMode; enforcement_capability: EnforcementCapability; @@ -646,6 +654,12 @@ export interface ConventionCandidate { scoring: ConventionScore; evidence_refs: EvidenceRef[]; counterexample_refs: EvidenceRef[]; + matcher_fingerprint?: string; + scope_fingerprint?: string; + graph_fingerprint?: string; + evidence_fingerprint?: string; + required_capabilities?: string[]; + reason_not_blocking?: "candidate_not_accepted" | "candidate_incomplete" | "candidate_heuristic"; status: ConventionStatus; created_at: string; } @@ -673,6 +687,9 @@ export interface AcceptedConvention { export interface RejectedInference { candidate_id: string; + evidence_fingerprint?: string; + matcher_fingerprint?: string; + scope_fingerprint?: string; reason: string; rejected_by: string; rejected_at: string; diff --git a/drift v3/packages/core/src/schemas.ts b/drift v3/packages/core/src/schemas.ts index 1df5d356..d8185bf6 100644 --- a/drift v3/packages/core/src/schemas.ts +++ b/drift v3/packages/core/src/schemas.ts @@ -111,7 +111,14 @@ export const ConventionMatcherSchema = z.object({ allowed_imports: z.array(z.string().min(1)).optional(), required_calls: z.array(z.string().min(1)).optional(), allowed_delegate_imports: z.array(z.string().min(1)).optional(), - applies_to_file_roles: z.array(FileRoleSchema).optional() + applies_to_file_roles: z.array(FileRoleSchema).optional(), + file_roles: z.array(FileRoleSchema).optional(), + path_globs: z.array(RepoRelativePatternSchema).optional(), + route_paths: z.array(z.string().min(1)).optional(), + methods: z.array(z.string().min(1)).optional(), + protection_kinds: z.array(z.string().min(1)).optional(), + middleware_ids: z.array(z.string().min(1)).optional(), + matcher_fact_ids: z.array(z.string().min(1)).optional() }); export const EnforcementCapabilitySchema = z.enum([ @@ -686,6 +693,7 @@ export const ConventionCandidateSchema = z.object({ rationale: z.string().optional(), scope: ConventionScopeSchema, matcher: ConventionMatcherSchema, + requires: z.record(z.unknown()).optional(), suggested_severity: SeveritySchema, suggested_enforcement_mode: EnforcementModeSchema, enforcement_capability: EnforcementCapabilitySchema, @@ -693,6 +701,16 @@ export const ConventionCandidateSchema = z.object({ scoring: ConventionScoreSchema, evidence_refs: z.array(EvidenceRefSchema), counterexample_refs: z.array(EvidenceRefSchema), + matcher_fingerprint: z.string().min(1).optional(), + scope_fingerprint: z.string().min(1).optional(), + graph_fingerprint: z.string().min(1).optional(), + evidence_fingerprint: z.string().min(1).optional(), + required_capabilities: z.array(z.string().min(1)).optional(), + reason_not_blocking: z.enum([ + "candidate_not_accepted", + "candidate_incomplete", + "candidate_heuristic" + ]).optional(), status: ConventionStatusSchema, created_at: z.string().datetime() }); @@ -720,6 +738,9 @@ export const AcceptedConventionSchema = z.object({ export const RejectedInferenceSchema = z.object({ candidate_id: z.string().min(1), + evidence_fingerprint: z.string().min(1).optional(), + matcher_fingerprint: z.string().min(1).optional(), + scope_fingerprint: z.string().min(1).optional(), reason: z.string().min(1), rejected_by: z.string().min(1), rejected_at: z.string().datetime() diff --git a/drift v3/packages/engine-contract/src/index.ts b/drift v3/packages/engine-contract/src/index.ts index f3d7325e..a0dc69ae 100644 --- a/drift v3/packages/engine-contract/src/index.ts +++ b/drift v3/packages/engine-contract/src/index.ts @@ -356,6 +356,18 @@ export const EngineCandidateSchema = z.object({ "api_route_no_direct_data_access", "api_route_requires_service_delegation", "api_route_requires_auth_helper", + "middleware_must_cover_routes", + "api_route_requires_request_validation", + "session_object_must_come_from_trusted_helper", + "api_route_requires_authorization", + "api_route_requires_tenant_scope", + "api_route_forbids_sensitive_response_fields", + "api_route_forbids_secret_exposure", + "api_route_forbids_untrusted_ssrf", + "api_route_forbids_raw_sql_without_params", + "api_route_cors_must_match_policy", + "api_route_requires_csrf_for_mutation", + "api_route_requires_rate_limit", "test_expected_for_changed_module", "custom_briefing" ]), @@ -369,6 +381,7 @@ export const EngineCandidateSchema = z.object({ rationale: z.string().min(1).optional(), scope: z.record(z.unknown()), matcher: z.record(z.unknown()), + requires: z.record(z.unknown()).optional(), suggested_severity: z.enum(["info", "warning", "error"]), suggested_enforcement_mode: z.enum(["off", "brief", "warn", "block"]), enforcement_capability: z.enum(["briefing_only", "heuristic_check", "deterministic_check"]), @@ -377,6 +390,12 @@ export const EngineCandidateSchema = z.object({ required_capabilities: z.array(z.string().min(1)), evidence_refs: z.array(EngineCandidateEvidenceRefSchema), counterexample_refs: z.array(EngineCandidateEvidenceRefSchema), + reason_not_blocking: z.enum([ + "candidate_not_accepted", + "candidate_incomplete", + "candidate_heuristic" + ]), + evidence_fingerprint: z.string().min(1), supersedes_candidate_id: z.string().min(1).optional() }); diff --git a/drift v3/packages/engine-contract/test/engine-contract.test.ts b/drift v3/packages/engine-contract/test/engine-contract.test.ts index 1a5b8076..bd9c4292 100644 --- a/drift v3/packages/engine-contract/test/engine-contract.test.ts +++ b/drift v3/packages/engine-contract/test/engine-contract.test.ts @@ -460,8 +460,11 @@ describe("engine contract schemas", () => { forbidden_imports: ["@/lib/db"], applies_to_file_roles: ["api_route"] }, + requires: { + forbidden_imports: ["@/lib/db"] + }, suggested_severity: "error", - suggested_enforcement_mode: "block", + suggested_enforcement_mode: "warn", enforcement_capability: "deterministic_check", confidence_label: "high", scoring: { @@ -484,7 +487,9 @@ describe("engine contract schemas", () => { file_hash: "a".repeat(64), redaction_state: "none" }], - counterexample_refs: [] + counterexample_refs: [], + reason_not_blocking: "candidate_not_accepted", + evidence_fingerprint: "evidence_fp" }], diagnostics: [], stats: { diff --git a/drift v3/packages/mcp/test/mcp.test.ts b/drift v3/packages/mcp/test/mcp.test.ts index 459ce66b..0f7dd5d0 100644 --- a/drift v3/packages/mcp/test/mcp.test.ts +++ b/drift v3/packages/mcp/test/mcp.test.ts @@ -3006,7 +3006,7 @@ describe("read-only MCP handlers", () => { mcp_version: "0.1.0", core_version: "0.1.0", scanner_version: "0.1.0", - supported_sqlite_schema_version: 23, + supported_sqlite_schema_version: 24, storage_driver: "sqlite" }, v1_scope: { diff --git a/drift v3/packages/storage/src/migrations.ts b/drift v3/packages/storage/src/migrations.ts index 69d3a79d..f0941239 100644 --- a/drift v3/packages/storage/src/migrations.ts +++ b/drift v3/packages/storage/src/migrations.ts @@ -695,5 +695,19 @@ export const MIGRATIONS: Migration[] = [ CREATE INDEX IF NOT EXISTS idx_security_boundary_proofs_repo_route ON security_boundary_proofs(repo_id, route_id); ` + }, + { + id: "024_phase7_candidate_election_metadata", + sql: ` + ALTER TABLE convention_candidates ADD COLUMN requires_json TEXT; + ALTER TABLE convention_candidates ADD COLUMN matcher_fingerprint TEXT; + ALTER TABLE convention_candidates ADD COLUMN scope_fingerprint TEXT; + ALTER TABLE convention_candidates ADD COLUMN graph_fingerprint TEXT; + ALTER TABLE convention_candidates ADD COLUMN evidence_fingerprint TEXT; + ALTER TABLE convention_candidates ADD COLUMN required_capabilities_json TEXT; + ALTER TABLE convention_candidates ADD COLUMN reason_not_blocking TEXT; + + ALTER TABLE accepted_conventions ADD COLUMN requires_json TEXT; + ` } ]; diff --git a/drift v3/packages/storage/src/sqlite-storage.ts b/drift v3/packages/storage/src/sqlite-storage.ts index ebab9e9a..de247797 100644 --- a/drift v3/packages/storage/src/sqlite-storage.ts +++ b/drift v3/packages/storage/src/sqlite-storage.ts @@ -1084,12 +1084,16 @@ export class SqliteDriftStorage { .prepare(` INSERT INTO convention_candidates ( id, repo_id, scan_id, kind, statement, rationale, scope_json, matcher_json, + requires_json, matcher_fingerprint, scope_fingerprint, graph_fingerprint, + evidence_fingerprint, required_capabilities_json, reason_not_blocking, suggested_severity, suggested_enforcement_mode, enforcement_capability, confidence_label, scoring_json, evidence_refs_json, counterexample_refs_json, status, created_at ) VALUES ( @id, @repo_id, @scan_id, @kind, @statement, @rationale, @scope_json, @matcher_json, + @requires_json, @matcher_fingerprint, @scope_fingerprint, @graph_fingerprint, + @evidence_fingerprint, @required_capabilities_json, @reason_not_blocking, @suggested_severity, @suggested_enforcement_mode, @enforcement_capability, @confidence_label, @scoring_json, @evidence_refs_json, @counterexample_refs_json, @status, @created_at @@ -1099,6 +1103,13 @@ export class SqliteDriftStorage { rationale = excluded.rationale, scope_json = excluded.scope_json, matcher_json = excluded.matcher_json, + requires_json = excluded.requires_json, + matcher_fingerprint = excluded.matcher_fingerprint, + scope_fingerprint = excluded.scope_fingerprint, + graph_fingerprint = excluded.graph_fingerprint, + evidence_fingerprint = excluded.evidence_fingerprint, + required_capabilities_json = excluded.required_capabilities_json, + reason_not_blocking = excluded.reason_not_blocking, suggested_severity = excluded.suggested_severity, suggested_enforcement_mode = excluded.suggested_enforcement_mode, enforcement_capability = excluded.enforcement_capability, @@ -1106,13 +1117,27 @@ export class SqliteDriftStorage { scoring_json = excluded.scoring_json, evidence_refs_json = excluded.evidence_refs_json, counterexample_refs_json = excluded.counterexample_refs_json, - status = excluded.status + status = CASE + WHEN convention_candidates.status = 'rejected' + AND COALESCE(convention_candidates.evidence_fingerprint, '') = COALESCE(excluded.evidence_fingerprint, '') + THEN convention_candidates.status + ELSE excluded.status + END `) .run({ ...parsed, rationale: parsed.rationale ?? null, scope_json: stringifyJson(parsed.scope), matcher_json: stringifyJson(parsed.matcher), + requires_json: parsed.requires ? stringifyJson(parsed.requires) : null, + matcher_fingerprint: parsed.matcher_fingerprint ?? null, + scope_fingerprint: parsed.scope_fingerprint ?? null, + graph_fingerprint: parsed.graph_fingerprint ?? null, + evidence_fingerprint: parsed.evidence_fingerprint ?? null, + required_capabilities_json: parsed.required_capabilities + ? stringifyJson(parsed.required_capabilities) + : null, + reason_not_blocking: parsed.reason_not_blocking ?? null, scoring_json: stringifyJson(parsed.scoring), evidence_refs_json: stringifyJson(parsed.evidence_refs), counterexample_refs_json: stringifyJson(parsed.counterexample_refs) @@ -1147,13 +1172,13 @@ export class SqliteDriftStorage { .prepare(` INSERT INTO accepted_conventions ( id, repo_id, contract_id, kind, statement, rationale, scope_json, matcher_json, - severity, enforcement_mode, enforcement_capability, exceptions_json, + requires_json, severity, enforcement_mode, enforcement_capability, exceptions_json, evidence_refs_json, counterexample_refs_json, accepted_by, accepted_at, updated_at, expires_at ) VALUES ( @id, @repo_id, @contract_id, @kind, @statement, @rationale, @scope_json, @matcher_json, - @severity, @enforcement_mode, @enforcement_capability, @exceptions_json, + @requires_json, @severity, @enforcement_mode, @enforcement_capability, @exceptions_json, @evidence_refs_json, @counterexample_refs_json, @accepted_by, @accepted_at, @updated_at, @expires_at ) @@ -1163,6 +1188,7 @@ export class SqliteDriftStorage { rationale = excluded.rationale, scope_json = excluded.scope_json, matcher_json = excluded.matcher_json, + requires_json = excluded.requires_json, severity = excluded.severity, enforcement_mode = excluded.enforcement_mode, enforcement_capability = excluded.enforcement_capability, @@ -1178,6 +1204,7 @@ export class SqliteDriftStorage { rationale: parsed.rationale ?? null, scope_json: stringifyJson(parsed.scope), matcher_json: stringifyJson(parsed.matcher), + requires_json: parsed.requires ? stringifyJson(parsed.requires) : null, exceptions_json: stringifyJson(parsed.exceptions), evidence_refs_json: stringifyJson(parsed.evidence_refs), counterexample_refs_json: stringifyJson(parsed.counterexample_refs), @@ -1759,9 +1786,18 @@ function conventionCandidateFromRow(row: unknown): ConventionCandidate { rationale: record.rationale ?? undefined, scope: parseJsonObject(record.scope_json), matcher: parseJsonObject(record.matcher_json), + requires: record.requires_json ? parseJsonObject(record.requires_json) : undefined, scoring: parseJsonObject(record.scoring_json), evidence_refs: parseJsonArray(record.evidence_refs_json), - counterexample_refs: parseJsonArray(record.counterexample_refs_json) + counterexample_refs: parseJsonArray(record.counterexample_refs_json), + matcher_fingerprint: record.matcher_fingerprint ?? undefined, + scope_fingerprint: record.scope_fingerprint ?? undefined, + graph_fingerprint: record.graph_fingerprint ?? undefined, + evidence_fingerprint: record.evidence_fingerprint ?? undefined, + required_capabilities: record.required_capabilities_json + ? parseJsonArray(record.required_capabilities_json) + : undefined, + reason_not_blocking: record.reason_not_blocking ?? undefined }); } @@ -1772,6 +1808,7 @@ function acceptedConventionFromRow(row: unknown): AcceptedConvention { rationale: record.rationale ?? undefined, scope: parseJsonObject(record.scope_json), matcher: parseJsonObject(record.matcher_json), + requires: record.requires_json ? parseJsonObject(record.requires_json) : undefined, exceptions: parseJsonArray(record.exceptions_json), evidence_refs: parseJsonArray(record.evidence_refs_json), counterexample_refs: parseJsonArray(record.counterexample_refs_json), diff --git a/drift v3/packages/storage/test/sqlite-storage.test.ts b/drift v3/packages/storage/test/sqlite-storage.test.ts index df3b4075..8b7534c3 100644 --- a/drift v3/packages/storage/test/sqlite-storage.test.ts +++ b/drift v3/packages/storage/test/sqlite-storage.test.ts @@ -63,7 +63,8 @@ describe("SQLite Drift storage", () => { "020_machine_contract_versions", "021_graph_evidence_confidence", "022_fact_imported_name", - "023_security_boundary_proofs" + "023_security_boundary_proofs", + "024_phase7_candidate_election_metadata" ]); storage.close(); }); @@ -132,7 +133,8 @@ describe("SQLite Drift storage", () => { "020_machine_contract_versions", "021_graph_evidence_confidence", "022_fact_imported_name", - "023_security_boundary_proofs" + "023_security_boundary_proofs", + "024_phase7_candidate_election_metadata" ]); expect(storage.getRepo("repo_abc")?.fingerprint).toBe("repo-fp"); storage.close(); @@ -1786,6 +1788,7 @@ describe("SQLite Drift storage", () => { forbidden_imports: ["@/lib/prisma"], applies_to_file_roles: ["api_route"] }, + requires: { forbidden_imports: ["@/lib/prisma"] }, suggested_severity: "error", suggested_enforcement_mode: "block", enforcement_capability: "deterministic_check", @@ -1799,6 +1802,12 @@ describe("SQLite Drift storage", () => { }, evidence_refs: [], counterexample_refs: [], + matcher_fingerprint: "matcher_fp", + scope_fingerprint: "scope_fp", + graph_fingerprint: "graph_fp", + evidence_fingerprint: "evidence_fp", + required_capabilities: ["syntax_facts"], + reason_not_blocking: "candidate_not_accepted", status: "candidate", created_at: "2026-05-10T00:00:01.000Z" }); @@ -1814,6 +1823,7 @@ describe("SQLite Drift storage", () => { forbidden_imports: ["@/lib/prisma"], applies_to_file_roles: ["api_route" as const] }, + requires: { forbidden_imports: ["@/lib/prisma"] }, severity: "error" as const, enforcement_mode: "block" as const, enforcement_capability: "deterministic_check" as const, @@ -1848,9 +1858,17 @@ describe("SQLite Drift storage", () => { agent_permissions: [] }); - expect(storage.getConventionCandidate("candidate_no_direct_db")?.status).toBe("candidate"); + expect(storage.getConventionCandidate("candidate_no_direct_db")).toMatchObject({ + status: "candidate", + requires: { forbidden_imports: ["@/lib/prisma"] }, + evidence_fingerprint: "evidence_fp", + reason_not_blocking: "candidate_not_accepted" + }); expect(storage.listConventionCandidates("repo_abc", { status: "candidate" })).toHaveLength(1); - expect(storage.listAcceptedConventions("repo_abc")[0]?.id).toBe("convention_no_direct_db"); + expect(storage.listAcceptedConventions("repo_abc")[0]).toMatchObject({ + id: "convention_no_direct_db", + requires: { forbidden_imports: ["@/lib/prisma"] } + }); expect(storage.getRepoContract("repo_abc")?.conventions[0]?.id).toBe("convention_no_direct_db"); storage.close(); }); diff --git a/drift v3/scripts/run-beta-proof.mjs b/drift v3/scripts/run-beta-proof.mjs index 5347b8ed..f6f16661 100644 --- a/drift v3/scripts/run-beta-proof.mjs +++ b/drift v3/scripts/run-beta-proof.mjs @@ -35,6 +35,7 @@ try { ]); const databasePath = started.state.database_path; const repoId = started.repo.id; + promoteDirectDataAccessConventionToBlock({ databasePath, repoId }); const requiredCheckCommand = "node -e \"process.stdout.write('ok')\""; addRequiredProofContract({ @@ -314,6 +315,34 @@ async function createFixture(dir) { return { repoRoot, stateRoot }; } +function promoteDirectDataAccessConventionToBlock({ databasePath, repoId }) { + const storage = openDriftStorage({ databasePath }); + try { + const contract = storage.getRepoContract(repoId); + if (!contract) { + throw new Error(`No repo contract exists for ${repoId}.`); + } + const conventions = storage.listAcceptedConventions(repoId).map((convention) => + convention.kind === "api_route_no_direct_data_access" + ? { ...convention, enforcement_mode: "block" } + : convention + ); + for (const convention of conventions) { + storage.upsertAcceptedConvention(repoId, convention); + } + storage.upsertRepoContract({ + ...contract, + conventions: conventions.map((convention) => + convention.kind === "api_route_no_direct_data_access" + ? { ...convention, enforcement_mode: "block" } + : convention + ) + }); + } finally { + storage.close(); + } +} + function addRequiredProofContract({ databasePath, repoId, command }) { const storage = openDriftStorage({ databasePath }); try { diff --git a/drift v3/test/e2e/cli-bin.test.ts b/drift v3/test/e2e/cli-bin.test.ts index 99b42d09..a1ca4e43 100644 --- a/drift v3/test/e2e/cli-bin.test.ts +++ b/drift v3/test/e2e/cli-bin.test.ts @@ -52,7 +52,7 @@ describe("built drift CLI binary", () => { expect(payload.runtime).toMatchObject({ cli_version: "0.1.0", core_version: "0.1.0", - supported_sqlite_schema_version: 23, + supported_sqlite_schema_version: 24, storage_driver: "sqlite" }); expect(payload.v1_scope).toMatchObject({ diff --git a/drift v3/test/e2e/golden.test.ts b/drift v3/test/e2e/golden.test.ts index 59c557ff..0f93ebe3 100644 --- a/drift v3/test/e2e/golden.test.ts +++ b/drift v3/test/e2e/golden.test.ts @@ -147,7 +147,7 @@ describe("golden fixture CLI lifecycle", () => { "governance_read_only": false, "next_command_count": 3, "repo_matches": true, - "schema_version": 23, + "schema_version": 24, } `); @@ -166,7 +166,7 @@ describe("golden fixture CLI lifecycle", () => { "governance_read_only": false, "next_command_count": 2, "repo_matches": true, - "schema_version": 23, + "schema_version": 24, "write_intent": true, } `); diff --git a/drift v3/test/e2e/installed-flow.test.ts b/drift v3/test/e2e/installed-flow.test.ts index e94334e0..5963f467 100644 --- a/drift v3/test/e2e/installed-flow.test.ts +++ b/drift v3/test/e2e/installed-flow.test.ts @@ -153,7 +153,7 @@ describe("installed Drift package flow", () => { expect(doctorPayload.runtime).toMatchObject({ cli_version: "0.1.0", core_version: "0.1.0", - supported_sqlite_schema_version: 23, + supported_sqlite_schema_version: 24, storage_driver: "sqlite" }); expect(doctorPayload.engine).toMatchObject({ @@ -212,7 +212,7 @@ describe("installed Drift package flow", () => { expect(versionPayload.runtime).toMatchObject({ cli_version: "0.1.0", core_version: "0.1.0", - supported_sqlite_schema_version: 23, + supported_sqlite_schema_version: 24, storage_driver: "sqlite" }); expect(versionPayload.engine).toMatchObject({ @@ -1177,7 +1177,7 @@ describe("installed Drift package flow", () => { expect(runtimePayload.runtime).toMatchObject({ mcp_version: "0.1.0", core_version: "0.1.0", - supported_sqlite_schema_version: 23, + supported_sqlite_schema_version: 24, storage_driver: "sqlite" }); expect(runtimePayload.governance).toMatchObject({