Skip to content

Commit 766719f

Browse files
committed
fix: stabilize leak guard env snapshot
1 parent dcab5a3 commit 766719f

3 files changed

Lines changed: 163 additions & 103 deletions

File tree

crates/rexos-runtime/src/leak_guard.rs

Lines changed: 144 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ use rexos_kernel::security::{LeakMode, SecurityConfig};
44

55
const BLOCKED_ERROR: &str = "tool output blocked by leak guard";
66

7+
#[derive(Debug, Clone, PartialEq, Eq)]
8+
struct SensitiveEnvValue {
9+
detector: String,
10+
value: String,
11+
}
12+
13+
#[derive(Debug, Clone)]
14+
pub(crate) struct LeakGuard {
15+
mode: LeakMode,
16+
sensitive_env_values: Vec<SensitiveEnvValue>,
17+
}
18+
719
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
820
pub(crate) struct LeakGuardAudit {
921
pub(crate) mode: String,
@@ -34,60 +46,92 @@ struct LeakMatch {
3446
detector: String,
3547
}
3648

37-
pub(crate) fn inspect_tool_output(content: String, security: &SecurityConfig) -> LeakGuardVerdict {
38-
if matches!(security.leaks.mode, LeakMode::Off) {
39-
return LeakGuardVerdict::Allowed {
40-
content,
41-
audit: None,
42-
};
49+
impl LeakGuard {
50+
pub(crate) fn from_security(security: &SecurityConfig) -> Self {
51+
Self {
52+
mode: security.leaks.mode.clone(),
53+
sensitive_env_values: collect_sensitive_env_values(),
54+
}
4355
}
4456

45-
let matches = collect_matches(&content);
46-
if matches.is_empty() {
47-
return LeakGuardVerdict::Allowed {
48-
content,
49-
audit: None,
50-
};
51-
}
57+
pub(crate) fn inspect_tool_output(&self, content: String) -> LeakGuardVerdict {
58+
if matches!(self.mode, LeakMode::Off) {
59+
return LeakGuardVerdict::Allowed {
60+
content,
61+
audit: None,
62+
};
63+
}
64+
65+
let matches = collect_matches(&content, &self.sensitive_env_values);
66+
if matches.is_empty() {
67+
return LeakGuardVerdict::Allowed {
68+
content,
69+
audit: None,
70+
};
71+
}
72+
73+
let detectors = detector_labels(&matches);
74+
let mode = mode_label(&self.mode).to_string();
5275

53-
let detectors = detector_labels(&matches);
54-
let mode = mode_label(&security.leaks.mode).to_string();
55-
56-
match security.leaks.mode {
57-
LeakMode::Off => LeakGuardVerdict::Allowed {
58-
content,
59-
audit: None,
60-
},
61-
LeakMode::Warn => LeakGuardVerdict::Allowed {
62-
content,
63-
audit: Some(LeakGuardAudit {
64-
mode,
65-
detectors,
66-
redacted: false,
67-
blocked: false,
68-
}),
69-
},
70-
LeakMode::Redact => LeakGuardVerdict::Allowed {
71-
content: redact_content(&content, &matches),
72-
audit: Some(LeakGuardAudit {
73-
mode,
74-
detectors,
75-
redacted: true,
76-
blocked: false,
77-
}),
78-
},
79-
LeakMode::Enforce => LeakGuardVerdict::Blocked {
80-
error: BLOCKED_ERROR.to_string(),
81-
audit: LeakGuardAudit {
82-
mode,
83-
detectors,
84-
redacted: false,
85-
blocked: true,
76+
match self.mode {
77+
LeakMode::Off => LeakGuardVerdict::Allowed {
78+
content,
79+
audit: None,
80+
},
81+
LeakMode::Warn => LeakGuardVerdict::Allowed {
82+
content,
83+
audit: Some(LeakGuardAudit {
84+
mode,
85+
detectors,
86+
redacted: false,
87+
blocked: false,
88+
}),
89+
},
90+
LeakMode::Redact => LeakGuardVerdict::Allowed {
91+
content: redact_content(&content, &matches),
92+
audit: Some(LeakGuardAudit {
93+
mode,
94+
detectors,
95+
redacted: true,
96+
blocked: false,
97+
}),
8698
},
87-
},
99+
LeakMode::Enforce => LeakGuardVerdict::Blocked {
100+
error: BLOCKED_ERROR.to_string(),
101+
audit: LeakGuardAudit {
102+
mode,
103+
detectors,
104+
redacted: false,
105+
blocked: true,
106+
},
107+
},
108+
}
88109
}
89110
}
90111

112+
fn collect_sensitive_env_values() -> Vec<SensitiveEnvValue> {
113+
let mut out = Vec::new();
114+
let mut seen_values = BTreeSet::new();
115+
116+
for (name, value) in std::env::vars() {
117+
if !is_sensitive_env_name(&name) {
118+
continue;
119+
}
120+
if value.trim().len() < 8 || value.contains(char::is_whitespace) {
121+
continue;
122+
}
123+
if !seen_values.insert(value.clone()) {
124+
continue;
125+
}
126+
out.push(SensitiveEnvValue {
127+
detector: format!("env:{name}"),
128+
value,
129+
});
130+
}
131+
132+
out
133+
}
134+
91135
fn detector_labels(matches: &[LeakMatch]) -> Vec<String> {
92136
let mut out = BTreeSet::new();
93137
for item in matches {
@@ -96,9 +140,9 @@ fn detector_labels(matches: &[LeakMatch]) -> Vec<String> {
96140
out.into_iter().collect()
97141
}
98142

99-
fn collect_matches(content: &str) -> Vec<LeakMatch> {
143+
fn collect_matches(content: &str, sensitive_env_values: &[SensitiveEnvValue]) -> Vec<LeakMatch> {
100144
let mut out = Vec::new();
101-
out.extend(find_sensitive_env_matches(content));
145+
out.extend(find_sensitive_env_matches(content, sensitive_env_values));
102146
out.extend(find_prefixed_token_matches(content, "sk-", 20, "token:sk"));
103147
out.extend(find_prefixed_token_matches(
104148
content,
@@ -121,24 +165,14 @@ fn collect_matches(content: &str) -> Vec<LeakMatch> {
121165
merge_matches(out)
122166
}
123167

124-
fn find_sensitive_env_matches(content: &str) -> Vec<LeakMatch> {
168+
fn find_sensitive_env_matches(
169+
content: &str,
170+
sensitive_env_values: &[SensitiveEnvValue],
171+
) -> Vec<LeakMatch> {
125172
let mut matches = Vec::new();
126-
let mut seen_values = BTreeSet::new();
127-
128-
for (name, value) in std::env::vars() {
129-
if !is_sensitive_env_name(&name) {
130-
continue;
131-
}
132-
if value.trim().len() < 8 || value.contains(char::is_whitespace) {
133-
continue;
134-
}
135-
if !seen_values.insert(value.clone()) {
136-
continue;
137-
}
138-
139-
matches.extend(find_exact_matches(content, &value, format!("env:{name}")));
173+
for item in sensitive_env_values {
174+
matches.extend(find_exact_matches(content, &item.value, item.detector.clone()));
140175
}
141-
142176
matches
143177
}
144178

@@ -259,7 +293,7 @@ fn mode_label(mode: &LeakMode) -> &'static str {
259293

260294
#[cfg(test)]
261295
mod tests {
262-
use super::{inspect_tool_output, LeakGuardVerdict};
296+
use super::{LeakGuard, LeakGuardVerdict};
263297
use rexos_kernel::security::{LeakMode, SecurityConfig};
264298

265299
struct EnvVarGuard {
@@ -285,31 +319,32 @@ mod tests {
285319
}
286320
}
287321

288-
fn security(mode: LeakMode) -> SecurityConfig {
322+
fn leak_guard(mode: LeakMode) -> LeakGuard {
289323
let mut cfg = SecurityConfig::default();
290324
cfg.leaks.mode = mode;
291-
cfg
325+
LeakGuard::from_security(&cfg)
292326
}
293327

294328
#[test]
295329
fn warn_mode_reports_detected_env_secret_without_mutating_output() {
296-
let _guard = EnvVarGuard::set("LOOPFORGE_TEST_SECRET", "super-secret-value-12345");
297-
let verdict = inspect_tool_output(
298-
"value=super-secret-value-12345".to_string(),
299-
&security(LeakMode::Warn),
330+
let _guard = EnvVarGuard::set(
331+
"LOOPFORGE_TEST_SECRET_WARN",
332+
"super-secret-warn-value-12345",
300333
);
334+
let verdict = leak_guard(LeakMode::Warn)
335+
.inspect_tool_output("value=super-secret-warn-value-12345".to_string());
301336

302337
match verdict {
303338
LeakGuardVerdict::Allowed {
304339
content,
305340
audit: Some(audit),
306341
} => {
307-
assert_eq!(content, "value=super-secret-value-12345");
342+
assert_eq!(content, "value=super-secret-warn-value-12345");
308343
assert_eq!(audit.mode, "warn");
309344
assert!(audit
310345
.detectors
311346
.iter()
312-
.any(|d| d == "env:LOOPFORGE_TEST_SECRET"));
347+
.any(|d| d == "env:LOOPFORGE_TEST_SECRET_WARN"));
313348
assert!(!audit.redacted);
314349
assert!(!audit.blocked);
315350
}
@@ -319,20 +354,21 @@ mod tests {
319354

320355
#[test]
321356
fn redact_mode_masks_detected_secret_before_returning_content() {
322-
let _guard = EnvVarGuard::set("LOOPFORGE_TEST_SECRET", "super-secret-value-12345");
323-
let verdict = inspect_tool_output(
324-
"prefix super-secret-value-12345 suffix".to_string(),
325-
&security(LeakMode::Redact),
357+
let _guard = EnvVarGuard::set(
358+
"LOOPFORGE_TEST_SECRET_REDACT",
359+
"super-secret-redact-value-12345",
326360
);
361+
let verdict = leak_guard(LeakMode::Redact)
362+
.inspect_tool_output("prefix super-secret-redact-value-12345 suffix".to_string());
327363

328364
match verdict {
329365
LeakGuardVerdict::Allowed {
330366
content,
331367
audit: Some(audit),
332368
} => {
333-
assert!(!content.contains("super-secret-value-12345"), "{content}");
369+
assert!(!content.contains("super-secret-redact-value-12345"), "{content}");
334370
assert!(
335-
content.contains("[redacted:env:LOOPFORGE_TEST_SECRET]"),
371+
content.contains("[redacted:env:LOOPFORGE_TEST_SECRET_REDACT]"),
336372
"{content}"
337373
);
338374
assert_eq!(audit.mode, "redact");
@@ -344,11 +380,12 @@ mod tests {
344380

345381
#[test]
346382
fn enforce_mode_blocks_detected_secret_with_stable_error() {
347-
let _guard = EnvVarGuard::set("LOOPFORGE_TEST_SECRET", "super-secret-value-12345");
348-
let verdict = inspect_tool_output(
349-
"prefix super-secret-value-12345 suffix".to_string(),
350-
&security(LeakMode::Enforce),
383+
let _guard = EnvVarGuard::set(
384+
"LOOPFORGE_TEST_SECRET_ENFORCE",
385+
"super-secret-enforce-value-12345",
351386
);
387+
let verdict = leak_guard(LeakMode::Enforce)
388+
.inspect_tool_output("prefix super-secret-enforce-value-12345 suffix".to_string());
352389

353390
match verdict {
354391
LeakGuardVerdict::Blocked { error, audit } => {
@@ -358,18 +395,36 @@ mod tests {
358395
assert!(audit
359396
.detectors
360397
.iter()
361-
.any(|d| d == "env:LOOPFORGE_TEST_SECRET"));
398+
.any(|d| d == "env:LOOPFORGE_TEST_SECRET_ENFORCE"));
362399
}
363400
other => panic!("expected enforce verdict, got: {other:?}"),
364401
}
365402
}
366403

367404
#[test]
368-
fn redact_mode_masks_common_sk_prefixed_tokens() {
369-
let verdict = inspect_tool_output(
370-
"token=sk-test-abcdefghijklmnopqrstuvwxyz123456".to_string(),
371-
&security(LeakMode::Redact),
405+
fn leak_guard_snapshots_env_values_at_construction() {
406+
let _guard = EnvVarGuard::set(
407+
"LOOPFORGE_TEST_SECRET_SNAPSHOT",
408+
"super-secret-snapshot-value-12345",
372409
);
410+
let leak_guard = leak_guard(LeakMode::Redact);
411+
std::env::remove_var("LOOPFORGE_TEST_SECRET_SNAPSHOT");
412+
413+
let verdict = leak_guard
414+
.inspect_tool_output("prefix super-secret-snapshot-value-12345 suffix".to_string());
415+
416+
match verdict {
417+
LeakGuardVerdict::Allowed { content, .. } => {
418+
assert!(content.contains("[redacted:env:LOOPFORGE_TEST_SECRET_SNAPSHOT]"));
419+
}
420+
other => panic!("expected snapshot redact verdict, got: {other:?}"),
421+
}
422+
}
423+
424+
#[test]
425+
fn redact_mode_masks_common_sk_prefixed_tokens() {
426+
let verdict = leak_guard(LeakMode::Redact)
427+
.inspect_tool_output("token=sk-test-abcdefghijklmnopqrstuvwxyz123456".to_string());
373428

374429
match verdict {
375430
LeakGuardVerdict::Allowed {

crates/rexos-runtime/src/lib.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ use approval::{
4141
skill_approval_is_granted, skill_permissions_are_readonly, tool_approval_is_granted,
4242
tool_requires_approval, ApprovalMode,
4343
};
44-
use leak_guard::{inspect_tool_output, LeakGuardAudit, LeakGuardVerdict};
44+
use leak_guard::{LeakGuard, LeakGuardAudit, LeakGuardVerdict};
4545
pub use records::{AcpDeliveryCheckpointRecord, AcpEventRecord, SessionSkillPolicy};
4646
use records::{
4747
AgentFindToolArgs, AgentKillToolArgs, AgentRecord, AgentSendToolArgs, AgentSpawnToolArgs,
@@ -100,6 +100,7 @@ pub struct AgentRuntime {
100100
llms: LlmRegistry,
101101
router: ModelRouter,
102102
security: SecurityConfig,
103+
leak_guard: LeakGuard,
103104
}
104105

105106
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -296,11 +297,13 @@ impl AgentRuntime {
296297
router: ModelRouter,
297298
security: SecurityConfig,
298299
) -> Self {
300+
let leak_guard = LeakGuard::from_security(&security);
299301
Self {
300302
memory,
301303
llms,
302304
router,
303305
security,
306+
leak_guard,
304307
}
305308
}
306309

@@ -625,7 +628,7 @@ impl AgentRuntime {
625628

626629
let duration_ms = started_at.elapsed().as_millis() as u64;
627630
let (output, leak_guard) = match output_result {
628-
Ok(output) => match inspect_tool_output(output, &self.security) {
631+
Ok(output) => match self.leak_guard.inspect_tool_output(output) {
629632
LeakGuardVerdict::Allowed { content, audit } => (content, audit),
630633
LeakGuardVerdict::Blocked { error, audit } => {
631634
let _ = self.append_acp_event(AcpEventRecord {
@@ -657,7 +660,7 @@ impl AgentRuntime {
657660
Err(e) => {
658661
let err_text = e.to_string();
659662
let (safe_error, leak_guard) =
660-
match inspect_tool_output(err_text, &self.security) {
663+
match self.leak_guard.inspect_tool_output(err_text) {
661664
LeakGuardVerdict::Allowed { content, audit } => (content, audit),
662665
LeakGuardVerdict::Blocked { error, audit } => (error, Some(audit)),
663666
};

0 commit comments

Comments
 (0)