Skip to main content

safe_shell_scanner/
config.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3
4/// Global config from ~/.config/safe-shell/config.toml
5#[derive(Debug, Clone, Deserialize, Default)]
6pub struct GlobalConfig {
7    pub shield: Option<ShieldConfig>,
8    // Global env/filesystem overrides (restrictive merge)
9    pub env: Option<EnvConfig>,
10    pub filesystem: Option<FilesystemConfig>,
11}
12
13#[derive(Debug, Clone, Deserialize, Default)]
14pub struct ShieldConfig {
15    pub aliases: Option<HashMap<String, ShieldAlias>>,
16}
17
18/// A shield alias — either a simple string or a detailed config.
19/// Simple:   `bun = "npm"`                    → sandbox all subcommands
20/// Detailed: `bun = { profile = "npm", subcommands = ["install", "run"] }`
21#[derive(Debug, Clone, Deserialize)]
22#[serde(untagged)]
23pub enum ShieldAlias {
24    Simple(String),
25    Detailed(ShieldAliasDetailed),
26}
27
28#[derive(Debug, Clone, Deserialize)]
29pub struct ShieldAliasDetailed {
30    pub profile: String,
31    pub subcommands: Option<Vec<String>>,
32}
33
34impl ShieldAlias {
35    pub fn profile(&self) -> &str {
36        match self {
37            ShieldAlias::Simple(p) => p,
38            ShieldAlias::Detailed(d) => &d.profile,
39        }
40    }
41
42    pub fn subcommands(&self) -> Option<&Vec<String>> {
43        match self {
44            ShieldAlias::Simple(_) => None, // None = sandbox all
45            ShieldAlias::Detailed(d) => d.subcommands.as_ref(),
46        }
47    }
48}
49
50impl GlobalConfig {
51    pub fn from_toml(text: &str) -> Result<Self, toml::de::Error> {
52        toml::from_str(text)
53    }
54}
55
56/// A custom profile entry from profiles.toml (dotted key format).
57/// ```toml
58/// [my-profile]
59/// description = "My custom profile"
60/// network.allow = ["example.com"]
61/// filesystem.deny_read = ["~/.aws"]
62/// env.scrub = ["*_KEY"]
63/// env.pass = ["PATH"]
64/// ```
65#[derive(Debug, Clone, Deserialize, Default)]
66pub struct CustomProfileEntry {
67    pub description: Option<String>,
68    pub network: Option<NetworkConfig>,
69    pub filesystem: Option<FilesystemConfig>,
70    pub env: Option<EnvConfig>,
71}
72
73impl CustomProfileEntry {
74    /// Convert to a standard Profile.
75    pub fn into_profile(self, name: &str) -> Profile {
76        Profile {
77            meta: Some(ProfileMeta {
78                name: Some(name.to_string()),
79                description: self.description,
80                profile: None,
81            }),
82            network: self.network,
83            filesystem: self.filesystem,
84            env: self.env,
85        }
86    }
87}
88
89/// Parse a profiles.toml file containing multiple custom profiles.
90pub fn parse_custom_profiles(
91    text: &str,
92) -> Result<HashMap<String, CustomProfileEntry>, toml::de::Error> {
93    toml::from_str(text)
94}
95
96#[derive(Debug, Clone, Deserialize, Default)]
97pub struct Profile {
98    pub meta: Option<ProfileMeta>,
99    pub network: Option<NetworkConfig>,
100    pub filesystem: Option<FilesystemConfig>,
101    pub env: Option<EnvConfig>,
102}
103
104#[derive(Debug, Clone, Deserialize, Default)]
105pub struct ProfileMeta {
106    pub name: Option<String>,
107    pub description: Option<String>,
108    pub profile: Option<String>,
109}
110
111#[derive(Debug, Clone, Deserialize, Default)]
112pub struct NetworkConfig {
113    #[serde(default)]
114    pub allow: Vec<String>,
115}
116
117#[derive(Debug, Clone, Deserialize, Default)]
118pub struct FilesystemConfig {
119    #[serde(default)]
120    pub allow_write: Vec<String>,
121    #[serde(default)]
122    pub deny_read: Vec<String>,
123}
124
125#[derive(Debug, Clone, Deserialize, Default)]
126pub struct EnvConfig {
127    #[serde(default)]
128    pub scrub: Vec<String>,
129    #[serde(default)]
130    pub pass: Vec<String>,
131}
132
133impl Profile {
134    /// Parse a profile from TOML text.
135    pub fn from_toml(text: &str) -> Result<Self, toml::de::Error> {
136        toml::from_str(text)
137    }
138
139    /// Merge another profile on top of this one (union semantics).
140    pub fn merge(&mut self, other: &Profile) {
141        if let Some(ref net) = other.network {
142            let self_net = self.network.get_or_insert_with(Default::default);
143            for domain in &net.allow {
144                if !self_net.allow.contains(domain) {
145                    self_net.allow.push(domain.clone());
146                }
147            }
148        }
149
150        if let Some(ref fs) = other.filesystem {
151            let self_fs = self.filesystem.get_or_insert_with(Default::default);
152            for path in &fs.allow_write {
153                if !self_fs.allow_write.contains(path) {
154                    self_fs.allow_write.push(path.clone());
155                }
156            }
157            for path in &fs.deny_read {
158                if !self_fs.deny_read.contains(path) {
159                    self_fs.deny_read.push(path.clone());
160                }
161            }
162        }
163
164        if let Some(ref env) = other.env {
165            let self_env = self.env.get_or_insert_with(Default::default);
166            for pat in &env.scrub {
167                if !self_env.scrub.contains(pat) {
168                    self_env.scrub.push(pat.clone());
169                }
170            }
171            for pat in &env.pass {
172                if !self_env.pass.contains(pat) {
173                    self_env.pass.push(pat.clone());
174                }
175            }
176        }
177    }
178
179    /// Merge another profile using restrictive-only semantics.
180    /// Can only ADD restrictions (more deny_read, more scrub patterns).
181    /// Cannot ADD permissions (network.allow, allow_write, env.pass are IGNORED).
182    /// Used for project configs (safe-shell.toml) and global configs to prevent
183    /// a malicious repo from relaxing the sandbox.
184    pub fn merge_restrictive(&mut self, other: &Profile) {
185        // network.allow — IGNORED (cannot add allowed domains)
186        // filesystem.allow_write — IGNORED (cannot add writable paths)
187
188        // filesystem.deny_read — can ADD more denied paths
189        if let Some(ref fs) = other.filesystem {
190            let self_fs = self.filesystem.get_or_insert_with(Default::default);
191            for path in &fs.deny_read {
192                if !self_fs.deny_read.contains(path) {
193                    self_fs.deny_read.push(path.clone());
194                }
195            }
196        }
197
198        // env.scrub — can ADD more scrub patterns
199        if let Some(ref env) = other.env {
200            let self_env = self.env.get_or_insert_with(Default::default);
201            for pat in &env.scrub {
202                if !self_env.scrub.contains(pat) {
203                    self_env.scrub.push(pat.clone());
204                }
205            }
206            // env.pass — IGNORED (cannot add passthrough patterns)
207        }
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    const NPM_TOML: &str = r#"
216[meta]
217name = "npm"
218description = "Node.js package manager"
219
220[network]
221allow = ["registry.npmjs.org", "*.npmjs.org", "github.com"]
222
223[filesystem]
224allow_write = ["./node_modules", "./package-lock.json", "/tmp"]
225deny_read = ["~/.aws", "~/.ssh"]
226
227[env]
228scrub = ["*_KEY", "*_SECRET", "*_TOKEN"]
229pass = ["PATH", "HOME", "NODE_ENV"]
230"#;
231
232    #[test]
233    fn parse_profile() {
234        let profile = Profile::from_toml(NPM_TOML).unwrap();
235        let meta = profile.meta.unwrap();
236        assert_eq!(meta.name.unwrap(), "npm");
237
238        let net = profile.network.unwrap();
239        assert_eq!(net.allow.len(), 3);
240        assert!(net.allow.contains(&"registry.npmjs.org".to_string()));
241
242        let fs = profile.filesystem.unwrap();
243        assert_eq!(fs.allow_write.len(), 3);
244        assert_eq!(fs.deny_read.len(), 2);
245
246        let env = profile.env.unwrap();
247        assert_eq!(env.scrub.len(), 3);
248        assert_eq!(env.pass.len(), 3);
249    }
250
251    #[test]
252    fn merge_union_semantics() {
253        let mut base = Profile::from_toml(NPM_TOML).unwrap();
254
255        let overlay = Profile::from_toml(
256            r#"
257[network]
258allow = ["custom.registry.com", "github.com"]
259
260[filesystem]
261deny_read = ["~/.gnupg"]
262
263[env]
264scrub = ["*_PASSWORD"]
265pass = ["CI"]
266"#,
267        )
268        .unwrap();
269
270        base.merge(&overlay);
271
272        let net = base.network.unwrap();
273        assert_eq!(net.allow.len(), 4); // 3 original + 1 new (github.com deduped)
274        assert!(net.allow.contains(&"custom.registry.com".to_string()));
275        assert!(net.allow.contains(&"github.com".to_string()));
276
277        let fs = base.filesystem.unwrap();
278        assert_eq!(fs.deny_read.len(), 3); // ~/.aws, ~/.ssh, ~/.gnupg
279
280        let env = base.env.unwrap();
281        assert_eq!(env.scrub.len(), 4); // 3 + *_PASSWORD
282        assert_eq!(env.pass.len(), 4); // 3 + CI
283    }
284
285    #[test]
286    fn merge_into_empty_profile() {
287        let mut base = Profile::default();
288        let overlay = Profile::from_toml(NPM_TOML).unwrap();
289        base.merge(&overlay);
290
291        let net = base.network.unwrap();
292        assert_eq!(net.allow.len(), 3);
293    }
294
295    #[test]
296    fn merge_with_empty_overlay() {
297        let mut base = Profile::from_toml(NPM_TOML).unwrap();
298        let overlay = Profile::default();
299        base.merge(&overlay);
300
301        let net = base.network.unwrap();
302        assert_eq!(net.allow.len(), 3); // unchanged
303    }
304
305    #[test]
306    fn merge_restrictive_blocks_network_allow() {
307        let mut base = Profile::from_toml(NPM_TOML).unwrap();
308
309        // Attacker tries to add evil.com via project config
310        let malicious = Profile::from_toml(
311            r#"
312[network]
313allow = ["evil.com"]
314"#,
315        )
316        .unwrap();
317
318        base.merge_restrictive(&malicious);
319
320        let net = base.network.unwrap();
321        assert_eq!(net.allow.len(), 3); // unchanged — evil.com NOT added
322        assert!(!net.allow.contains(&"evil.com".to_string()));
323    }
324
325    #[test]
326    fn merge_restrictive_blocks_env_pass() {
327        let mut base = Profile::from_toml(NPM_TOML).unwrap();
328
329        // Attacker tries to pass through secrets
330        let malicious = Profile::from_toml(
331            r#"
332[env]
333pass = ["*_SECRET", "AWS_*"]
334"#,
335        )
336        .unwrap();
337
338        base.merge_restrictive(&malicious);
339
340        let env = base.env.unwrap();
341        assert_eq!(env.pass.len(), 3); // unchanged — no new pass patterns
342        assert!(!env.pass.contains(&"*_SECRET".to_string()));
343    }
344
345    #[test]
346    fn merge_restrictive_blocks_allow_write() {
347        let mut base = Profile::from_toml(NPM_TOML).unwrap();
348
349        // Attacker tries to make ~/.ssh writable
350        let malicious = Profile::from_toml(
351            r#"
352[filesystem]
353allow_write = ["~/.ssh", "/etc"]
354"#,
355        )
356        .unwrap();
357
358        base.merge_restrictive(&malicious);
359
360        let fs = base.filesystem.unwrap();
361        assert_eq!(fs.allow_write.len(), 3); // unchanged
362        assert!(!fs.allow_write.contains(&"~/.ssh".to_string()));
363    }
364
365    #[test]
366    fn merge_restrictive_allows_adding_deny_read() {
367        let mut base = Profile::from_toml(NPM_TOML).unwrap();
368
369        // Project adds extra denied paths — this IS allowed (more restrictive)
370        let extra_deny = Profile::from_toml(
371            r#"
372[filesystem]
373deny_read = ["~/.config/company-secrets"]
374"#,
375        )
376        .unwrap();
377
378        base.merge_restrictive(&extra_deny);
379
380        let fs = base.filesystem.unwrap();
381        assert_eq!(fs.deny_read.len(), 3); // original 2 + 1 new
382        assert!(fs
383            .deny_read
384            .contains(&"~/.config/company-secrets".to_string()));
385    }
386
387    #[test]
388    fn merge_restrictive_allows_adding_scrub() {
389        let mut base = Profile::from_toml(NPM_TOML).unwrap();
390
391        // Project adds extra scrub patterns — this IS allowed (more restrictive)
392        let extra_scrub = Profile::from_toml(
393            r#"
394[env]
395scrub = ["COMPANY_*"]
396"#,
397        )
398        .unwrap();
399
400        base.merge_restrictive(&extra_scrub);
401
402        let env = base.env.unwrap();
403        assert_eq!(env.scrub.len(), 4); // original 3 + 1 new
404        assert!(env.scrub.contains(&"COMPANY_*".to_string()));
405    }
406
407    #[test]
408    fn parse_minimal_profile() {
409        let minimal = r#"
410[meta]
411name = "minimal"
412description = "Maximum isolation"
413
414[network]
415allow = []
416
417[filesystem]
418allow_write = [".", "/tmp"]
419deny_read = ["~/.aws", "~/.ssh", "~/.gnupg"]
420
421[env]
422scrub = ["*_KEY", "*_SECRET"]
423pass = ["PATH", "HOME"]
424"#;
425        let profile = Profile::from_toml(minimal).unwrap();
426        let net = profile.network.unwrap();
427        assert!(net.allow.is_empty());
428    }
429}