Skip to content

Commit b6624ff

Browse files
committed
fix(skills): address PR 3219 review — case-insensitive deny, CLI policy, runtime warn
- resolve_effective_passthrough lowercases both pattern and name before glob match, closing a bypass where 'aws_secret_access_key' would slip past the default 'AWS_*' deny pattern (Windows env vars are case-insensitive at the OS level). New unit test asserts mixed-case manifest entries are blocked. - librefang skill test now loads [skills] from ~/.librefang/config.toml and applies the same EnvPassthroughPolicy the daemon does, falling back to SkillsConfig::default() when no config exists. Dev runs see the same gate prod will apply instead of silently passing every declared var. - registry.load_skill emits a tracing::warn when env_passthrough is non-empty for Builtin / PromptOnly / Wasm runtimes, since the field only flows to subprocess-spawning runtimes (Python / Node / Shell) and was previously inert without feedback. - Drop stale 'Returns None' sentence on EnvPassthroughPolicy::from_skills_config doc; the Optional-ness lives on KernelHandle::skill_env_passthrough_policy.
1 parent 9dcdc1c commit b6624ff

4 files changed

Lines changed: 82 additions & 8 deletions

File tree

crates/librefang-cli/src/main.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1895,6 +1895,27 @@ fn load_update_channel_from_config() -> Option<librefang_types::config::UpdateCh
18951895
.ok()
18961896
}
18971897

