Skip to content

Commit 2b39558

Browse files
committed
feat: harden loopforge security posture
1 parent 8c6a8e6 commit 2b39558

62 files changed

Lines changed: 2557 additions & 67 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ All notable user-visible changes are documented in this file.
44

55
## [Unreleased]
66

7+
## [1.3.0] - 2026-03-07
8+
9+
### Added
10+
11+
- Security hardening baseline for runtime and CLI:
12+
- `security.secrets` config for explicit host-env secret resolution
13+
- `security.leaks` modes (`off`, `warn`, `redact`, `enforce`) with runtime leak-guarded tool output
14+
- `security.egress.rules` allowlists enforced for `web_fetch`, A2A requests, and browser navigation entrypoints
15+
- Tool audit records now carry leak-guard metadata without persisting matched secret values.
16+
- Internal operator note `docs/internal/loopforge-network-security.md` documenting trust boundaries and rollout expectations.
17+
18+
### Changed
19+
20+
- `loopforge doctor` now reports security posture for secret resolution, leak-guard mode, and outbound allowlist coverage.
21+
- Public security/config/CLI docs (EN + zh-CN) now document the security model and safe configuration examples.
22+
723
## [1.2.0] - 2026-03-07
824

925
### Added

Cargo.lock

Lines changed: 11 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ members = [
1414
]
1515

1616
[workspace.package]
17-
version = "1.2.0"
17+
version = "1.3.0"
1818
edition = "2021"
1919
license = "MIT"
2020
rust-version = "1.75"

crates/loopforge-cli/src/doctor.rs

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)