@@ -217,6 +217,58 @@ pub async fn run_doctor(opts: DoctorOptions) -> anyhow::Result<DoctorReport> {
217217 } ) ;
218218 }
219219
220+ checks. push ( DoctorCheck {
221+ id : "security.secrets.mode" . to_string ( ) ,
222+ status : CheckStatus :: Ok ,
223+ message : match cfg. security . secrets . mode {
224+ rexos:: security:: SecretMode :: EnvFirst => {
225+ "env_first (provider credentials resolve from host environment)" . to_string ( )
226+ }
227+ } ,
228+ } ) ;
229+
230+ checks. push ( DoctorCheck {
231+ id : "security.leaks.mode" . to_string ( ) ,
232+ status : match cfg. security . leaks . mode {
233+ rexos:: security:: LeakMode :: Off | rexos:: security:: LeakMode :: Warn => {
234+ CheckStatus :: Warn
235+ }
236+ rexos:: security:: LeakMode :: Redact | rexos:: security:: LeakMode :: Enforce => {
237+ CheckStatus :: Ok
238+ }
239+ } ,
240+ message : match cfg. security . leaks . mode {
241+ rexos:: security:: LeakMode :: Off => {
242+ "off (tool output is not scanned for secrets)" . to_string ( )
243+ }
244+ rexos:: security:: LeakMode :: Warn => {
245+ "warn (detects likely secrets but still forwards raw output)" . to_string ( )
246+ }
247+ rexos:: security:: LeakMode :: Redact => {
248+ "redact (masks detected secrets before persistence and follow-up model calls)"
249+ . to_string ( )
250+ }
251+ rexos:: security:: LeakMode :: Enforce => {
252+ "enforce (blocks tool output when likely secrets are detected)" . to_string ( )
253+ }
254+ } ,
255+ } ) ;
256+
257+ let egress_rules = cfg. security . egress . rules . len ( ) ;
258+ checks. push ( DoctorCheck {
259+ id : "security.egress.rules" . to_string ( ) ,
260+ status : if egress_rules == 0 {
261+ CheckStatus :: Warn
262+ } else {
263+ CheckStatus :: Ok
264+ } ,
265+ message : if egress_rules == 0 {
266+ "no allowlist rules configured; network tools still rely on baseline SSRF/private-network guards only" . to_string ( )
267+ } else {
268+ format ! ( "{egress_rules} outbound allowlist rule(s) configured" )
269+ } ,
270+ } ) ;
271+
220272 // Probe Ollama only when it looks local and requires no key.
221273 if let Some ( ollama) = cfg. providers . get ( "ollama" ) {
222274 if ollama. kind == ProviderKind :: OpenAiCompatible && ollama. api_key_env . trim ( ) . is_empty ( )
@@ -388,6 +440,30 @@ fn derive_next_actions(checks: &[DoctorCheck]) -> Vec<String> {
388440 }
389441 }
390442
443+ if let Some ( check) = find ( "security.leaks.mode" ) {
444+ if check. status == CheckStatus :: Warn {
445+ push_unique (
446+ & mut actions,
447+ format ! (
448+ "Set `[security.leaks].mode = \" redact\" ` or `\" enforce\" ` in `~/.loopforge/config.toml` to keep secret-like tool output out of follow-up model context and audits ({})" ,
449+ check. message
450+ ) ,
451+ ) ;
452+ }
453+ }
454+
455+ if let Some ( check) = find ( "security.egress.rules" ) {
456+ if check. status == CheckStatus :: Warn {
457+ push_unique (
458+ & mut actions,
459+ format ! (
460+ "Add `[security.egress.rules]` entries in `~/.loopforge/config.toml` to allow only the outbound hosts your workflows need ({})" ,
461+ check. message
462+ ) ,
463+ ) ;
464+ }
465+ }
466+
391467 if let Some ( check) = find ( "ollama.http" ) {
392468 if check. status != CheckStatus :: Ok {
393469 push_unique (
@@ -672,6 +748,7 @@ mod tests {
672748 . into_iter ( )
673749 . collect ( ) ,
674750 router : rexos:: config:: RouterConfig :: default ( ) ,
751+ security : Default :: default ( ) ,
675752 } ;
676753 std:: fs:: write ( paths. config_path ( ) , toml:: to_string ( & cfg) . unwrap ( ) ) . unwrap ( ) ;
677754 std:: env:: set_var ( "LOOPFORGE_BROWSER_CDP_HTTP" , format ! ( "http://{addr}" ) ) ;
@@ -694,4 +771,124 @@ mod tests {
694771 std:: env:: remove_var ( "LOOPFORGE_BROWSER_CDP_HTTP" ) ;
695772 server. abort ( ) ;
696773 }
774+
775+ #[ tokio:: test]
776+ async fn doctor_reports_security_posture_checks ( ) {
777+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
778+ let paths = RexosPaths {
779+ base_dir : tmp. path ( ) . join ( ".loopforge" ) ,
780+ } ;
781+ std:: fs:: create_dir_all ( & paths. base_dir ) . unwrap ( ) ;
782+
783+ let mut cfg = RexosConfig {
784+ llm : rexos:: config:: LlmConfig :: default ( ) ,
785+ providers : [ (
786+ "ollama" . to_string ( ) ,
787+ rexos:: config:: ProviderConfig {
788+ kind : ProviderKind :: OpenAiCompatible ,
789+ base_url : "http://127.0.0.1:11434/v1" . to_string ( ) ,
790+ api_key_env : "" . to_string ( ) ,
791+ default_model : "x" . to_string ( ) ,
792+ } ,
793+ ) ]
794+ . into_iter ( )
795+ . collect ( ) ,
796+ router : rexos:: config:: RouterConfig :: default ( ) ,
797+ security : Default :: default ( ) ,
798+ } ;
799+ cfg. security . leaks . mode = rexos:: security:: LeakMode :: Redact ;
800+ cfg. security . egress . rules . push ( rexos:: security:: EgressRule {
801+ tool : "web_fetch" . to_string ( ) ,
802+ host : "docs.rs" . to_string ( ) ,
803+ path_prefix : "/" . to_string ( ) ,
804+ methods : vec ! [ "GET" . to_string( ) ] ,
805+ } ) ;
806+ std:: fs:: write ( paths. config_path ( ) , toml:: to_string ( & cfg) . unwrap ( ) ) . unwrap ( ) ;
807+
808+ let report = run_doctor ( DoctorOptions {
809+ paths,
810+ timeout : Duration :: from_millis ( 200 ) ,
811+ } )
812+ . await
813+ . unwrap ( ) ;
814+
815+ let statuses: std:: collections:: BTreeMap < String , CheckStatus > = report
816+ . checks
817+ . iter ( )
818+ . map ( |c| ( c. id . clone ( ) , c. status ) )
819+ . collect ( ) ;
820+ assert_eq ! (
821+ statuses. get( "security.secrets.mode" ) ,
822+ Some ( & CheckStatus :: Ok )
823+ ) ;
824+ assert_eq ! ( statuses. get( "security.leaks.mode" ) , Some ( & CheckStatus :: Ok ) ) ;
825+ assert_eq ! (
826+ statuses. get( "security.egress.rules" ) ,
827+ Some ( & CheckStatus :: Ok )
828+ ) ;
829+ }
830+
831+ #[ tokio:: test]
832+ async fn doctor_suggests_leak_guard_and_egress_hardening_when_defaults_are_open ( ) {
833+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
834+ let paths = RexosPaths {
835+ base_dir : tmp. path ( ) . join ( ".loopforge" ) ,
836+ } ;
837+ std:: fs:: create_dir_all ( & paths. base_dir ) . unwrap ( ) ;
838+
839+ let cfg = RexosConfig {
840+ llm : rexos:: config:: LlmConfig :: default ( ) ,
841+ providers : [ (
842+ "ollama" . to_string ( ) ,
843+ rexos:: config:: ProviderConfig {
844+ kind : ProviderKind :: OpenAiCompatible ,
845+ base_url : "http://127.0.0.1:11434/v1" . to_string ( ) ,
846+ api_key_env : "" . to_string ( ) ,
847+ default_model : "x" . to_string ( ) ,
848+ } ,
849+ ) ]
850+ . into_iter ( )
851+ . collect ( ) ,
852+ router : rexos:: config:: RouterConfig :: default ( ) ,
853+ security : Default :: default ( ) ,
854+ } ;
855+ std:: fs:: write ( paths. config_path ( ) , toml:: to_string ( & cfg) . unwrap ( ) ) . unwrap ( ) ;
856+
857+ let report = run_doctor ( DoctorOptions {
858+ paths,
859+ timeout : Duration :: from_millis ( 200 ) ,
860+ } )
861+ . await
862+ . unwrap ( ) ;
863+
864+ let statuses: std:: collections:: BTreeMap < String , CheckStatus > = report
865+ . checks
866+ . iter ( )
867+ . map ( |c| ( c. id . clone ( ) , c. status ) )
868+ . collect ( ) ;
869+ assert_eq ! (
870+ statuses. get( "security.leaks.mode" ) ,
871+ Some ( & CheckStatus :: Warn )
872+ ) ;
873+ assert_eq ! (
874+ statuses. get( "security.egress.rules" ) ,
875+ Some ( & CheckStatus :: Warn )
876+ ) ;
877+ assert ! (
878+ report
879+ . next_actions
880+ . iter( )
881+ . any( |item| item. contains( "security.leaks" ) ) ,
882+ "expected leak-guard guidance, got: {:?}" ,
883+ report. next_actions
884+ ) ;
885+ assert ! (
886+ report
887+ . next_actions
888+ . iter( )
889+ . any( |item| item. contains( "security.egress" ) ) ,
890+ "expected egress guidance, got: {:?}" ,
891+ report. next_actions
892+ ) ;
893+ }
697894}
0 commit comments