1898+
/// Load the `[skills]` config block and derive the `EnvPassthroughPolicy`
1899+
/// the daemon would apply. Falls back to `SkillsConfig::default()` so the
1900+
/// conservative built-in deny patterns still apply when no config exists —
1901+
/// otherwise `librefang skill test` would silently allow vars that
1902+
/// production strips. Errors during read/parse degrade to default; this is
1903+
/// a dev-time gate, not a security boundary, but its job is to mirror
1904+
/// what prod will do.
1905+
fn load_skill_env_policy_from_config() -> librefang_types::config::EnvPassthroughPolicy {
1906+
let cfg = (|| -> Option<librefang_types::config::SkillsConfig> {
1907+
let config_path = dirs::home_dir()?.join(".librefang").join("config.toml");
1908+
let content = std::fs::read_to_string(&config_path).ok()?;
1909+
let value: toml::Value = toml::from_str(&content).ok()?;
1910+
let skills = value.get("skills")?.clone();
1911+
skills
1912+
.try_into::<librefang_types::config::SkillsConfig>()
1913+
.ok()
1914+
})()
1915+
.unwrap_or_default();
1916+
librefang_types::config::EnvPassthroughPolicy::from_skills_config(&cfg)
1917+
}
1918+
18981919
/// Load just the `log_dir` field from config.toml without fully deserializing.
18991920
/// Returns the configured custom log directory, or `None` to use the default.
19001921
fn load_log_dir_from_config() -> Option<PathBuf> {
@@ -6917,12 +6938,13 @@ fn cmd_skill_test(path: Option<PathBuf>, tool: Option<String>, input: Option<Str
69176938
};
69186939

69196940
let rt = tokio::runtime::Runtime::new().unwrap();
6941+
let env_policy = load_skill_env_policy_from_config();
69206942
let result = rt.block_on(librefang_skills::loader::execute_skill_tool(
69216943
&prepared.manifest,
69226944
&prepared.source_dir,
69236945
&tool_name,
69246946
&input_json,
6925-
None,
6947+
Some(&env_policy),
69266948
));
69276949
match result {
69286950
Ok(result) => {

crates/librefang-skills/src/loader.rs

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,18 @@ pub fn resolve_effective_passthrough(
120120
);
121121
return false;
122122
}
123-
let blocked_by_deny = denied
124-
.iter()
125-
.any(|pattern| librefang_types::capability::glob_matches(pattern, name));
123+
// Match deny patterns case-insensitively. `glob_matches` itself
124+
// is case-sensitive, but env-var names are conventionally
125+
// upper-case and case-insensitive on Windows; lowercasing both
126+
// sides closes a bypass where `aws_secret_access_key` would slip
127+
// past the default `AWS_*` deny pattern.
128+
let name_lower = name.to_ascii_lowercase();
129+
let blocked_by_deny = denied.iter().any(|pattern| {
130+
librefang_types::capability::glob_matches(
131+
&pattern.to_ascii_lowercase(),
132+
&name_lower,
133+
)
134+
});
126135
if blocked_by_deny {
127136
let allowed_by_override = overrides
128137
.map(|v| v.iter().any(|n| name_matches(n, name)))
@@ -703,6 +712,26 @@ mod tests {
703712
assert_eq!(resolved, vec!["GOG_KEYRING_PASSWORD".to_string()]);
704713
}
705714

715+
#[test]
716+
fn test_resolve_passthrough_deny_pattern_is_case_insensitive() {
717+
// Default deny includes `AWS_*` and `*_KEY`. Lowercase requests must
718+
// still be blocked — Windows env-var names are case-insensitive at
719+
// the OS level, so `aws_secret_access_key` resolves to the same
720+
// value as `AWS_SECRET_ACCESS_KEY`.
721+
let policy = EnvPassthroughPolicy {
722+
denied_patterns: vec!["AWS_*".to_string(), "*_KEY".to_string()],
723+
per_skill_overrides: std::collections::HashMap::new(),
724+
};
725+
let manifest = vec![
726+
"aws_secret_access_key".to_string(),
727+
"openai_api_key".to_string(),
728+
"Aws_Region".to_string(),
729+
"GOG_KEYRING_PASSWORD".to_string(),
730+
];
731+
let resolved = resolve_effective_passthrough(&manifest, "any-skill", Some(&policy));
732+
assert_eq!(resolved, vec!["GOG_KEYRING_PASSWORD".to_string()]);
733+
}
734+
706735
#[test]
707736
fn test_resolve_passthrough_per_skill_override_unblocks() {
708737
let mut overrides = std::collections::HashMap::new();

crates/librefang-skills/src/registry.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,27 @@ impl SkillRegistry {
286286
}
287287
}
288288

289+
// env_passthrough only flows to subprocess-spawning runtimes
290+
// (Python / Node / Shell). For other runtimes the field is silently
291+
// inert; warn so authors don't think they've granted access.
292+
if !manifest.env_passthrough.is_empty()
293+
&& !matches!(
294+
manifest.runtime.runtime_type,
295+
crate::SkillRuntime::Python
296+
| crate::SkillRuntime::Node
297+
| crate::SkillRuntime::Shell
298+
)
299+
{
300+
warn!(
301+
skill = %manifest.skill.name,
302+
runtime = ?manifest.runtime.runtime_type,
303+
vars = ?manifest.env_passthrough,
304+
"skill declares env_passthrough but runtime does not spawn a \
305+
subprocess; field will be ignored. Move credentials to \
306+
[skill.config] or remove env_passthrough"
307+
);
308+
}
309+
289310
let name = manifest.skill.name.clone();
290311

291312
// Canonicalize the skill directory path so entry-point resolution

crates/librefang-types/src/config/types.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,10 +1269,12 @@ pub struct EnvPassthroughPolicy {
12691269
}
12701270

12711271
impl EnvPassthroughPolicy {
1272-
/// Construct a policy from a `[skills]` config block. Returns `None`
1273-
/// when the operator has explicitly disabled the deny check (empty
1274-
/// patterns *and* empty overrides) — callers can skip the resolve step
1275-
/// in that case, since only the built-in hard blocks remain.
1272+
/// Construct a policy from a `[skills]` config block. Always returns a
1273+
/// populated `Self`; callers that want to skip applying the operator
1274+
/// gate entirely (e.g. an operator with empty deny patterns and no
1275+
/// per-skill overrides) should drop down to passing `None` for the
1276+
/// `env_policy` argument instead — that's `KernelHandle::skill_env_passthrough_policy`'s
1277+
/// job, not this constructor's.
12761278
pub fn from_skills_config(cfg: &SkillsConfig) -> Self {
12771279
Self {
12781280
denied_patterns: cfg.env_passthrough_denied_patterns.clone(),

0 commit comments

Comments
 (0)