1use std::collections::{BTreeMap, BTreeSet};
22use std::fmt::Write as _;
23use std::path::Path;
24
25use keel_core_api::policy::{FlowMatchRule, Policy};
26use serde::Serialize;
27
28use crate::cmd_match::{compile_cmd_rules, match_argv};
29use crate::diff::{PolicyOp, PolicyPath, Proposal, propose, resolve_dotted_path};
30use crate::render::to_json;
31use crate::scan::{ScanResult, TransportClass};
32use crate::{EXIT_OK, EXIT_USAGE, Rendered, agents_cli, evidence, scan};
33
34#[derive(Debug, Clone, Copy, Serialize)]
37struct Adapter {
38 best_effort: bool,
39 lang: &'static str,
40 lib: &'static str,
41 target: &'static str,
42}
43
44const REGISTRY: &[Adapter] = &[
48 Adapter {
49 lib: "httpx",
50 lang: "python",
51 target: "host",
52 best_effort: false,
53 },
54 Adapter {
55 lib: "requests",
56 lang: "python",
57 target: "host",
58 best_effort: false,
59 },
60 Adapter {
61 lib: "aiohttp",
62 lang: "python",
63 target: "host",
64 best_effort: true,
65 },
66 Adapter {
67 lib: "urllib3",
68 lang: "python",
69 target: "host",
70 best_effort: true,
71 },
72 Adapter {
79 lib: "urllib.request",
80 lang: "python",
81 target: "host",
82 best_effort: false,
83 },
84 Adapter {
85 lib: "boto3",
86 lang: "python",
87 target: "tool:aws.*",
88 best_effort: true,
89 },
90 Adapter {
91 lib: "psycopg",
92 lang: "python",
93 target: "host",
94 best_effort: true,
95 },
96 Adapter {
97 lib: "openai",
98 lang: "python+node",
99 target: "llm:openai",
100 best_effort: false,
101 },
102 Adapter {
103 lib: "anthropic",
104 lang: "python+node",
105 target: "llm:anthropic",
106 best_effort: false,
107 },
108 Adapter {
123 lib: "google-adk",
124 lang: "python",
125 target: "tool:<name>",
126 best_effort: false,
127 },
128 Adapter {
129 lib: "google-genai",
130 lang: "python",
131 target: "llm:google-genai",
132 best_effort: false,
133 },
134 Adapter {
135 lib: "pydantic-ai",
136 lang: "python",
137 target: "tool:<name>",
138 best_effort: false,
139 },
140 Adapter {
141 lib: "openai-agents",
142 lang: "python",
143 target: "tool:<name>",
144 best_effort: false,
145 },
146 Adapter {
147 lib: "crewai",
148 lang: "python",
149 target: "tool:<name>",
150 best_effort: false,
151 },
152 Adapter {
153 lib: "langgraph",
154 lang: "python",
155 target: "tool:<name>",
156 best_effort: false,
157 },
158 Adapter {
165 lib: "mcp",
166 lang: "python+node",
167 target: "mcp:<server>",
168 best_effort: false,
169 },
170 Adapter {
171 lib: "fetch",
172 lang: "node",
173 target: "host",
174 best_effort: false,
175 },
176 Adapter {
177 lib: "undici",
178 lang: "node",
179 target: "host",
180 best_effort: true,
181 },
182 Adapter {
183 lib: "http",
184 lang: "node",
185 target: "host",
186 best_effort: true,
187 },
188 Adapter {
189 lib: "ai-sdk",
190 lang: "node",
191 target: "llm:*",
192 best_effort: false,
193 },
194 Adapter {
195 lib: "ioredis",
196 lang: "node",
197 target: "host",
198 best_effort: true,
199 },
200 Adapter {
201 lib: "mysql2",
202 lang: "node",
203 target: "host",
204 best_effort: true,
205 },
206 Adapter {
207 lib: "pg",
208 lang: "node",
209 target: "host",
210 best_effort: true,
211 },
212];
213
214pub(crate) fn registry_libs() -> BTreeSet<&'static str> {
218 REGISTRY.iter().map(|a| a.lib).collect()
219}
220
221#[derive(Debug, Serialize)]
224struct AdapterStatus {
225 detected: bool,
226 lib: &'static str,
227 status: &'static str,
228 target: &'static str,
229}
230
231#[derive(Debug, Serialize)]
233struct Coverage {
234 invisible: Vec<String>,
235 visible_unwrapped: Vec<String>,
236 wrapped: Vec<String>,
237}
238
239#[derive(Debug, Serialize)]
241struct PolicyCheck {
242 field: Option<String>,
243 message: Option<String>,
244 present: bool,
245 valid: bool,
246}
247
248#[derive(Debug, Serialize)]
252struct Finding {
253 action: String,
254 detail: String,
255 #[serde(skip_serializing_if = "Option::is_none")]
256 fix: Option<Proposal>,
257 level: &'static str,
258 topic: &'static str,
259}
260
261#[derive(Debug, Serialize)]
269struct FollowUp {
270 code: &'static str,
271 detail: String,
272 rank: u32,
273 subject: String,
274}
275
276#[derive(Debug, Serialize)]
279struct JournalReport {
280 backend: &'static str,
282 location: String,
285 source: &'static str,
287 supported: bool,
290}
291
292impl JournalReport {
293 fn from_resolved(resolved: &evidence::ResolvedJournal) -> Self {
294 Self {
295 backend: resolved.backend.as_str(),
296 location: resolved.display.clone(),
297 source: if resolved.from_policy {
298 "keel.toml"
299 } else {
300 "default"
301 },
302 supported: resolved.backend == evidence::JournalBackendKind::Sqlite,
303 }
304 }
305}
306
307#[derive(Debug, Serialize)]
311pub(crate) struct TopologyEntry {
312 pub(crate) host: String,
313 pub(crate) reason: String,
314}
315
316#[derive(Debug, Serialize)]
321pub(crate) struct ExternalProcess {
322 pub(crate) command: String,
323 #[serde(skip_serializing_if = "Option::is_none")]
332 pub(crate) covered_by: Option<String>,
333 pub(crate) file: String,
334 pub(crate) launcher: String,
335 pub(crate) line: u32,
336}
337
338#[derive(Debug, Serialize)]
351pub(crate) struct Topology {
352 pub(crate) excluded: Vec<TopologyEntry>,
353 pub(crate) external_processes: Vec<ExternalProcess>,
354 pub(crate) unreachable: Vec<TopologyEntry>,
355 pub(crate) wrappable: Vec<String>,
356}
357
358#[derive(Debug, Serialize)]
375struct Boundaries {
376 governance_files: Vec<&'static str>,
381 parsed_languages: &'static [&'static str],
383 protocol: &'static str,
386 unparsed: &'static [&'static str],
390}
391
392#[derive(Debug, Serialize)]
394struct DoctorReport {
395 adapters: Vec<AdapterStatus>,
396 boundaries: Boundaries,
397 coverage: Coverage,
398 findings: Vec<Finding>,
399 follow_ups: Vec<FollowUp>,
400 journal: JournalReport,
401 ok: bool,
402 policy: PolicyCheck,
403 topology: Topology,
404}
405
406#[derive(Debug)]
411struct PolicyValidation {
412 check: PolicyCheck,
413 cmd_match: BTreeMap<String, FlowMatchRule>,
418 fix: Option<Proposal>,
419}
420
421#[must_use]
430pub fn preflight_advisory(project: &Path) -> Option<String> {
431 let scan = scan::scan(project);
432 let registry_libs = registry_libs();
433 let finding = resilience_finding(&scan, ®istry_libs)?;
434 Some(format!(
435 "keel \u{25b8} {}\n next: {} (run `keel doctor --json` for detail; skip this check with \
436 --no-preflight or KEEL_SKIP_PREFLIGHT=1)",
437 finding.detail, finding.action
438 ))
439}
440
441pub fn run(project: &Path) -> Rendered {
443 let scan = scan::scan(project);
444 let discovery = match evidence::read_discovery(project) {
445 Ok(d) => d.into_iter().map(|s| s.target).collect(),
446 Err(e) => {
447 return Rendered {
448 human: format!("keel \u{25b8} doctor unavailable: {e}"),
449 json: to_json(&serde_json::json!({ "error": e })),
450 exit: crate::EXIT_FAILURE,
451 to_stderr: true,
452 };
453 }
454 };
455 let policy = validate_policy(&evidence::keel_toml(project));
456 let journal = JournalReport::from_resolved(&evidence::resolved_journal(project));
457 let agents_cli_finding = agents_cli_placement_finding(project);
458 let boundaries = boundaries(project);
459 let stale_flows = crate::flows::stale_code_hash_flows(project);
460 let report = build_report(
461 &scan,
462 &discovery,
463 policy,
464 journal,
465 agents_cli_finding,
466 boundaries,
467 &stale_flows,
468 );
469 let exit = if report.ok { EXIT_OK } else { EXIT_USAGE };
470 let human = human(&report);
471 Rendered::ok(human, to_json(&report)).with_exit(exit)
472}
473
474fn resilience_finding(scan: &ScanResult, registry_libs: &BTreeSet<&str>) -> Option<Finding> {
488 let compounds_with = scan
489 .libs
490 .iter()
491 .any(|lib| registry_libs.contains(lib.as_str()));
492 if scan.resilience_libs.is_empty() || !compounds_with {
493 return None;
494 }
495 let libs: Vec<&str> = scan.resilience_libs.iter().map(String::as_str).collect();
496 Some(Finding {
497 action: "Delete the old retry/backoff code if Keel's policy now covers it, or scope \
498 Keel's policy to skip this target (e.g. `attempts = 1`) if you want to keep \
499 relying on it — don't leave both running unconfigured against each other."
500 .to_owned(),
501 detail: format!(
502 "This project imports {} alongside at least one library Keel wraps — Keel cannot \
503 see whether {} is actually configured to retry the same calls, so retries may be \
504 silently compounding.",
505 libs.join(", "),
506 if libs.len() == 1 { "it" } else { "they" }
507 ),
508 fix: None,
509 level: "warn",
510 topic: "preexisting-resilience",
511 })
512}
513
514fn topology_findings(topology: &Topology) -> Vec<Finding> {
520 let mut findings = Vec::new();
521 for entry in &topology.unreachable {
522 findings.push(Finding {
523 action: "Trace how this request is actually dispatched before proposing policy; \
524 Python's stdlib `urllib.request` is adapted — importing it directly in \
525 that file makes the host wrappable."
526 .to_owned(),
527 detail: format!("`{}` — {}.", entry.host, entry.reason),
528 fix: None,
529 level: "warn",
530 topic: "url-no-transport",
531 });
532 }
533 let (covered, uncovered): (Vec<_>, Vec<_>) = topology
534 .external_processes
535 .iter()
536 .partition(|p| p.covered_by.is_some());
537 if !uncovered.is_empty() {
538 let cmds: Vec<String> = uncovered
539 .iter()
540 .map(|p| format!("`{}` ({} at {}:{})", p.command, p.launcher, p.file, p.line))
541 .collect();
542 findings.push(Finding {
543 action: "Confirm none of these processes carry traffic you care about; Keel must be \
544 installed inside a process to see it."
545 .to_owned(),
546 detail: format!(
547 "Keel cannot see traffic inside {} externally-launched process(es): {}.",
548 uncovered.len(),
549 cmds.join(", ")
550 ),
551 fix: None,
552 level: "warn",
553 topic: "subprocess-blind-spot",
554 });
555 }
556 if !covered.is_empty() {
557 let cmds: Vec<String> = covered
563 .iter()
564 .map(|p| {
565 format!(
566 "`{}` ({} at {}:{}, matches `{}`)",
567 p.command,
568 p.launcher,
569 p.file,
570 p.line,
571 p.covered_by.as_deref().unwrap_or_default()
572 )
573 })
574 .collect();
575 findings.push(Finding {
576 action: "No action needed unless the matching `[flows.match]` rule is wrong, or Keel \
577 is not actually active in the process that runs this command."
578 .to_owned(),
579 detail: format!(
580 "{} externally-launched process(es) match a declared `[flows.match.\"cmd:*\"]` \
581 rule and are wrapped when Keel is active in that process: {}.",
582 covered.len(),
583 cmds.join(", ")
584 ),
585 fix: None,
586 level: "info",
587 topic: "subprocess-blind-spot",
588 });
589 }
590 for entry in &topology.excluded {
591 findings.push(Finding {
592 action: "Confirm the exclusion is intended; add `# keel: include` to the file to \
593 override."
594 .to_owned(),
595 detail: format!("`{}` — {}.", entry.host, entry.reason),
596 fix: None,
597 level: "info",
598 topic: "dependency-averse-excluded",
599 });
600 }
601 findings
602}
603
604fn simplification_findings(scan: &ScanResult, topology: &Topology) -> Vec<Finding> {
614 let wrappable: BTreeSet<&str> = topology.wrappable.iter().map(String::as_str).collect();
615 let mut findings = Vec::new();
616 for s in &scan.simplifications {
617 let targets = s.targets.join(", ");
618 let actionable_now = s.targets.iter().any(|t| wrappable.contains(t.as_str()));
619 let (level, when) = if actionable_now {
620 ("warn", "Keel can wrap this target now")
621 } else {
622 ("info", "once Keel can wrap this target")
623 };
624 let (topic, what, action): (&'static str, String, String) = match s.kind.as_str() {
625 "hand-rolled-poll" => (
626 "hand-rolled-poll",
627 format!(
628 "`{}` ({}:{}) hand-rolls poll-until-terminal against `{}` — {}, a `poll` \
629 policy (interval / deadline / until) replaces the whole loop",
630 s.function, s.file, s.line, targets, when
631 ),
632 "Wrap the target, then replace the loop with a `poll` policy — the poll \
633 primitive (CCR-3) is the designated replacement for submit-then-poll loops."
634 .to_owned(),
635 ),
636 "silent-swallow" => (
637 "silent-swallow",
638 format!(
639 "`{}` ({}:{}) silences failures from `{}` with a broad `except` returning a \
640 default — Keel replaces silence with retry + observability",
641 s.function, s.file, s.line, targets
642 ),
643 "Wrap the target so Keel's policy owns the failure (retry, breaker, journal), \
644 then narrow or remove the broad except."
645 .to_owned(),
646 ),
647 "hand-rolled-retry" => (
648 "hand-rolled-retry",
649 format!(
650 "`{}` ({}:{}) hand-rolls retry around `{}` — {}, this loop becomes redundant",
651 s.function, s.file, s.line, targets, when
652 ),
653 "Wrap the target with Keel retry/backoff policy, then delete the hand-rolled \
654 loop — don't run both."
655 .to_owned(),
656 ),
657 other => {
658 debug_assert!(false, "unknown simplification kind: {other}");
664 continue;
665 }
666 };
667 findings.push(Finding {
668 action,
669 detail: format!("{what}."),
670 fix: None,
671 level,
672 topic,
673 });
674 }
675 findings
676}
677
678fn follow_up_rank(code: &str) -> u32 {
688 match code {
689 "url-no-transport" => 1,
690 "orchestration-blind-spot" => 2,
691 "subprocess-blind-spot" => 3,
692 "dependency-averse-excluded" => 4,
693 "preexisting-resilience" => 5,
694 _ => 6, }
696}
697
698fn build_follow_ups(
702 topology: &Topology,
703 resilience: Option<&Finding>,
704 scan: &ScanResult,
705 stale_flows: &[crate::flows::StaleFlow],
706) -> Vec<FollowUp> {
707 let mut ups = Vec::new();
708 for entry in &topology.unreachable {
709 ups.push(FollowUp {
710 code: "url-no-transport",
711 detail: format!(
712 "Trace how requests to `{}` are actually dispatched — {}.",
713 entry.host, entry.reason
714 ),
715 rank: follow_up_rank("url-no-transport"),
716 subject: entry.host.clone(),
717 });
718 }
719 if !scan.orchestration.is_empty() {
720 let mut files: Vec<&str> = scan.orchestration.iter().map(|o| o.file.as_str()).collect();
721 files.dedup();
722 ups.push(FollowUp {
723 code: "orchestration-blind-spot",
724 detail: "Keel cannot parse shell/Makefile/CI files; these carry a coarse \
725 at-most-once-dispatch signature (lockfile/guard/PID check). Confirm \
726 whether each is real dispatch gating a `cmd:` flow could replace."
727 .to_owned(),
728 rank: follow_up_rank("orchestration-blind-spot"),
729 subject: format!("{} orchestration file(s)", files.len()),
730 });
731 }
732 let uncovered: Vec<&ExternalProcess> = topology
737 .external_processes
738 .iter()
739 .filter(|p| p.covered_by.is_none())
740 .collect();
741 if !uncovered.is_empty() {
742 let cmds: Vec<String> = uncovered
743 .iter()
744 .map(|p| format!("`{}` ({}:{})", p.command, p.file, p.line))
745 .collect();
746 ups.push(FollowUp {
747 code: "subprocess-blind-spot",
748 detail: format!(
749 "Keel cannot see traffic inside externally-launched processes; confirm none of \
750 these carry traffic you care about: {}.",
751 cmds.join(", ")
752 ),
753 rank: follow_up_rank("subprocess-blind-spot"),
754 subject: format!("{} externally-launched process(es)", uncovered.len()),
755 });
756 }
757 for entry in &topology.excluded {
758 ups.push(FollowUp {
759 code: "dependency-averse-excluded",
760 detail: format!(
761 "`{}` was excluded from proposed policy — {}. Confirm the exclusion is intended.",
762 entry.host, entry.reason
763 ),
764 rank: follow_up_rank("dependency-averse-excluded"),
765 subject: entry.host.clone(),
766 });
767 }
768 if resilience.is_some() {
769 let libs: Vec<&str> = scan.resilience_libs.iter().map(String::as_str).collect();
770 ups.push(FollowUp {
771 code: "preexisting-resilience",
772 detail: format!(
773 "Decide whether {} still needs its own retry/backoff now that Keel wraps the \
774 same calls — delete the old code or scope Keel's policy, not both.",
775 libs.join(", ")
776 ),
777 rank: follow_up_rank("preexisting-resilience"),
778 subject: libs.join(", "),
779 });
780 }
781 for flow in stale_flows {
782 ups.push(FollowUp {
783 code: "code-hash-stale",
784 detail: format!(
785 "Flow `{}` ({}) was recorded under a different code hash than its current \
786 script; resuming would replay recorded steps against changed code (the resume \
787 fence downgrades nondeterminism fail->warn). Inspect with `keel replay {}` \
788 before resuming.",
789 flow.flow_id, flow.entrypoint, flow.flow_id
790 ),
791 rank: follow_up_rank("code-hash-stale"),
792 subject: flow.flow_id.clone(),
793 });
794 }
795 ups.sort_by(|a, b| {
796 (a.rank, a.code, a.subject.as_str()).cmp(&(b.rank, b.code, b.subject.as_str()))
797 });
798 ups
799}
800
801fn agents_cli_placement_finding(project: &Path) -> Option<Finding> {
810 let layout = agents_cli::find_agents_cli_layout(project)?;
811 if layout.agent_dir == project || !evidence::keel_toml(project).exists() {
812 return None;
813 }
814 let agent_dir = relative_display(project, &layout.agent_dir);
820 let moved_to = relative_display(project, &layout.agent_dir.join("keel.toml"));
821 Some(Finding {
822 action: format!(
823 "Move keel.toml to {moved_to} (or add a `COPY keel.toml` line to the Dockerfile)."
824 ),
825 detail: format!(
826 "This is an agents-cli project — its generated Dockerfile only COPYs \
827 pyproject.toml, README.md, uv.lock*, and {agent_dir} into the image, so the \
828 keel.toml at the project root never ships to the container."
829 ),
830 fix: None,
831 level: "warn",
832 topic: "agents-cli-config-placement",
833 })
834}
835
836fn relative_display(base: &Path, target: &Path) -> String {
841 target.strip_prefix(base).map_or_else(
842 |_| target.display().to_string(),
843 |rel| rel.display().to_string(),
844 )
845}
846
847fn boundaries(project: &Path) -> Boundaries {
853 let mut governance_files = Vec::new();
854 if project.join("CLAUDE.md").exists() {
855 governance_files.push("CLAUDE.md");
856 }
857 if project.join("AGENTS.md").exists() {
858 governance_files.push("AGENTS.md");
859 }
860 Boundaries {
861 governance_files,
862 parsed_languages: &["python", "js-ts"],
863 protocol: "Static + adapter-interception evidence, not a verdict. Drive an \
864 evaluate/adopt/review task through the keel skill's five phases: Scope every \
865 I/O process (including shell/CI launchers) -> Explore how each call is \
866 dispatched -> Collect this report -> Baseline real failure classes in observe \
867 mode (`keel record run`) -> Analyze & propose. Retry only helps \
868 genuinely-transient classes (conn/timeout/5xx/429).",
869 unparsed: &["shell", "makefile", "ci-workflow", "governance-prose"],
870 }
871}
872
873fn journal_finding(journal: &JournalReport) -> Option<Finding> {
876 (!journal.supported).then(|| Finding {
877 action: "Use a `file:` location (or drop the key for the default .keel/journal.db); Postgres support is future work — see docs.".to_owned(),
878 detail: format!(
879 "keel.toml sets `journal` to a {} location, but this build has no {} backend — the app will fail to configure with KEEL-E005.",
880 journal.backend, journal.backend
881 ),
882 fix: None,
883 level: "error",
884 topic: "journal",
885 })
886}
887
888#[allow(clippy::too_many_lines)] fn build_report(
899 scan: &ScanResult,
900 wrapped_targets: &BTreeSet<String>,
901 policy: PolicyValidation,
902 journal: JournalReport,
903 agents_cli_finding: Option<Finding>,
904 boundaries: Boundaries,
905 stale_flows: &[crate::flows::StaleFlow],
906) -> DoctorReport {
907 let PolicyValidation {
908 check: policy,
909 cmd_match,
910 fix,
911 } = policy;
912 let registry_libs = registry_libs();
913
914 let visible: BTreeSet<&String> = scan.targets.keys().collect();
916 let wrapped: Vec<String> = wrapped_targets.iter().cloned().collect();
917 let visible_unwrapped: Vec<String> = visible
918 .iter()
919 .filter(|t| !wrapped_targets.contains(**t))
920 .map(|t| (*t).clone())
921 .collect();
922 let invisible: Vec<String> = scan
923 .libs
924 .iter()
925 .filter(|lib| !registry_libs.contains(lib.as_str()))
926 .cloned()
927 .collect();
928
929 let topology = classify_topology(scan, wrapped_targets, &cmd_match);
933
934 let adapters: Vec<AdapterStatus> = REGISTRY
936 .iter()
937 .map(|a| AdapterStatus {
938 detected: scan.libs.contains(a.lib),
939 lib: a.lib,
940 status: if a.best_effort {
941 "best-effort"
942 } else {
943 "pinned"
944 },
945 target: a.target,
946 })
947 .collect();
948
949 let mut findings = Vec::new();
951 for target in &visible_unwrapped {
952 findings.push(Finding {
953 action:
954 "Run `keel run <script>` so Keel can confirm this target is wrapped at runtime."
955 .to_owned(),
956 detail: format!(
957 "`{target}` is visible in your code but has no observed runtime evidence."
958 ),
959 fix: None,
960 level: "warn",
961 topic: "visible-unwrapped",
962 });
963 }
964 for lib in &invisible {
965 findings.push(Finding {
966 action: format!("No adapter for `{lib}` yet — its calls are invisible to Keel. Track adapter support or wrap manually."),
967 detail: format!("`{lib}` is imported but has no adapter in the registry."),
968 fix: None,
969 level: "warn",
970 topic: "invisible",
971 });
972 }
973 findings.push(Finding {
975 action: "If a dependency makes calls Keel never reports, file an adapter request.".to_owned(),
976 detail: "Raw sockets and unknown native libraries are invisible to static and adapter-based interception.".to_owned(),
977 fix: None,
978 level: "info",
979 topic: "invisible",
980 });
981 if !scan.orchestration.is_empty() {
985 const MAX_LISTED: usize = 5;
986 let mut files: Vec<&str> = scan.orchestration.iter().map(|o| o.file.as_str()).collect();
987 files.dedup();
990 let shown = files.len().min(MAX_LISTED);
991 let mut list = files[..shown]
992 .iter()
993 .map(|f| format!("`{f}`"))
994 .collect::<Vec<_>>()
995 .join(", ");
996 if files.len() > shown {
997 let rest = files.len() - shown;
998 let _ = write!(list, " and {rest} more");
999 }
1000 findings.push(Finding {
1001 action: "Inspect these files for hand-rolled at-most-once dispatch (lockfile/guard/\
1002 PID checks). A durable `cmd:` flow replaces it crash-safely: `keel exec \
1003 --flow` for a standalone launcher, or `[flows.match.\"cmd:<name>\"]` when \
1004 the call is made from inside an already-Keel-active process."
1005 .to_owned(),
1006 detail: format!(
1007 "Static scan cannot parse these orchestration files, but sighted the \
1008 at-most-once-dispatch signature in: {list}."
1009 ),
1010 fix: None,
1011 level: "warn",
1012 topic: "orchestration-blind-spot",
1013 });
1014 }
1015 findings.extend(topology_findings(&topology));
1016 findings.extend(simplification_findings(scan, &topology));
1017 if !policy.valid && policy.present {
1018 let field = policy.field.clone().unwrap_or_default();
1019 let mut action = "Fix the field above, then re-run `keel doctor`; validate against contracts/policy.schema.json.".to_owned();
1020 if fix.is_some() {
1021 action.push_str(
1022 " Or apply the attached patch (`git apply`) to remove the invalid entry — defaults cover it.",
1023 );
1024 }
1025 findings.push(Finding {
1026 action,
1027 detail: format!(
1028 "keel.toml failed validation at `{field}`: {}",
1029 policy.message.clone().unwrap_or_default()
1030 ),
1031 fix,
1032 level: "error",
1033 topic: "policy",
1034 });
1035 }
1036 let resilience = resilience_finding(scan, ®istry_libs);
1037 let follow_ups = build_follow_ups(&topology, resilience.as_ref(), scan, stale_flows);
1038 findings.extend(resilience);
1039 findings.extend(journal_finding(&journal));
1040 findings.extend(agents_cli_finding);
1041
1042 let ok = (policy.valid || !policy.present) && journal.supported;
1043 DoctorReport {
1044 adapters,
1045 boundaries,
1046 coverage: Coverage {
1047 invisible,
1048 visible_unwrapped,
1049 wrapped,
1050 },
1051 findings,
1052 follow_ups,
1053 journal,
1054 ok,
1055 policy,
1056 topology,
1057 }
1058}
1059
1060pub(crate) fn classify_topology(
1074 scan: &ScanResult,
1075 wrapped_targets: &BTreeSet<String>,
1076 cmd_match: &BTreeMap<String, FlowMatchRule>,
1077) -> Topology {
1078 let dep_files: BTreeSet<&str> = scan
1079 .dependency_averse
1080 .iter()
1081 .map(|d| d.file.as_str())
1082 .collect();
1083 let mut wrappable = Vec::new();
1084 let mut unreachable = Vec::new();
1085 let mut excluded = Vec::new();
1086 for (target, ev) in &scan.targets {
1087 if wrapped_targets.contains(target) || target.starts_with("llm:") {
1088 wrappable.push(target.clone());
1089 continue;
1090 }
1091 let only_dep_averse = !ev.sightings.is_empty()
1092 && ev
1093 .sightings
1094 .iter()
1095 .all(|s| dep_files.contains(s.file.as_str()));
1096 if only_dep_averse {
1097 let files: BTreeSet<&str> = ev.sightings.iter().map(|s| s.file.as_str()).collect();
1098 excluded.push(TopologyEntry {
1099 host: target.clone(),
1100 reason: format!(
1101 "seen only in dependency-averse file(s) {} — excluded from proposed policy; \
1102 add `# keel: include` to override",
1103 files.into_iter().collect::<Vec<_>>().join(", ")
1104 ),
1105 });
1106 continue;
1107 }
1108 match scan
1109 .host_transports
1110 .get(target)
1111 .copied()
1112 .unwrap_or(TransportClass::Unknown)
1113 {
1114 TransportClass::Tracked => wrappable.push(target.clone()),
1115 TransportClass::UntrackedKnown => unreachable.push(TopologyEntry {
1116 host: target.clone(),
1117 reason: "reached via a stdlib transport Keel does not adapt (http.client, or \
1118 urllib without urllib.request; Python's urllib.request itself is \
1119 adapted)"
1120 .to_owned(),
1121 }),
1122 TransportClass::Unknown => unreachable.push(TopologyEntry {
1123 host: target.clone(),
1124 reason: "URL literal with no tracked transport in reach — trace how this request \
1125 is dispatched"
1126 .to_owned(),
1127 }),
1128 }
1129 }
1130 let cmd_rules = compile_cmd_rules(cmd_match);
1131 let external_processes: Vec<ExternalProcess> = scan
1132 .subprocesses
1133 .iter()
1134 .map(|s| ExternalProcess {
1135 command: s.command.clone(),
1136 covered_by: cmd_flow_covering(&cmd_rules, s),
1137 file: s.file.clone(),
1138 launcher: s.launcher.clone(),
1139 line: s.line,
1140 })
1141 .collect();
1142 Topology {
1143 excluded,
1144 external_processes,
1145 unreachable,
1146 wrappable,
1147 }
1148}
1149
1150const INTERCEPTED_CMD_LAUNCHERS: &[&str] = &[
1163 "subprocess.run",
1164 "subprocess.check_output",
1165 "subprocess.call",
1166 "subprocess.check_call",
1167];
1168
1169fn cmd_flow_covering(
1175 rules: &[crate::cmd_match::CompiledCmdRule],
1176 sighting: &scan::SubprocessSighting,
1177) -> Option<String> {
1178 if !INTERCEPTED_CMD_LAUNCHERS.contains(&sighting.launcher.as_str()) {
1179 return None;
1180 }
1181 let argv = sighting.argv.as_ref()?;
1182 match_argv(rules, argv).map(str::to_owned)
1183}
1184
1185fn validate_policy(path: &Path) -> PolicyValidation {
1189 if !path.exists() {
1190 return PolicyValidation {
1191 check: PolicyCheck {
1192 field: None,
1193 message: None,
1194 present: false,
1195 valid: true,
1196 },
1197 cmd_match: BTreeMap::new(),
1198 fix: None,
1199 };
1200 }
1201 let Ok(text) = std::fs::read_to_string(path) else {
1202 return invalid(None, "keel.toml exists but could not be read", None);
1203 };
1204 let toml_value: toml::Value = match text.parse() {
1205 Ok(v) => v,
1206 Err(e) => return invalid(None, &format!("keel.toml is not valid TOML: {e}"), None),
1207 };
1208 let json_value = match serde_json::to_value(&toml_value) {
1209 Ok(v) => v,
1210 Err(e) => {
1211 return invalid(
1212 None,
1213 &format!("keel.toml could not be normalized: {e}"),
1214 None,
1215 );
1216 }
1217 };
1218 match serde_path_to_error::deserialize::<_, Policy>(&json_value) {
1219 Ok(policy) => PolicyValidation {
1220 check: PolicyCheck {
1221 field: None,
1222 message: None,
1223 present: true,
1224 valid: true,
1225 },
1226 cmd_match: policy.flows.and_then(|f| f.match_).unwrap_or_default(),
1227 fix: None,
1228 },
1229 Err(e) => {
1230 let field = e.path().to_string();
1231 let fix = suggest_removal(&text, &field);
1232 invalid(Some(field), &e.inner().to_string(), fix)
1233 }
1234 }
1235}
1236
1237fn invalid(field: Option<String>, message: &str, fix: Option<Proposal>) -> PolicyValidation {
1238 PolicyValidation {
1239 check: PolicyCheck {
1240 field,
1241 message: Some(message.to_owned()),
1242 present: true,
1243 valid: false,
1244 },
1245 cmd_match: BTreeMap::new(),
1246 fix,
1247 }
1248}
1249
1250const MAX_FIX_DEPTH: usize = 3;
1254
1255fn suggest_removal(text: &str, field: &str) -> Option<Proposal> {
1259 let resolved = resolve_dotted_path(text, field)?;
1260 let segments = resolved.segments();
1261 let cut = segments.len().min(MAX_FIX_DEPTH);
1262 let path = PolicyPath::new(segments[..cut].iter().cloned());
1263 let proposal = propose(Some(text), &[PolicyOp::Remove { path }]).ok()?;
1264 if proposal.patch.is_empty() {
1265 None
1266 } else {
1267 Some(proposal)
1268 }
1269}
1270
1271#[allow(clippy::too_many_lines)] fn human(r: &DoctorReport) -> String {
1275 let mut out = String::from("keel \u{25b8} doctor\n");
1276
1277 out.push_str("\ncoverage\n");
1278 line_list(&mut out, " wrapped: ", &r.coverage.wrapped);
1279 line_list(
1280 &mut out,
1281 " visible-unwrapped:",
1282 &r.coverage.visible_unwrapped,
1283 );
1284 line_list(&mut out, " invisible: ", &r.coverage.invisible);
1285
1286 out.push_str("\ntopology\n");
1287 line_list(&mut out, " wrap it: ", &r.topology.wrappable);
1288 for e in &r.topology.unreachable {
1289 let line = format!(" can't reach: {} — {}\n", e.host, e.reason);
1290 out.push_str(&line);
1291 }
1292 for e in &r.topology.excluded {
1293 let line = format!(" shouldn't reach: {} — {}\n", e.host, e.reason);
1294 out.push_str(&line);
1295 }
1296 for p in &r.topology.external_processes {
1297 let line = format!(
1298 " external process: {} ({} at {}:{})\n",
1299 p.command, p.launcher, p.file, p.line
1300 );
1301 out.push_str(&line);
1302 }
1303
1304 out.push_str("\nadapters\n");
1305 for a in &r.adapters {
1306 let mark = if a.detected { "\u{2713}" } else { " " };
1307 let line = format!(
1308 " [{mark}] {lib:<10} {status:<12} -> {target}\n",
1309 lib = a.lib,
1310 status = a.status,
1311 target = a.target,
1312 );
1313 out.push_str(&line);
1314 }
1315
1316 out.push_str("\npolicy\n");
1317 if !r.policy.present {
1318 out.push_str(" no keel.toml — smart defaults apply. `keel init` to customize.\n");
1319 } else if r.policy.valid {
1320 out.push_str(" keel.toml is valid.\n");
1321 } else {
1322 let line = format!(
1323 " keel.toml INVALID at `{}`: {}\n",
1324 r.policy.field.clone().unwrap_or_default(),
1325 r.policy.message.clone().unwrap_or_default(),
1326 );
1327 out.push_str(&line);
1328 }
1329
1330 out.push_str("\njournal\n");
1331 let journal_line = if r.journal.supported {
1332 format!(
1333 " {} at {} ({})\n",
1334 r.journal.backend, r.journal.location, r.journal.source
1335 )
1336 } else {
1337 format!(
1338 " {} at {} ({}) — NOT supported in this build (KEEL-E005)\n",
1339 r.journal.backend, r.journal.location, r.journal.source
1340 )
1341 };
1342 out.push_str(&journal_line);
1343
1344 if !r.findings.is_empty() {
1345 out.push_str("\nfindings\n");
1346 for f in &r.findings {
1347 let line = format!(
1348 " [{}] {}\n \u{2192} {}\n",
1349 f.level, f.detail, f.action
1350 );
1351 out.push_str(&line);
1352 if let Some(fix) = &f.fix {
1353 out.push_str(" patch (apply with `git apply`):\n");
1355 out.push_str(&fix.patch);
1356 }
1357 }
1358 }
1359 if !r.follow_ups.is_empty() {
1360 out.push_str("\nfollow-ups (work top-down; 1 = Keel knows least)\n");
1361 for f in &r.follow_ups {
1362 let line = format!(" {}. [{}] {} — {}\n", f.rank, f.code, f.subject, f.detail);
1363 out.push_str(&line);
1364 }
1365 }
1366 out.push_str("\nboundaries\n");
1367 let parsed = format!(
1368 " parsed: {} — {} not parsed (shell/Makefile/CI sighted coarsely only)\n",
1369 r.boundaries.parsed_languages.join(", "),
1370 r.boundaries.unparsed.join(", "),
1371 );
1372 out.push_str(&parsed);
1373 if !r.boundaries.governance_files.is_empty() {
1374 let gov = format!(
1375 " governance: {} — read before applying policy; this report can't parse it\n",
1376 r.boundaries.governance_files.join(", "),
1377 );
1378 out.push_str(&gov);
1379 }
1380 out.push_str(
1381 " next: evidence, not a verdict — see the keel skill's evaluation protocol\n",
1382 );
1383
1384 let tail = format!(
1385 "\n{}\n",
1386 if r.ok {
1387 "ok"
1388 } else {
1389 "configuration error (exit 2)"
1390 }
1391 );
1392 out.push_str(&tail);
1393 out
1394}
1395
1396fn line_list(out: &mut String, label: &str, items: &[String]) {
1397 let line = if items.is_empty() {
1398 format!("{label} (none)\n")
1399 } else {
1400 format!("{label} {}\n", items.join(", "))
1401 };
1402 out.push_str(&line);
1403}
1404
1405#[cfg(test)]
1406mod tests {
1407 use super::*;
1408 use crate::scan::{Sighting, TargetClass, TargetEvidence};
1409
1410 fn default_journal() -> JournalReport {
1412 JournalReport {
1413 backend: "sqlite",
1414 location: ".keel/journal.db".to_owned(),
1415 source: "default",
1416 supported: true,
1417 }
1418 }
1419
1420 fn scan_with(target: &str, class: TargetClass, libs: &[&str]) -> ScanResult {
1421 let mut s = ScanResult {
1422 files_scanned: 1,
1423 python_available: true,
1424 ..ScanResult::default()
1425 };
1426 s.targets.insert(
1427 target.to_owned(),
1428 TargetEvidence {
1429 class,
1430 sightings: [Sighting {
1431 file: "app.py".into(),
1432 line: 1,
1433 }]
1434 .into_iter()
1435 .collect(),
1436 },
1437 );
1438 s.libs = libs.iter().map(|l| (*l).to_owned()).collect();
1439 s
1440 }
1441
1442 #[test]
1443 fn wrapped_visible_and_invisible_are_classified() {
1444 let scan = scan_with("llm:openai", TargetClass::Llm, &["openai", "django"]);
1447 let wrapped: BTreeSet<String> = ["api.observed.com".to_owned()].into_iter().collect();
1449 let policy = PolicyValidation {
1450 check: PolicyCheck {
1451 field: None,
1452 message: None,
1453 present: false,
1454 valid: true,
1455 },
1456 cmd_match: BTreeMap::new(),
1457 fix: None,
1458 };
1459 let r = build_report(
1460 &scan,
1461 &wrapped,
1462 policy,
1463 default_journal(),
1464 None,
1465 empty_boundaries(),
1466 &[],
1467 );
1468
1469 assert_eq!(r.coverage.wrapped, vec!["api.observed.com"]);
1470 assert_eq!(r.coverage.visible_unwrapped, vec!["llm:openai"]);
1471 assert_eq!(
1472 r.coverage.invisible,
1473 vec!["django"],
1474 "django has no adapter"
1475 );
1476 assert!(r.ok, "no policy present → ok");
1477 let openai = r.adapters.iter().find(|a| a.lib == "openai").unwrap();
1479 assert!(openai.detected);
1480 assert_eq!(openai.status, "pinned");
1481 }
1482
1483 #[test]
1484 fn topology_buckets_classify_hosts_honestly() {
1485 use crate::scan::{DepAverseFile, SubprocessSighting, TransportClass};
1486 let mut scan = ScanResult {
1487 files_scanned: 3,
1488 python_available: true,
1489 ..ScanResult::default()
1490 };
1491 scan.targets.insert(
1493 "api.ok.com".into(),
1494 TargetEvidence {
1495 class: TargetClass::Host,
1496 sightings: [Sighting {
1497 file: "app.py".into(),
1498 line: 3,
1499 }]
1500 .into_iter()
1501 .collect(),
1502 },
1503 );
1504 scan.host_transports
1505 .insert("api.ok.com".into(), TransportClass::Tracked);
1506 scan.targets.insert(
1508 "api.stdlib.com".into(),
1509 TargetEvidence {
1510 class: TargetClass::Host,
1511 sightings: [Sighting {
1512 file: "screen.py".into(),
1513 line: 9,
1514 }]
1515 .into_iter()
1516 .collect(),
1517 },
1518 );
1519 scan.host_transports
1520 .insert("api.stdlib.com".into(), TransportClass::UntrackedKnown);
1521 scan.targets.insert(
1524 "api.broker.com".into(),
1525 TargetEvidence {
1526 class: TargetClass::Host,
1527 sightings: [Sighting {
1528 file: "risk_gate.py".into(),
1529 line: 20,
1530 }]
1531 .into_iter()
1532 .collect(),
1533 },
1534 );
1535 scan.host_transports
1536 .insert("api.broker.com".into(), TransportClass::UntrackedKnown);
1537 scan.dependency_averse.push(DepAverseFile {
1538 file: "risk_gate.py".into(),
1539 reason: "stdlib-only + name/docstring signal: risk".into(),
1540 });
1541 scan.subprocesses.push(SubprocessSighting {
1542 file: "mcp.sh.py".into(),
1543 line: 12,
1544 launcher: "subprocess.run".into(),
1545 command: "uvx alpaca-mcp-server".into(),
1546 argv: Some(vec!["uvx".into(), "alpaca-mcp-server".into()]),
1547 });
1548 let r = build_report(
1549 &scan,
1550 &BTreeSet::new(),
1551 default_policy(),
1552 default_journal(),
1553 None,
1554 empty_boundaries(),
1555 &[],
1556 );
1557 assert_eq!(r.topology.wrappable, vec!["api.ok.com"]);
1558 assert_eq!(r.topology.unreachable.len(), 1);
1559 assert_eq!(r.topology.unreachable[0].host, "api.stdlib.com");
1560 assert_eq!(r.topology.excluded.len(), 1);
1561 assert_eq!(r.topology.excluded[0].host, "api.broker.com");
1562 assert!(r.topology.excluded[0].reason.contains("risk_gate.py"));
1563 assert_eq!(r.topology.external_processes.len(), 1);
1564 assert_eq!(
1565 r.topology.external_processes[0].command,
1566 "uvx alpaca-mcp-server"
1567 );
1568 assert!(
1570 r.findings
1571 .iter()
1572 .any(|f| f.topic == "url-no-transport" && f.level == "warn")
1573 );
1574 assert!(r.findings.iter().any(|f| f.topic == "subprocess-blind-spot"
1575 && f.level == "warn"
1576 && f.detail.contains("uvx alpaca-mcp-server")));
1577 assert!(
1578 r.findings
1579 .iter()
1580 .any(|f| f.topic == "dependency-averse-excluded" && f.level == "info")
1581 );
1582 assert!(r.ok);
1584 }
1585
1586 #[test]
1593 fn covered_subprocess_sighting_is_downgraded_not_dropped() {
1594 use crate::scan::SubprocessSighting;
1595 let mut scan = ScanResult {
1596 files_scanned: 1,
1597 python_available: true,
1598 ..ScanResult::default()
1599 };
1600 scan.subprocesses.push(SubprocessSighting {
1601 file: "etl.py".into(),
1602 line: 9,
1603 launcher: "subprocess.run".into(),
1604 command: "etl run".into(),
1605 argv: Some(vec!["etl".into(), "run".into()]),
1606 });
1607 scan.subprocesses.push(SubprocessSighting {
1608 file: "backup.py".into(),
1609 line: 20,
1610 launcher: "subprocess.run".into(),
1611 command: "backup now".into(),
1612 argv: Some(vec!["backup".into(), "now".into()]),
1613 });
1614 let mut policy = default_policy();
1615 policy.check.present = true;
1616 policy.cmd_match.insert(
1617 "cmd:etl".to_owned(),
1618 FlowMatchRule {
1619 argv: vec!["etl".into(), "run".into()],
1620 },
1621 );
1622 let r = build_report(
1623 &scan,
1624 &BTreeSet::new(),
1625 policy,
1626 default_journal(),
1627 None,
1628 empty_boundaries(),
1629 &[],
1630 );
1631
1632 assert_eq!(r.topology.external_processes.len(), 2);
1633 let etl = r
1634 .topology
1635 .external_processes
1636 .iter()
1637 .find(|p| p.command == "etl run")
1638 .expect("etl sighting present");
1639 assert_eq!(etl.covered_by.as_deref(), Some("cmd:etl"));
1640 let backup = r
1641 .topology
1642 .external_processes
1643 .iter()
1644 .find(|p| p.command == "backup now")
1645 .expect("backup sighting present");
1646 assert_eq!(backup.covered_by, None);
1647
1648 assert!(r.findings.iter().any(|f| f.topic == "subprocess-blind-spot"
1650 && f.level == "info"
1651 && f.detail.contains("etl run")
1652 && f.detail.contains("cmd:etl")));
1653 assert!(r.findings.iter().any(|f| f.topic == "subprocess-blind-spot"
1655 && f.level == "warn"
1656 && f.detail.contains("backup now")
1657 && !f.detail.contains("etl run")));
1658 let follow_up = r
1660 .follow_ups
1661 .iter()
1662 .find(|u| u.code == "subprocess-blind-spot")
1663 .expect("one uncovered sighting still yields a follow-up");
1664 assert!(follow_up.detail.contains("backup now"));
1665 assert!(!follow_up.detail.contains("etl run"));
1666 assert_eq!(follow_up.subject, "1 externally-launched process(es)");
1667 }
1668
1669 #[test]
1674 fn uncovered_launchers_never_match_even_with_matching_text() {
1675 use crate::scan::SubprocessSighting;
1676 let mut scan = ScanResult {
1677 files_scanned: 1,
1678 python_available: true,
1679 ..ScanResult::default()
1680 };
1681 scan.subprocesses.push(SubprocessSighting {
1682 file: "legacy.py".into(),
1683 line: 4,
1684 launcher: "os.system".into(),
1685 command: "etl run".into(),
1686 argv: None,
1687 });
1688 scan.subprocesses.push(SubprocessSighting {
1689 file: "legacy.py".into(),
1690 line: 8,
1691 launcher: "subprocess.Popen".into(),
1692 command: "etl run".into(),
1693 argv: Some(vec!["etl".into(), "run".into()]),
1694 });
1695 let mut policy = default_policy();
1696 policy.check.present = true;
1697 policy.cmd_match.insert(
1698 "cmd:etl".to_owned(),
1699 FlowMatchRule {
1700 argv: vec!["etl".into(), "run".into()],
1701 },
1702 );
1703 let r = build_report(
1704 &scan,
1705 &BTreeSet::new(),
1706 policy,
1707 default_journal(),
1708 None,
1709 empty_boundaries(),
1710 &[],
1711 );
1712 assert!(
1713 r.topology
1714 .external_processes
1715 .iter()
1716 .all(|p| p.covered_by.is_none()),
1717 "neither os.system nor subprocess.Popen is ever a match candidate"
1718 );
1719 }
1720
1721 #[test]
1727 fn simplification_findings_pair_with_topology_buckets() {
1728 use crate::scan::{SimplificationSighting, TransportClass};
1729 let mut scan = ScanResult {
1730 files_scanned: 2,
1731 python_available: true,
1732 ..ScanResult::default()
1733 };
1734 scan.targets.insert(
1736 "api.ok.com".into(),
1737 TargetEvidence {
1738 class: TargetClass::Host,
1739 sightings: [Sighting {
1740 file: "app.py".into(),
1741 line: 3,
1742 }]
1743 .into_iter()
1744 .collect(),
1745 },
1746 );
1747 scan.host_transports
1748 .insert("api.ok.com".into(), TransportClass::Tracked);
1749 scan.simplifications.push(SimplificationSighting {
1750 file: "app.py".into(),
1751 line: 12,
1752 kind: "hand-rolled-retry".into(),
1753 function: "caller".into(),
1754 targets: vec!["api.ok.com".into()],
1755 });
1756 scan.targets.insert(
1759 "api.tavily.com".into(),
1760 TargetEvidence {
1761 class: TargetClass::Host,
1762 sightings: [Sighting {
1763 file: "fetch_short_metrics.py".into(),
1764 line: 39,
1765 }]
1766 .into_iter()
1767 .collect(),
1768 },
1769 );
1770 scan.host_transports
1771 .insert("api.tavily.com".into(), TransportClass::UntrackedKnown);
1772 scan.simplifications.push(SimplificationSighting {
1773 file: "fetch_short_metrics.py".into(),
1774 line: 83,
1775 kind: "hand-rolled-poll".into(),
1776 function: "_poll_research".into(),
1777 targets: vec!["api.tavily.com".into()],
1778 });
1779 let r = build_report(
1780 &scan,
1781 &BTreeSet::new(),
1782 default_policy(),
1783 default_journal(),
1784 None,
1785 empty_boundaries(),
1786 &[],
1787 );
1788 let retry = r
1789 .findings
1790 .iter()
1791 .find(|f| f.topic == "hand-rolled-retry")
1792 .expect("retry finding");
1793 assert_eq!(retry.level, "warn", "wrappable target → actionable now");
1794 assert!(retry.detail.contains("api.ok.com"));
1795 assert!(retry.detail.contains("app.py:12"));
1796 assert!(retry.detail.contains("caller"));
1797 let poll = r
1798 .findings
1799 .iter()
1800 .find(|f| f.topic == "hand-rolled-poll")
1801 .expect("poll finding");
1802 assert_eq!(poll.level, "info", "unreachable target → once-wrapped lead");
1803 assert!(poll.detail.contains("fetch_short_metrics.py:83"));
1804 assert!(poll.action.contains("poll"), "names the poll primitive");
1805 assert!(
1807 r.follow_ups
1808 .iter()
1809 .all(|f| !f.code.starts_with("hand-rolled") && f.code != "silent-swallow"),
1810 "{:?}",
1811 r.follow_ups
1812 );
1813 assert!(r.ok);
1815 }
1816
1817 #[test]
1821 fn follow_ups_are_ranked_closed_vocabulary_and_sorted() {
1822 use crate::scan::{DepAverseFile, SubprocessSighting, TransportClass};
1823 let mut scan = ScanResult {
1824 files_scanned: 4,
1825 python_available: true,
1826 ..ScanResult::default()
1827 };
1828 for (host, file) in [("api.zeta.com", "z.py"), ("api.alpha.com", "a.py")] {
1831 scan.targets.insert(
1832 host.into(),
1833 TargetEvidence {
1834 class: TargetClass::Host,
1835 sightings: [Sighting {
1836 file: file.into(),
1837 line: 1,
1838 }]
1839 .into_iter()
1840 .collect(),
1841 },
1842 );
1843 scan.host_transports
1844 .insert(host.into(), TransportClass::UntrackedKnown);
1845 }
1846 scan.subprocesses.push(SubprocessSighting {
1848 file: "launch.py".into(),
1849 line: 12,
1850 launcher: "subprocess.run".into(),
1851 command: "uvx alpaca-mcp-server".into(),
1852 argv: Some(vec!["uvx".into(), "alpaca-mcp-server".into()]),
1853 });
1854 scan.targets.insert(
1856 "api.broker.com".into(),
1857 TargetEvidence {
1858 class: TargetClass::Host,
1859 sightings: [Sighting {
1860 file: "risk_gate.py".into(),
1861 line: 20,
1862 }]
1863 .into_iter()
1864 .collect(),
1865 },
1866 );
1867 scan.dependency_averse.push(DepAverseFile {
1868 file: "risk_gate.py".into(),
1869 reason: "stdlib-only + name/docstring signal: risk".into(),
1870 });
1871 scan.libs.insert("httpx".to_owned());
1873 scan.resilience_libs.insert("tenacity".to_owned());
1874
1875 let r = build_report(
1876 &scan,
1877 &BTreeSet::new(),
1878 default_policy(),
1879 default_journal(),
1880 None,
1881 empty_boundaries(),
1882 &[],
1883 );
1884
1885 let got: Vec<(u32, &str, &str)> = r
1886 .follow_ups
1887 .iter()
1888 .map(|f| (f.rank, f.code, f.subject.as_str()))
1889 .collect();
1890 assert_eq!(
1891 got,
1892 vec![
1893 (1, "url-no-transport", "api.alpha.com"),
1894 (1, "url-no-transport", "api.zeta.com"),
1895 (
1896 3,
1897 "subprocess-blind-spot",
1898 "1 externally-launched process(es)"
1899 ),
1900 (4, "dependency-averse-excluded", "api.broker.com"),
1901 (5, "preexisting-resilience", "tenacity"),
1902 ]
1903 );
1904 assert!(r.follow_ups.iter().all(|f| !f.detail.is_empty()));
1906 assert!(r.ok);
1908 let text = human(&r);
1910 assert!(text.contains("follow-ups"));
1911 assert!(text.contains("[url-no-transport] api.alpha.com"));
1912 }
1913
1914 #[test]
1916 fn no_signals_means_empty_follow_ups() {
1917 use crate::scan::TransportClass;
1918 let scan = scan_with("api.example.com", TargetClass::Host, &["httpx"]);
1919 let mut scan = scan;
1921 scan.host_transports
1922 .insert("api.example.com".into(), TransportClass::Tracked);
1923 let r = build_report(
1924 &scan,
1925 &BTreeSet::new(),
1926 default_policy(),
1927 default_journal(),
1928 None,
1929 empty_boundaries(),
1930 &[],
1931 );
1932 assert!(r.follow_ups.is_empty(), "{:?}", r.follow_ups);
1933 }
1934
1935 #[test]
1941 #[allow(clippy::too_many_lines)] fn wrapped_and_llm_targets_are_always_wrappable() {
1944 use crate::scan::{DepAverseFile, TransportClass};
1945 let mut scan = ScanResult {
1946 files_scanned: 3,
1947 python_available: true,
1948 ..ScanResult::default()
1949 };
1950 scan.targets.insert(
1952 "api.wrapped-but-unknown.com".into(),
1953 TargetEvidence {
1954 class: TargetClass::Host,
1955 sightings: [Sighting {
1956 file: "app.py".into(),
1957 line: 1,
1958 }]
1959 .into_iter()
1960 .collect(),
1961 },
1962 );
1963 scan.host_transports.insert(
1964 "api.wrapped-but-unknown.com".into(),
1965 TransportClass::Unknown,
1966 );
1967 scan.targets.insert(
1970 "api.wrapped-but-excluded.com".into(),
1971 TargetEvidence {
1972 class: TargetClass::Host,
1973 sightings: [Sighting {
1974 file: "risk_gate.py".into(),
1975 line: 5,
1976 }]
1977 .into_iter()
1978 .collect(),
1979 },
1980 );
1981 scan.host_transports.insert(
1982 "api.wrapped-but-excluded.com".into(),
1983 TransportClass::UntrackedKnown,
1984 );
1985 scan.dependency_averse.push(DepAverseFile {
1986 file: "risk_gate.py".into(),
1987 reason: "stdlib-only + name/docstring signal: risk".into(),
1988 });
1989 scan.targets.insert(
1992 "llm:some-model".into(),
1993 TargetEvidence {
1994 class: TargetClass::Llm,
1995 sightings: [Sighting {
1996 file: "agent.py".into(),
1997 line: 7,
1998 }]
1999 .into_iter()
2000 .collect(),
2001 },
2002 );
2003 let wrapped: BTreeSet<String> = [
2004 "api.wrapped-but-unknown.com".to_owned(),
2005 "api.wrapped-but-excluded.com".to_owned(),
2006 ]
2007 .into_iter()
2008 .collect();
2009 let r = build_report(
2010 &scan,
2011 &wrapped,
2012 default_policy(),
2013 default_journal(),
2014 None,
2015 empty_boundaries(),
2016 &[],
2017 );
2018
2019 assert!(
2020 r.topology
2021 .wrappable
2022 .contains(&"api.wrapped-but-unknown.com".to_owned()),
2023 "a wrapped-at-runtime target must be wrappable even with an Unknown transport class: \
2024 {:?}",
2025 r.topology
2026 );
2027 assert!(
2028 !r.topology
2029 .unreachable
2030 .iter()
2031 .any(|e| e.host == "api.wrapped-but-unknown.com"),
2032 "must not also land in unreachable"
2033 );
2034 assert!(
2035 r.topology
2036 .wrappable
2037 .contains(&"api.wrapped-but-excluded.com".to_owned()),
2038 "a wrapped-at-runtime target must be wrappable even when sighted only in a \
2039 dependency-averse file: {:?}",
2040 r.topology
2041 );
2042 assert!(
2043 !r.topology
2044 .excluded
2045 .iter()
2046 .any(|e| e.host == "api.wrapped-but-excluded.com"),
2047 "must not also land in excluded"
2048 );
2049 assert!(
2050 r.topology.wrappable.contains(&"llm:some-model".to_owned()),
2051 "an llm:* target must be wrappable with zero transport evidence: {:?}",
2052 r.topology
2053 );
2054 }
2055
2056 #[test]
2061 fn agent_pack_adapters_are_registered_pinned_and_detected() {
2062 let scan = scan_with(
2063 "llm:google-genai",
2064 TargetClass::Llm,
2065 &[
2066 "google-adk",
2067 "google-genai",
2068 "pydantic-ai",
2069 "openai-agents",
2070 "crewai",
2071 "langgraph",
2072 "mcp",
2073 ],
2074 );
2075 let policy = PolicyValidation {
2076 check: PolicyCheck {
2077 field: None,
2078 message: None,
2079 present: false,
2080 valid: true,
2081 },
2082 cmd_match: BTreeMap::new(),
2083 fix: None,
2084 };
2085 let r = build_report(
2086 &scan,
2087 &BTreeSet::new(),
2088 policy,
2089 default_journal(),
2090 None,
2091 empty_boundaries(),
2092 &[],
2093 );
2094
2095 assert!(
2096 r.coverage.invisible.is_empty(),
2097 "every imported agent-pack lib has a registry adapter: {:?}",
2098 r.coverage.invisible
2099 );
2100 for (lib, target) in [
2101 ("google-adk", "tool:<name>"),
2102 ("google-genai", "llm:google-genai"),
2103 ("pydantic-ai", "tool:<name>"),
2104 ("openai-agents", "tool:<name>"),
2105 ("crewai", "tool:<name>"),
2106 ("langgraph", "tool:<name>"),
2107 ("mcp", "mcp:<server>"),
2112 ] {
2113 let a = r
2114 .adapters
2115 .iter()
2116 .find(|a| a.lib == lib && a.target == target)
2117 .unwrap_or_else(|| panic!("missing REGISTRY entry for {lib} -> {target}"));
2118 assert!(a.detected, "{lib} should be detected");
2119 assert_eq!(a.status, "pinned");
2120 }
2121 }
2122
2123 #[test]
2133 fn pure_node_mcp_project_lights_up_the_mcp_adapter() {
2134 use std::fs;
2135 use tempfile::TempDir;
2136
2137 let dir = TempDir::new().unwrap();
2138 fs::write(
2139 dir.path().join("server.ts"),
2140 "import { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\n",
2141 )
2142 .unwrap();
2143 let scan = scan::scan(dir.path());
2144 assert!(
2145 scan.libs.contains("mcp"),
2146 "a Node-only scan must carry `mcp` into ScanResult.libs: {:?}",
2147 scan.libs
2148 );
2149
2150 let r = build_report(
2151 &scan,
2152 &BTreeSet::new(),
2153 default_policy(),
2154 default_journal(),
2155 None,
2156 empty_boundaries(),
2157 &[],
2158 );
2159 let mcp = r
2160 .adapters
2161 .iter()
2162 .find(|a| a.lib == "mcp")
2163 .expect("mcp REGISTRY row");
2164 assert!(
2165 mcp.detected,
2166 "pure-Node MCP project must show the mcp adapter detected"
2167 );
2168 assert_eq!(mcp.target, "mcp:<server>");
2169 assert!(
2170 !r.coverage.invisible.iter().any(|l| l == "mcp"),
2171 "a registered adapter is coverage, not an invisible finding: {:?}",
2172 r.coverage.invisible
2173 );
2174 }
2175
2176 fn default_policy() -> PolicyValidation {
2177 PolicyValidation {
2178 check: PolicyCheck {
2179 field: None,
2180 message: None,
2181 present: false,
2182 valid: true,
2183 },
2184 cmd_match: BTreeMap::new(),
2185 fix: None,
2186 }
2187 }
2188
2189 #[test]
2194 fn urllib_request_is_a_registered_tracked_adapter() {
2195 let mut scan = scan_with("api.tavily.com", TargetClass::Host, &["urllib.request"]);
2196 scan.host_transports
2197 .insert("api.tavily.com".into(), TransportClass::Tracked);
2198 let r = build_report(
2199 &scan,
2200 &BTreeSet::new(),
2201 default_policy(),
2202 default_journal(),
2203 None,
2204 empty_boundaries(),
2205 &[],
2206 );
2207 let row = r
2208 .adapters
2209 .iter()
2210 .find(|a| a.lib == "urllib.request")
2211 .expect("REGISTRY row for urllib.request");
2212 assert!(row.detected);
2213 assert_eq!(row.status, "pinned");
2214 assert_eq!(row.target, "host");
2215 assert!(
2216 r.coverage.invisible.is_empty(),
2217 "{:?}",
2218 r.coverage.invisible
2219 );
2220 assert!(r.topology.wrappable.contains(&"api.tavily.com".to_owned()));
2221 assert!(
2222 r.topology.unreachable.is_empty(),
2223 "{:?}",
2224 r.topology.unreachable
2225 );
2226 }
2227
2228 #[test]
2229 fn resilience_lib_alongside_a_wrapped_effect_is_a_finding() {
2230 let mut scan = scan_with("api.example.com", TargetClass::Host, &["httpx"]);
2231 scan.resilience_libs.insert("tenacity".to_owned());
2232 let r = build_report(
2233 &scan,
2234 &BTreeSet::new(),
2235 default_policy(),
2236 default_journal(),
2237 None,
2238 empty_boundaries(),
2239 &[],
2240 );
2241 let finding = r
2242 .findings
2243 .iter()
2244 .find(|f| f.topic == "preexisting-resilience")
2245 .expect("tenacity + httpx should raise a finding");
2246 assert_eq!(finding.level, "warn");
2247 assert!(finding.detail.contains("tenacity"));
2248 }
2249
2250 #[test]
2251 fn resilience_lib_with_no_wrapped_effect_is_not_a_finding() {
2252 let mut scan = ScanResult {
2256 files_scanned: 1,
2257 python_available: true,
2258 ..ScanResult::default()
2259 };
2260 scan.resilience_libs.insert("tenacity".to_owned());
2261 let r = build_report(
2262 &scan,
2263 &BTreeSet::new(),
2264 default_policy(),
2265 default_journal(),
2266 None,
2267 empty_boundaries(),
2268 &[],
2269 );
2270 assert!(
2271 !r.findings
2272 .iter()
2273 .any(|f| f.topic == "preexisting-resilience")
2274 );
2275 }
2276
2277 #[test]
2278 fn no_resilience_libs_is_not_a_finding() {
2279 let scan = scan_with("api.example.com", TargetClass::Host, &["httpx"]);
2280 let r = build_report(
2281 &scan,
2282 &BTreeSet::new(),
2283 default_policy(),
2284 default_journal(),
2285 None,
2286 empty_boundaries(),
2287 &[],
2288 );
2289 assert!(
2290 !r.findings
2291 .iter()
2292 .any(|f| f.topic == "preexisting-resilience")
2293 );
2294 }
2295
2296 #[test]
2297 fn invalid_policy_is_a_finding_and_not_ok() {
2298 let scan = ScanResult::default();
2299 let wrapped = BTreeSet::new();
2300 let policy = PolicyValidation {
2301 check: PolicyCheck {
2302 field: Some("target.x.retry.attempts".to_owned()),
2303 message: Some("invalid value: integer `0`".to_owned()),
2304 present: true,
2305 valid: false,
2306 },
2307 cmd_match: BTreeMap::new(),
2308 fix: None,
2309 };
2310 let r = build_report(
2311 &scan,
2312 &wrapped,
2313 policy,
2314 default_journal(),
2315 None,
2316 empty_boundaries(),
2317 &[],
2318 );
2319 assert!(!r.ok);
2320 assert!(
2321 r.findings
2322 .iter()
2323 .any(|f| f.topic == "policy" && f.level == "error")
2324 );
2325 }
2326
2327 #[test]
2331 fn unsupported_journal_backend_is_an_error_finding_and_not_ok() {
2332 let scan = ScanResult::default();
2333 let wrapped = BTreeSet::new();
2334 let policy = PolicyValidation {
2335 check: PolicyCheck {
2336 field: None,
2337 message: None,
2338 present: true,
2339 valid: true,
2340 },
2341 cmd_match: BTreeMap::new(),
2342 fix: None,
2343 };
2344 let journal = JournalReport {
2345 backend: "postgres",
2346 location: "postgres://\u{2026}@db.internal/keel".to_owned(),
2347 source: "keel.toml",
2348 supported: false,
2349 };
2350 let r = build_report(
2351 &scan,
2352 &wrapped,
2353 policy,
2354 journal,
2355 None,
2356 empty_boundaries(),
2357 &[],
2358 );
2359 assert!(!r.ok, "an unbootable configuration must not be ok");
2360 let finding = r
2361 .findings
2362 .iter()
2363 .find(|f| f.topic == "journal")
2364 .expect("journal finding present");
2365 assert_eq!(finding.level, "error");
2366 assert!(finding.detail.contains("KEEL-E005"));
2367 assert!(finding.action.contains("file:"));
2368 let text = human(&r);
2370 assert!(text.contains("postgres"));
2371 assert!(text.contains("NOT supported"));
2372 }
2373
2374 #[test]
2380 fn agents_cli_placement_finding_fires_for_a_root_keel_toml() {
2381 let dir = tempfile::TempDir::new().unwrap();
2382 std::fs::create_dir(dir.path().join("app")).unwrap();
2383 std::fs::write(
2384 dir.path().join("agents-cli-manifest.yaml"),
2385 "agent_directory: app\n",
2386 )
2387 .unwrap();
2388 std::fs::write(dir.path().join("keel.toml"), "[target.\"x\"]\n").unwrap();
2389
2390 let finding =
2391 agents_cli_placement_finding(dir.path()).expect("root keel.toml should be flagged");
2392 assert_eq!(finding.level, "warn");
2393 assert_eq!(finding.topic, "agents-cli-config-placement");
2394 assert!(finding.detail.contains("Dockerfile"));
2395 assert!(finding.detail.contains("pyproject.toml"));
2396 assert!(finding.detail.contains("app"));
2397 assert!(
2398 finding.action.contains("app/keel.toml") || finding.action.contains("app\\keel.toml"),
2399 "action names the relative move-to path: {}",
2400 finding.action
2401 );
2402 }
2403
2404 #[test]
2406 fn agents_cli_placement_finding_is_none_without_a_manifest() {
2407 let dir = tempfile::TempDir::new().unwrap();
2408 std::fs::write(dir.path().join("keel.toml"), "[target.\"x\"]\n").unwrap();
2409 assert!(agents_cli_placement_finding(dir.path()).is_none());
2410 }
2411
2412 #[test]
2415 fn agents_cli_placement_finding_is_none_when_keel_toml_is_already_in_the_agent_dir() {
2416 let dir = tempfile::TempDir::new().unwrap();
2417 std::fs::create_dir(dir.path().join("app")).unwrap();
2418 std::fs::write(
2419 dir.path().join("agents-cli-manifest.yaml"),
2420 "agent_directory: app\n",
2421 )
2422 .unwrap();
2423 std::fs::write(dir.path().join("app").join("keel.toml"), "[target.\"x\"]\n").unwrap();
2424
2425 assert!(agents_cli_placement_finding(dir.path()).is_none());
2426 }
2427
2428 #[test]
2432 fn agents_cli_placement_finding_is_none_when_agent_dir_is_the_project_root() {
2433 let dir = tempfile::TempDir::new().unwrap();
2434 std::fs::write(
2435 dir.path().join("agents-cli-manifest.yaml"),
2436 "agent_directory: .\n",
2437 )
2438 .unwrap();
2439 std::fs::write(dir.path().join("keel.toml"), "[target.\"x\"]\n").unwrap();
2440
2441 assert!(agents_cli_placement_finding(dir.path()).is_none());
2442 }
2443
2444 #[test]
2447 fn doctor_run_surfaces_the_agents_cli_placement_finding() {
2448 let dir = tempfile::TempDir::new().unwrap();
2449 std::fs::create_dir(dir.path().join("app")).unwrap();
2450 std::fs::write(
2451 dir.path().join("agents-cli-manifest.yaml"),
2452 "agent_directory: app\n",
2453 )
2454 .unwrap();
2455 std::fs::write(dir.path().join("keel.toml"), "[target.\"x\"]\n").unwrap();
2456
2457 let r = run(dir.path());
2458 assert_eq!(r.exit, EXIT_OK);
2459 assert_eq!(r.json["ok"], true);
2460 let findings = r.json["findings"].as_array().unwrap();
2461 assert!(
2462 findings
2463 .iter()
2464 .any(|f| f["topic"] == "agents-cli-config-placement" && f["level"] == "warn")
2465 );
2466 }
2467
2468 #[test]
2471 fn doctor_reports_the_policy_selected_journal_location() {
2472 let dir = tempfile::TempDir::new().unwrap();
2473 std::fs::write(
2474 dir.path().join("keel.toml"),
2475 "journal = \"file:custom/j.db\"\n",
2476 )
2477 .unwrap();
2478 let r = run(dir.path());
2479 assert_eq!(r.exit, EXIT_OK);
2480 assert_eq!(r.json["journal"]["backend"], "sqlite");
2481 assert_eq!(r.json["journal"]["location"], "custom/j.db");
2482 assert_eq!(r.json["journal"]["source"], "keel.toml");
2483 assert_eq!(r.json["journal"]["supported"], true);
2484 assert!(r.human.contains("custom/j.db"));
2485 }
2486
2487 #[test]
2490 fn doctor_flags_a_postgres_journal_and_redacts_credentials() {
2491 let dir = tempfile::TempDir::new().unwrap();
2492 std::fs::write(
2493 dir.path().join("keel.toml"),
2494 "journal = \"postgres://keel:sekrit@db.internal/keel\"\n",
2495 )
2496 .unwrap();
2497 let r = run(dir.path());
2498 assert_eq!(r.exit, EXIT_USAGE);
2499 assert_eq!(r.json["journal"]["backend"], "postgres");
2500 assert_eq!(r.json["journal"]["supported"], false);
2501 assert_eq!(r.json["ok"], false);
2502 let json_text = crate::render::json_string(&r.json);
2503 assert!(!json_text.contains("sekrit"), "credentials never printed");
2504 assert!(!r.human.contains("sekrit"), "credentials never printed");
2505 }
2506
2507 #[test]
2508 fn validate_policy_reports_exact_field_path() {
2509 let dir = tempfile::TempDir::new().unwrap();
2510 let path = dir.path().join("keel.toml");
2511 std::fs::write(&path, "[target.\"x\"]\nretry = { attempts = 0 }\n").unwrap();
2512 let v = validate_policy(&path);
2513 assert!(!v.check.valid);
2514 assert_eq!(v.check.field.as_deref(), Some("target.x.retry.attempts"));
2515 }
2516
2517 #[test]
2518 fn validate_policy_accepts_a_good_file() {
2519 let dir = tempfile::TempDir::new().unwrap();
2520 let path = dir.path().join("keel.toml");
2521 std::fs::write(
2522 &path,
2523 "[target.\"api.x\"]\nretry = { attempts = 5, schedule = \"exp(200ms, x2, max 30s, jitter)\" }\n",
2524 )
2525 .unwrap();
2526 let v = validate_policy(&path);
2527 assert!(
2528 v.check.valid,
2529 "field={:?} msg={:?}",
2530 v.check.field, v.check.message
2531 );
2532 assert!(v.fix.is_none(), "a valid policy needs no fix");
2533 }
2534
2535 #[test]
2536 fn absent_policy_is_valid_and_ok() {
2537 let v = validate_policy(Path::new("/nonexistent/keel.toml"));
2538 assert!(v.check.valid);
2539 assert!(!v.check.present);
2540 }
2541
2542 #[test]
2546 fn invalid_policy_finding_carries_an_applyable_removal_fix() {
2547 let dir = tempfile::TempDir::new().unwrap();
2548 let path = dir.path().join("keel.toml");
2549 std::fs::write(
2550 &path,
2551 "# my tuning\n[target.\"api.example.com\"]\ntimeout = \"30s\" # keep\nretry = { attempts = 0 }\n",
2552 )
2553 .unwrap();
2554
2555 let v = validate_policy(&path);
2556 assert!(!v.check.valid);
2557 assert_eq!(
2558 v.check.field.as_deref(),
2559 Some("target.api.example.com.retry.attempts"),
2560 "dotted host key resolves"
2561 );
2562 let fix = v.fix.expect("fix proposal attached");
2563 assert!(fix.patch.starts_with("--- a/keel.toml\n+++ b/keel.toml\n"));
2564 let applied =
2566 crate::diff::apply_unified(&std::fs::read_to_string(&path).unwrap(), &fix.patch)
2567 .unwrap();
2568 assert_eq!(applied, fix.new_text);
2569 std::fs::write(&path, &fix.new_text).unwrap();
2571 let after = validate_policy(&path);
2572 assert!(after.check.valid, "removal fix yields a valid policy");
2573 assert!(fix.new_text.contains("# my tuning"));
2574 assert!(fix.new_text.contains("timeout = \"30s\" # keep"));
2575 assert!(
2576 !fix.new_text.contains("retry"),
2577 "whole invalid entry removed"
2578 );
2579 assert_eq!(fix.changes.len(), 1);
2581 assert_eq!(fix.changes[0].path, "target.\"api.example.com\".retry");
2582 assert!(fix.changes[0].after.is_none());
2583 }
2584
2585 #[test]
2587 fn unparseable_policy_has_no_fix() {
2588 let dir = tempfile::TempDir::new().unwrap();
2589 let path = dir.path().join("keel.toml");
2590 std::fs::write(&path, "not [valid toml\n").unwrap();
2591 let v = validate_policy(&path);
2592 assert!(!v.check.valid);
2593 assert!(v.fix.is_none());
2594 }
2595
2596 fn python3_present() -> bool {
2600 std::process::Command::new("python3")
2601 .arg("--version")
2602 .stdout(std::process::Stdio::null())
2603 .stderr(std::process::Stdio::null())
2604 .status()
2605 .is_ok_and(|s| s.success())
2606 }
2607
2608 #[test]
2611 fn code_hash_stale_flow_emits_the_rank6_follow_up() {
2612 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
2613 let schema = std::fs::read_to_string(root.join("contracts/journal.sql")).unwrap();
2614 let dir = tempfile::TempDir::new().unwrap();
2615 let keel = dir.path().join(".keel");
2616 std::fs::create_dir_all(&keel).unwrap();
2617 let conn = rusqlite::Connection::open(keel.join("journal.db")).unwrap();
2618 conn.execute_batch(&schema).unwrap();
2619 let t0: i64 = 1_783_728_000_000;
2620 conn.execute(
2621 "INSERT INTO flows (flow_id, entrypoint, args_hash, code_hash, status, created_at, \
2622 updated_at) VALUES ('01STALEFLOW', 'py:pipeline.ingest:main', 'ah-1', \
2623 'deadbeefdeadbeef', 'running', ?1, ?1)",
2624 rusqlite::params![t0],
2625 )
2626 .unwrap();
2627 let script_dir = dir.path().join("pipeline");
2630 std::fs::create_dir_all(&script_dir).unwrap();
2631 std::fs::write(script_dir.join("ingest.py"), "def main():\n pass\n").unwrap();
2632
2633 let r = run(dir.path());
2634 let f = r.json["follow_ups"]
2635 .as_array()
2636 .unwrap()
2637 .iter()
2638 .find(|f| f["code"] == "code-hash-stale")
2639 .expect("code-hash-stale emitted");
2640 assert_eq!(f["rank"], 6);
2641 assert!(f["detail"].as_str().unwrap().contains("keel replay"));
2642 }
2643
2644 #[test]
2650 fn doctor_and_init_diff_never_leak_raw_source() {
2651 const CANARIES: [&str; 5] = [
2652 "CANARY_COMMENT_9f31",
2653 "CANARY_SECRET_9f31",
2654 "CANARY_QUERY_9f31",
2655 "CANARY_DOCSTRING_9f31",
2656 "CANARY_QUERY2_9f31",
2657 ];
2658 if !python3_present() {
2659 eprintln!("skip: python3 not available");
2660 return;
2661 }
2662 let dir = tempfile::TempDir::new().unwrap();
2663 std::fs::write(
2664 dir.path().join("app.py"),
2665 r#"import time
2666import urllib.request
2667import httpx
2668import tenacity
2669# CANARY_COMMENT_9f31 must never appear in any report
2670TOKEN = "CANARY_SECRET_9f31"
2671U = "https://api.leak.example/v1?key=CANARY_QUERY_9f31"
2672
2673def caller():
2674 attempt = 0
2675 while True:
2676 try:
2677 return httpx.get(U)
2678 except Exception:
2679 # CANARY_COMMENT_9f31 must never appear in any report
2680 # Deliberate handler-local canary: an unused local-variable
2681 # assignment RHS, a syntactic position distinct from the other
2682 # four canaries above (comment, module const, URL query param,
2683 # docstring) — not dead code left behind by mistake.
2684 local_secret = "CANARY_QUERY2_9f31"
2685 attempt += 1
2686 time.sleep(1)
2687"#,
2688 )
2689 .unwrap();
2690 std::fs::write(
2691 dir.path().join("risk_gate.py"),
2692 "\"\"\"risk gate. CANARY_DOCSTRING_9f31 stdlib only.\"\"\"\n\
2693 import json\n\
2694 G = \"https://api.gateonly.example/v2?tok=CANARY_QUERY2_9f31\"\n",
2695 )
2696 .unwrap();
2697 let doctor = run(dir.path());
2698 let doctor_json = crate::render::json_string(&doctor.json);
2699 let init = crate::init::run(
2700 dir.path(),
2701 crate::init::InitOptions {
2702 diff: true,
2703 stamp: false,
2704 agents: false,
2705 },
2706 );
2707 let init_json = crate::render::json_string(&init.json);
2708 for canary in CANARIES {
2709 assert!(!doctor_json.contains(canary), "doctor json leaks {canary}");
2710 assert!(
2711 !doctor.human.contains(canary),
2712 "doctor human leaks {canary}"
2713 );
2714 assert!(
2715 !init_json.contains(canary),
2716 "init --diff json leaks {canary}"
2717 );
2718 assert!(
2719 !init.human.contains(canary),
2720 "init --diff human leaks {canary}"
2721 );
2722 }
2723 assert!(doctor_json.contains("api.leak.example"));
2726 assert!(
2731 init_json.contains("hand-rolled-retry"),
2732 "init --diff notes should surface the hand-rolled retry loop: {init_json}"
2733 );
2734 }
2735
2736 fn empty_boundaries() -> Boundaries {
2743 let dir = tempfile::TempDir::new().unwrap();
2744 boundaries(dir.path())
2745 }
2746
2747 #[test]
2748 fn report_always_carries_boundaries() {
2749 let scan = ScanResult::default();
2750 let wrapped = BTreeSet::new();
2751 let r = build_report(
2752 &scan,
2753 &wrapped,
2754 default_policy(),
2755 default_journal(),
2756 None,
2757 empty_boundaries(),
2758 &[],
2759 );
2760 assert!(r.boundaries.parsed_languages.contains(&"js-ts"));
2761 assert!(r.boundaries.unparsed.contains(&"ci-workflow"));
2762 assert!(!r.findings.iter().any(|f| f.topic == "evaluation-protocol"));
2764 assert!(!r.findings.iter().any(|f| f.topic == "governance-boundary"));
2765 }
2766
2767 #[test]
2768 fn boundaries_list_governance_files_that_exist() {
2769 let dir = tempfile::TempDir::new().unwrap();
2770 std::fs::write(dir.path().join("CLAUDE.md"), "# rules\n").unwrap();
2771 let b = boundaries(dir.path());
2772 assert_eq!(b.governance_files, vec!["CLAUDE.md"]);
2773
2774 std::fs::write(dir.path().join("AGENTS.md"), "# keel\n").unwrap();
2775 let b = boundaries(dir.path());
2776 assert_eq!(b.governance_files, vec!["CLAUDE.md", "AGENTS.md"]);
2777 }
2778
2779 #[test]
2780 fn boundaries_are_empty_but_present_without_governance_files() {
2781 let dir = tempfile::TempDir::new().unwrap();
2782 let b = boundaries(dir.path());
2783 assert!(b.governance_files.is_empty());
2784 assert!(b.parsed_languages.contains(&"python"));
2787 assert!(b.unparsed.contains(&"shell"));
2788 assert!(b.protocol.contains("Baseline"));
2789 }
2790
2791 #[test]
2792 fn human_report_carries_a_compact_boundaries_section() {
2793 let dir = tempfile::TempDir::new().unwrap();
2794 std::fs::write(dir.path().join("CLAUDE.md"), "# rules\n").unwrap();
2795 let scan = ScanResult::default();
2796 let r = build_report(
2797 &scan,
2798 &BTreeSet::new(),
2799 default_policy(),
2800 default_journal(),
2801 None,
2802 boundaries(dir.path()),
2803 &[],
2804 );
2805 let text = human(&r);
2806 assert!(text.contains("\nboundaries\n"), "{text}");
2807 assert!(text.contains("python, js-ts"), "{text}");
2808 assert!(text.contains("CLAUDE.md"), "{text}");
2809 let section = text.split("\nboundaries\n").nth(1).unwrap();
2811 let lines = section.lines().take_while(|l| l.starts_with(" ")).count();
2812 assert!(
2813 lines <= 3,
2814 "boundaries section is {lines} lines:\n{section}"
2815 );
2816 }
2817
2818 #[test]
2821 fn orchestration_sightings_become_a_finding() {
2822 let mut scan = ScanResult::default();
2823 for (file, line) in [
2824 ("scripts/run_autonomous.sh", 3),
2825 ("scripts/run_autonomous.sh", 9),
2826 ] {
2827 scan.orchestration.push(scan::OrchestrationSighting {
2828 file: file.to_owned(),
2829 line,
2830 kind: "lockfile-mutex".to_owned(),
2831 snippet: "flock -n /tmp/x.lock || exit 0".to_owned(),
2832 });
2833 }
2834 let r = build_report(
2835 &scan,
2836 &BTreeSet::new(),
2837 default_policy(),
2838 default_journal(),
2839 None,
2840 empty_boundaries(),
2841 &[],
2842 );
2843 let f = r
2844 .findings
2845 .iter()
2846 .find(|f| f.topic == "orchestration-blind-spot")
2847 .expect("orchestration finding present");
2848 assert_eq!(f.level, "warn");
2849 assert!(f.detail.contains("run_autonomous.sh"), "{}", f.detail);
2850 assert_eq!(
2852 f.detail.matches("run_autonomous.sh").count(),
2853 1,
2854 "{}",
2855 f.detail
2856 );
2857 }
2858
2859 #[test]
2861 fn orchestration_finding_caps_the_file_list() {
2862 let mut scan = ScanResult::default();
2863 for i in 0..40 {
2864 scan.orchestration.push(scan::OrchestrationSighting {
2865 file: format!("scripts/s{i:02}.sh"),
2866 line: 1,
2867 kind: "pid-check".to_owned(),
2868 snippet: "kill -0 $PID".to_owned(),
2869 });
2870 }
2871 scan.orchestration.sort();
2872 let r = build_report(
2873 &scan,
2874 &BTreeSet::new(),
2875 default_policy(),
2876 default_journal(),
2877 None,
2878 empty_boundaries(),
2879 &[],
2880 );
2881 let f = r
2882 .findings
2883 .iter()
2884 .find(|f| f.topic == "orchestration-blind-spot")
2885 .unwrap();
2886 assert!(f.detail.contains("and 35 more"), "{}", f.detail);
2887 assert!(f.detail.len() < 600, "detail is {} bytes", f.detail.len());
2888 }
2889
2890 #[test]
2891 fn no_orchestration_no_finding() {
2892 let scan = ScanResult::default();
2893 let r = build_report(
2894 &scan,
2895 &BTreeSet::new(),
2896 default_policy(),
2897 default_journal(),
2898 None,
2899 empty_boundaries(),
2900 &[],
2901 );
2902 assert!(
2903 !r.findings
2904 .iter()
2905 .any(|f| f.topic == "orchestration-blind-spot")
2906 );
2907 }
2908
2909 #[test]
2910 fn orchestration_sightings_become_a_ranked_follow_up() {
2911 let mut scan = ScanResult::default();
2912 scan.orchestration.push(scan::OrchestrationSighting {
2913 file: "scripts/run_autonomous.sh".to_owned(),
2914 line: 3,
2915 kind: "lockfile-mutex".to_owned(),
2916 snippet: "flock -n /tmp/x.lock || exit 0".to_owned(),
2917 });
2918 let r = build_report(
2919 &scan,
2920 &BTreeSet::new(),
2921 default_policy(),
2922 default_journal(),
2923 None,
2924 empty_boundaries(),
2925 &[],
2926 );
2927 let up = r
2928 .follow_ups
2929 .iter()
2930 .find(|f| f.code == "orchestration-blind-spot")
2931 .expect("orchestration follow-up present");
2932 assert_eq!(up.rank, 2);
2933 assert!(!up.detail.is_empty());
2934 assert!(r.ok);
2936 }
2937}