1use std::collections::BTreeSet;
37
38use evorule_tcb::discipline::DISCIPLINE_CORE_EVAL;
39use evorule_tcb::{execute_transition, JsonValue, TransitionResult};
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct DisciplineError(pub String);
44
45impl std::fmt::Display for DisciplineError {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 write!(f, "discipline gate error: {}", self.0)
48 }
49}
50
51impl std::error::Error for DisciplineError {}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Violation {
56 pub path: String,
58 pub node_type: String,
60 pub code: String,
62 pub reason: String,
64}
65
66fn load_discipline() -> Result<Vec<JsonValue>, DisciplineError> {
71 let raw: serde_json::Value = serde_json::from_str(DISCIPLINE_CORE_EVAL).map_err(|e| {
72 DisciplineError(format!(
73 "embedded discipline core_eval is not valid JSON: {e}"
74 ))
75 })?;
76 let arr = raw.as_array().ok_or_else(|| {
77 DisciplineError("embedded discipline core_eval must be a JSON array of rules".to_string())
78 })?;
79 Ok(arr.iter().map(evorule_reactor::serde_to_tcb).collect())
80}
81
82pub fn discipline_len() -> Result<usize, DisciplineError> {
84 Ok(load_discipline()?.len())
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Default)]
89pub struct Report {
90 pub scanned: usize,
92 pub violations: Vec<Violation>,
94}
95
96pub fn check(ruleset: &[JsonValue]) -> Result<Report, DisciplineError> {
98 let discipline = load_discipline()?;
99 let tops: Vec<TopFacts> = ruleset.iter().map(build_top_facts).collect();
100 let nodes = flatten(ruleset, &tops);
101
102 let mut violations = Vec::new();
103 for nd in &nodes {
104 let probe = build_probe(nd, &tops);
105 match execute_transition(&discipline, &probe, &JsonValue::Null, &[]) {
107 Ok(TransitionResult::Halted { rule_index, reason }) => violations.push(Violation {
108 path: nd.path.clone(),
109 node_type: node_type(nd.node).to_string(),
110 code: {
111 let n = rule_index.saturating_add(1);
112 format!("DC-{n:02}")
113 },
114 reason,
115 }),
116 Ok(_) => {}
117 Err(e) => {
118 return Err(DisciplineError(format!(
119 "discipline evaluation failed at {}: {e:?}",
120 nd.path
121 )))
122 }
123 }
124 }
125 Ok(Report {
126 scanned: nodes.len(),
127 violations,
128 })
129}
130
131fn node_type(n: &JsonValue) -> &str {
134 n.get("type").and_then(JsonValue::as_str).unwrap_or("")
135}
136
137fn params_has_key(n: &JsonValue, key: &str) -> bool {
144 n.get("params")
145 .and_then(JsonValue::as_object)
146 .map(|o| o.contains_key(key))
147 .unwrap_or(false)
148}
149
150fn children_of<'a>(n: &'a JsonValue, key: &str) -> Vec<&'a JsonValue> {
151 match n
152 .get("params")
153 .and_then(|p| p.get(key))
154 .and_then(JsonValue::as_array)
155 {
156 Some(a) => a.iter().collect(),
157 None => Vec::new(),
158 }
159}
160
161fn collect_targets(domain: &JsonValue, out: &mut BTreeSet<String>) {
163 match domain.get("type").and_then(JsonValue::as_str) {
164 Some("instruction") => {
165 if let Some(s) = domain.get("instruction_type").and_then(JsonValue::as_str) {
166 out.insert(s.to_string());
167 }
168 }
169 Some("all") => {
170 if let Some(JsonValue::Array(items)) = domain.get("inner") {
171 for d in items {
172 collect_targets(d, out);
173 }
174 }
175 }
176 Some("not") => {
177 if let Some(inner) = domain.get("inner") {
178 collect_targets(inner, out);
179 }
180 }
181 _ => {}
182 }
183}
184
185fn top_targets(rule: &JsonValue) -> BTreeSet<String> {
186 let mut s = BTreeSet::new();
187 if let Some(d) = rule.get("params").and_then(|p| p.get("domain")) {
188 collect_targets(d, &mut s);
189 }
190 s
191}
192
193fn provable_targets(rule: &JsonValue) -> BTreeSet<String> {
200 fn walk(domain: &JsonValue, out: &mut BTreeSet<String>) {
201 match domain.get("type").and_then(JsonValue::as_str) {
202 Some("instruction") => {
203 if let Some(s) = domain.get("instruction_type").and_then(JsonValue::as_str) {
204 out.insert(s.to_string());
205 }
206 }
207 Some("all") => {
209 if let Some(JsonValue::Array(items)) = domain.get("inner") {
210 for d in items {
211 walk(d, out);
212 }
213 }
214 }
215 _ => {}
216 }
217 }
218 let mut s = BTreeSet::<String>::new();
219 if let Some(d) = rule.get("params").and_then(|p| p.get("domain")) {
220 walk(d, &mut s);
221 }
222 s
223}
224
225fn is_early_exit(n: &JsonValue) -> bool {
227 match node_type(n) {
228 "enforce" | "io_request" => true,
229 "branch" => ["on_true", "on_false"]
230 .iter()
231 .any(|k| children_of(n, k).iter().any(|c| is_early_exit(c))),
232 _ => false,
233 }
234}
235
236fn count_io(n: &JsonValue) -> usize {
238 let mut c = usize::from(node_type(n) == "io_request");
239 if node_type(n) == "branch" {
240 for key in ["on_true", "on_false"] {
241 for ch in children_of(n, key) {
242 c += count_io(ch);
243 }
244 }
245 }
246 c
247}
248
249fn has_mutating(n: &JsonValue) -> bool {
251 if matches!(node_type(n), "set" | "push") {
252 return true;
253 }
254 node_type(n) == "branch"
255 && ["on_true", "on_false"]
256 .iter()
257 .any(|k| children_of(n, k).iter().any(|c| has_mutating(c)))
258}
259
260fn maybe_overlap(a: &BTreeSet<String>, b: &BTreeSet<String>) -> bool {
262 a.is_empty() || b.is_empty() || a.intersection(b).next().is_some()
263}
264
265struct TopFacts {
267 shadow_source: bool,
269 targets: BTreeSet<String>,
271 provable: BTreeSet<String>,
273 pos_exists: BTreeSet<String>,
275 neg_exists: BTreeSet<String>,
277}
278
279fn build_top_facts(r: &JsonValue) -> TopFacts {
281 let mut pos = BTreeSet::new();
282 let mut neg = BTreeSet::new();
283 if let Some(d) = r.get("params").and_then(|p| p.get("domain")) {
284 collect_exists_paths(d, false, &mut pos, &mut neg);
285 }
286 TopFacts {
287 shadow_source: is_shadow_source(r),
288 targets: top_targets(r),
289 provable: provable_targets(r),
290 pos_exists: pos,
291 neg_exists: neg,
292 }
293}
294
295fn collect_exists_paths(
303 domain: &JsonValue,
304 negated: bool,
305 pos: &mut BTreeSet<String>,
306 neg: &mut BTreeSet<String>,
307) {
308 match domain.get("type").and_then(JsonValue::as_str) {
309 Some("exists") => {
310 if let Some(p) = domain.get("path").and_then(JsonValue::as_str) {
311 if negated {
312 neg.insert(p.to_string());
313 } else {
314 pos.insert(p.to_string());
315 }
316 }
317 }
318 Some("all") => {
319 if let Some(JsonValue::Array(items)) = domain.get("inner") {
320 for d in items {
321 collect_exists_paths(d, negated, pos, neg);
322 }
323 }
324 }
325 Some("not")
327 if domain
328 .get("inner")
329 .and_then(|i| i.get("type"))
330 .and_then(JsonValue::as_str)
331 == Some("exists") =>
332 {
333 if let Some(inner) = domain.get("inner") {
334 collect_exists_paths(inner, !negated, pos, neg);
335 }
336 }
337 _ => {}
338 }
339}
340
341fn mutually_exclusive(a: &TopFacts, b: &TopFacts) -> bool {
347 !a.pos_exists.is_disjoint(&b.neg_exists) || !b.pos_exists.is_disjoint(&a.neg_exists)
348}
349
350fn is_shadow_source(top_rule: &JsonValue) -> bool {
355 match node_type(top_rule) {
356 "enforce" => false,
357 _ => is_early_exit(top_rule),
358 }
359}
360
361struct Node<'a> {
363 top: usize,
364 path: String,
365 node: &'a JsonValue,
366 nested: bool,
367 targets: BTreeSet<String>,
368 io_in_tree: usize,
370 preceded_by_mutating: bool,
376}
377
378fn walk<'a>(nd: &Node<'a>, out: &mut Vec<Node<'a>>) {
383 out.push(Node {
384 top: nd.top,
385 path: nd.path.clone(),
386 node: nd.node,
387 nested: nd.nested,
388 targets: nd.targets.clone(),
389 io_in_tree: nd.io_in_tree,
390 preceded_by_mutating: nd.preceded_by_mutating,
391 });
392 if node_type(nd.node) == "branch" {
393 for key in ["on_true", "on_false"] {
394 let children = children_of(nd.node, key);
395 for (j, c) in children.iter().enumerate() {
396 let prior: Vec<&'a JsonValue> = children.iter().take(j).copied().collect();
397 let child = child_node(nd, format!("{}.{key}[{j}]", nd.path), c, true, &prior);
398 walk(&child, out);
399 }
400 }
401 }
402}
403
404fn child_node<'a>(
411 parent: &Node<'a>,
412 path: String,
413 node: &'a JsonValue,
414 nested: bool,
415 before: &[&'a JsonValue],
416) -> Node<'a> {
417 Node {
418 top: parent.top,
419 path,
420 node,
421 nested,
422 targets: parent.targets.clone(),
423 io_in_tree: parent.io_in_tree,
424 preceded_by_mutating: parent.preceded_by_mutating || before.iter().any(|s| has_mutating(s)),
425 }
426}
427
428fn flatten<'a>(rules: &'a [JsonValue], tops: &[TopFacts]) -> Vec<Node<'a>> {
429 let mut out = Vec::new();
430 for (i, r) in rules.iter().enumerate() {
431 let before: Vec<&JsonValue> = rules.iter().take(i).collect();
432 let preceded_by_mutating = before.iter().enumerate().any(|(j, s)| {
441 has_mutating(s)
442 && maybe_overlap(&tops[j].provable, &tops[i].provable)
443 && !mutually_exclusive(&tops[j], &tops[i])
444 });
445 let root = Node {
446 top: i,
447 path: format!("[{i}]"),
448 node: r,
449 nested: false,
450 targets: tops[i].targets.clone(),
451 io_in_tree: count_io(r),
452 preceded_by_mutating,
453 };
454 walk(&root, &mut out);
455 }
456 out
457}
458
459fn build_probe(nd: &Node<'_>, tops: &[TopFacts]) -> JsonValue {
461 let owner = tops.get(nd.top);
462 let shadowed = node_type(nd.node) == "io_request"
463 && (0..nd.top).any(|j| match tops.get(j) {
464 Some(t) => {
465 t.shadow_source
466 && maybe_overlap(&t.targets, &nd.targets)
467 && !owner.is_some_and(|o| mutually_exclusive(t, o))
470 }
471 None => false,
472 });
473
474 let mut probe = nd.node.clone();
475 let ctx = JsonValue::object_from_pairs(&[
476 ("path", JsonValue::string(nd.path.clone())),
477 ("nested", JsonValue::bool(nd.nested)),
478 ("shadowed", JsonValue::bool(shadowed)),
479 (
480 "io_in_tree",
481 JsonValue::integer(i64::try_from(nd.io_in_tree).unwrap_or(i64::MAX)),
482 ),
483 (
484 "preceded_by_mutating",
485 JsonValue::bool(nd.preceded_by_mutating),
486 ),
487 ("has_attr", JsonValue::bool(params_has_key(nd.node, "attr"))),
488 (
489 "has_value",
490 JsonValue::bool(params_has_key(nd.node, "value")),
491 ),
492 ]);
493 probe.insert("_ctx".to_string(), ctx);
494 probe
495}
496
497#[cfg(test)]
498mod tests {
499 #![allow(clippy::unwrap_used)]
500 use super::*;
501
502 fn ruleset(json: &str) -> Vec<JsonValue> {
504 let v: serde_json::Value = serde_json::from_str(json).unwrap();
505 match v {
506 serde_json::Value::Array(a) => a.iter().map(evorule_reactor::serde_to_tcb).collect(),
507 other => vec![evorule_reactor::serde_to_tcb(&other)],
508 }
509 }
510
511 fn codes(violations: &[Violation]) -> Vec<&str> {
512 violations.iter().map(|v| v.code.as_str()).collect()
513 }
514
515 const GOOD: &str = r#"[
516 {"type":"enforce","params":{"domain":{"type":"instruction","instruction_type":"set"},"reason":"r1"}},
517 {"type":"branch","params":{"domain":{"type":"exists","path":"a"},
518 "on_true":[{"type":"set","params":{"attr":"x","value":1}}],
519 "on_false":[{"type":"io_request","params":{"io_type":"llm"}}]}}
520 ]"#;
521
522 #[test]
523 fn good_ruleset_passes_the_gate() {
524 let report = check(&ruleset(GOOD)).unwrap();
525 let v = report.violations;
526 assert!(report.scanned > 0, "应实际扫描到节点");
527 assert!(v.is_empty(), "合规规则集不得被拒载: {v:?}");
528 }
529
530 #[test]
531 fn unknown_meta_instruction_is_rejected() {
532 let v = check(&ruleset(r#"[{"type":"while_loop","params":{}}]"#))
533 .unwrap()
534 .violations;
535 assert_eq!(codes(&v), vec!["DC-01"], "未知元指令应命中 DC-01");
536 }
537
538 #[test]
539 fn nested_enforce_is_rejected() {
540 let json = r#"[{"type":"branch","params":{"domain":{"type":"exists","path":"a"},
541 "on_true":[{"type":"enforce","params":{"domain":{"type":"exists","path":"b"},"reason":"r"}}],
542 "on_false":[]}}]"#;
543 let v = check(&ruleset(json)).unwrap().violations;
544 assert_eq!(codes(&v), vec!["DC-02"], "嵌套 enforce 应命中 DC-02");
545 }
546
547 #[test]
548 fn shadowed_io_request_is_rejected() {
549 let json = r#"[
551 {"type":"io_request","params":{"io_type":"audit"},
552 "domain":{"type":"instruction","instruction_type":"call_external"}},
553 {"type":"branch","params":{"domain":{"type":"instruction","instruction_type":"call_external"},
554 "on_true":[{"type":"io_request","params":{"io_type":"llm"}}],"on_false":[]}}
555 ]"#;
556 let v = check(&ruleset(json)).unwrap().violations;
557 assert!(
558 codes(&v).contains(&"DC-05"),
559 "被遮蔽的 io_request 应命中 DC-05, got {v:?}"
560 );
561 }
562
563 #[test]
564 fn mutually_exclusive_rules_are_not_shadow_sources() {
565 let json = r#"[
570 {"type":"branch","params":{"domain":{"type":"all","inner":[
571 {"type":"instruction","instruction_type":"call_service"},
572 {"type":"exists","path":"p.service_name"}]},
573 "on_true":[{"type":"io_request","params":{"io_type":"call_service"}}]}},
574 {"type":"branch","params":{"domain":{"type":"all","inner":[
575 {"type":"instruction","instruction_type":"call_service"},
576 {"type":"not","inner":{"type":"exists","path":"p.service_name"}}]},
577 "on_true":[{"type":"io_request","params":{"io_type":"call_service"}}]}}
578 ]"#;
579 let v = check(&ruleset(json)).unwrap().violations;
580 assert!(v.is_empty(), "互斥规则不得判遮蔽: {v:?}");
581 }
582
583 #[test]
584 fn negated_all_does_not_create_false_exclusion() {
585 let json = r#"[{"type":"branch","params":{"domain":{"type":"all","inner":[{"type":"instruction","instruction_type":"call_service"},{"type":"exists","path":"p.service_name"}]},"on_true":[{"type":"io_request","params":{"io_type":"call_service"}}]}},{"type":"branch","params":{"domain":{"type":"all","inner":[{"type":"instruction","instruction_type":"call_service"},{"type":"not","inner":{"type":"all","inner":[{"type":"exists","path":"p.service_name"}]}},{"type":"exists","path":"p.tool_name"}]},"on_true":[{"type":"io_request","params":{"io_type":"call_service"}}]}}]"#;
588 let v = check(&ruleset(json)).unwrap().violations;
589 assert!(
590 codes(&v).contains(&"DC-05"),
591 "not(all) 嵌套不构成互斥,保守遮蔽判定应生效: {v:?}"
592 );
593 }
594
595 #[test]
596 fn double_io_in_one_rule_is_rejected() {
597 let json = r#"[{"type":"branch","params":{"domain":{"type":"exists","path":"a"},
598 "on_true":[{"type":"io_request","params":{"io_type":"llm"}}],
599 "on_false":[{"type":"io_request","params":{"io_type":"db"}}]}}]"#;
600 let v = check(&ruleset(json)).unwrap().violations;
601 assert!(
602 codes(&v).iter().filter(|c| **c == "DC-08").count() == 2,
603 "一条规则内两个 io_request 应命中 DC-08 两次, got {v:?}"
604 );
605 }
606
607 #[test]
608 fn io_request_after_mutation_is_rejected() {
609 let json = r#"[
611 {"type":"set","params":{"attr":"x","value":1}},
612 {"type":"io_request","params":{"io_type":"llm"}}
613 ]"#;
614 let v = check(&ruleset(json)).unwrap().violations;
615 assert!(
616 codes(&v).contains(&"DC-09"),
617 "io_request 前有改状态操作应命中 DC-09, got {v:?}"
618 );
619 }
620
621 #[test]
622 fn disjoint_top_rule_mutations_are_not_same_pass() {
623 let json = r#"[{"type":"branch","params":{"domain":{"type":"instruction","instruction_type":"foo"},"on_true":[{"type":"set","params":{"attr":"_args.v","value":1}}],"on_false":[]}},{"type":"branch","params":{"domain":{"type":"instruction","instruction_type":"bar"},"on_true":[{"type":"io_request","params":{"io_type":"call_service","args":"__exec__.payload._args"}}],"on_false":[]}}]"#;
628 let v = check(&ruleset(json)).unwrap().violations;
629 assert!(
630 v.is_empty(),
631 "指令类型不相交的前序规则不构成同趟前置: {v:?}"
632 );
633 }
634
635 #[test]
636 fn overlapping_top_rule_mutations_still_rejected() {
637 let json = r#"[{"type":"branch","params":{"domain":{"type":"instruction","instruction_type":"foo"},"on_true":[{"type":"set","params":{"attr":"_args.v","value":1}}],"on_false":[]}},{"type":"branch","params":{"domain":{"type":"instruction","instruction_type":"foo"},"on_true":[{"type":"io_request","params":{"io_type":"call_service","args":"__exec__.payload._args"}}],"on_false":[]}}]"#;
640 let v = check(&ruleset(json)).unwrap().violations;
641 assert!(
642 codes(&v).contains(&"DC-09"),
643 "目标集相交的前序 set 仍是同趟前置, got {v:?}"
644 );
645
646 let any_pred = r#"[{"type":"set","params":{"attr":"x","value":1}},{"type":"branch","params":{"domain":{"type":"instruction","instruction_type":"bar"},"on_true":[{"type":"io_request","params":{"io_type":"llm"}}],"on_false":[]}}]"#;
647 let v2 = check(&ruleset(any_pred)).unwrap().violations;
648 assert!(
649 codes(&v2).contains(&"DC-09"),
650 "无指令谓词(ANY)的前序 set 按保守口径仍命中, got {v2:?}"
651 );
652 }
653
654 #[test]
655 fn mutually_exclusive_top_rules_are_not_same_pass() {
656 let json = r#"[{"type":"branch","params":{"domain":{"type":"all","inner":[{"type":"instruction","instruction_type":"call_service"},{"type":"exists","path":"__exec__.instruction.params.service_name"}]},"on_true":[{"type":"set","params":{"attr":"x","value":1}}],"on_false":[]}},{"type":"branch","params":{"domain":{"type":"all","inner":[{"type":"instruction","instruction_type":"call_service"},{"type":"not","inner":{"type":"exists","path":"__exec__.instruction.params.service_name"}}]},"on_true":[{"type":"branch","params":{"domain":{"type":"exists","path":"__exec__.payload.__io_results__.call_service"},"on_false":[{"type":"io_request","params":{"io_type":"call_service"}}],"on_true":[]}}],"on_false":[]}}]"#;
661 let v = check(&ruleset(json)).unwrap().violations;
662 assert!(
663 v.is_empty(),
664 "exists/¬exists 互斥的前序规则不构成同趟前置: {v:?}"
665 );
666 }
667
668 #[test]
669 fn missing_type_field_is_rejected() {
670 let v = check(&ruleset(r#"[{"params":{}}]"#)).unwrap().violations;
671 assert_eq!(codes(&v), vec!["DC-01"], "缺 type 字段应命中 DC-01");
672 }
673
674 #[test]
678 fn set_with_null_value_is_accepted() {
679 let json = r#"[{"type":"set","params":{"attr":"__io_results__.demo","operation":"set","value":null}}]"#;
680 let v = check(&ruleset(json)).unwrap().violations;
681 assert!(v.is_empty(), "value=null 的 set 不得被拒载: {v:?}");
682 }
683
684 #[test]
685 fn set_without_value_field_is_rejected() {
686 let v = check(&ruleset(r#"[{"type":"set","params":{"attr":"x"}}]"#))
687 .unwrap()
688 .violations;
689 assert_eq!(codes(&v), vec!["DC-06"], "缺 value 字段应命中 DC-06");
690 }
691
692 #[test]
695 fn embedded_discipline_ruleset_is_self_compliant() {
696 let report = check(&load_discipline().unwrap()).unwrap();
697 assert!(
698 report.violations.is_empty(),
699 "纪律数据自身违规(自指失败): {:?}",
700 report.violations
701 );
702 }
703}