Skip to main content

molo_coding/coding/
policy.rs

1use crate::harness::{
2    ClassifiedEffect, DefaultPolicyEngine, HarnessError, PolicyDecision, PolicyEngine,
3};
4use crate::{EffectKind, RiskLevel, RunContext, RunMetadata};
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7
8use super::command::{CommandRequest, PtyMode};
9use super::payload::{CommandPayload, ListFilesPayload};
10
11/// Coding-specific operation class used by conservative policy presets.
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
13#[non_exhaustive]
14pub enum CodingPolicyClass {
15    /// Read a workspace file.
16    ReadWorkspace,
17    /// List workspace files.
18    ListWorkspace,
19    /// Search workspace content.
20    SearchWorkspace,
21    /// Write a workspace file.
22    WriteWorkspace,
23    /// Apply a workspace patch.
24    ApplyPatch,
25    /// Read git state.
26    GitRead,
27    /// Mutate git state.
28    GitMutation,
29    /// Destructively mutate git state.
30    GitDestructiveMutation,
31    /// Host-allowlisted test command.
32    TestCommand,
33    /// Host-allowlisted build command.
34    BuildCommand,
35    /// Host-allowlisted lint command.
36    LintCommand,
37    /// Package manager install/update command.
38    PackageInstall,
39    /// Command that can perform network I/O.
40    NetworkCommand,
41    /// Shell command such as `sh -c` or `bash -lc`.
42    ShellCommand,
43    /// Command requesting a PTY.
44    PtyCommand,
45    /// Destructive command.
46    DestructiveCommand,
47    /// Command that does not match a safer known class.
48    UnknownCommand,
49    /// Trusted MCP operation.
50    McpTrusted,
51    /// Untrusted MCP operation.
52    McpUntrusted,
53}
54
55/// Prefix pattern used by [`CommandTaxonomy`] allowlists.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct CommandPattern {
58    /// Argument prefix that must match from `argv[0]`.
59    pub argv_prefix: Vec<String>,
60}
61
62impl CommandPattern {
63    /// Constructs an argument-prefix pattern.
64    pub fn new<I, S>(argv_prefix: I) -> Self
65    where
66        I: IntoIterator<Item = S>,
67        S: Into<String>,
68    {
69        Self {
70            argv_prefix: argv_prefix.into_iter().map(Into::into).collect(),
71        }
72    }
73
74    fn matches(&self, argv: &[String]) -> bool {
75        !self.argv_prefix.is_empty()
76            && argv.len() >= self.argv_prefix.len()
77            && self
78                .argv_prefix
79                .iter()
80                .zip(argv)
81                .all(|(expected, actual)| expected == actual)
82    }
83}
84
85/// Command taxonomy and host-provided allowlists for coding policy.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(default)]
88#[non_exhaustive]
89pub struct CommandTaxonomy {
90    /// Test command prefixes allowed without approval.
91    pub allowed_test_commands: Vec<CommandPattern>,
92    /// Build command prefixes allowed without approval.
93    pub allowed_build_commands: Vec<CommandPattern>,
94    /// Lint command prefixes allowed without approval.
95    pub allowed_lint_commands: Vec<CommandPattern>,
96    /// Program names treated as network-capable.
97    pub network_programs: Vec<String>,
98    /// Program names treated as shells.
99    pub shell_programs: Vec<String>,
100    /// Program names treated as package managers.
101    pub package_managers: Vec<String>,
102    /// Lowercase command fragments treated as destructive.
103    pub destructive_fragments: Vec<String>,
104}
105
106impl Default for CommandTaxonomy {
107    fn default() -> Self {
108        Self {
109            allowed_test_commands: Vec::new(),
110            allowed_build_commands: Vec::new(),
111            allowed_lint_commands: Vec::new(),
112            network_programs: [
113                "curl", "wget", "ssh", "scp", "sftp", "rsync", "nc", "netcat",
114            ]
115            .into_iter()
116            .map(str::to_string)
117            .collect(),
118            shell_programs: ["sh", "bash", "zsh", "fish", "cmd", "powershell", "pwsh"]
119                .into_iter()
120                .map(str::to_string)
121                .collect(),
122            package_managers: [
123                "npm", "pnpm", "yarn", "cargo", "pip", "pip3", "uv", "poetry", "brew", "apt",
124                "apt-get",
125            ]
126            .into_iter()
127            .map(str::to_string)
128            .collect(),
129            destructive_fragments: [
130                "rm -rf",
131                "git reset --hard",
132                "git clean -fd",
133                "push --force",
134                "push -f",
135                "sudo ",
136                "mkfs",
137                "dd if=",
138            ]
139            .into_iter()
140            .map(str::to_string)
141            .collect(),
142        }
143    }
144}
145
146impl CommandTaxonomy {
147    /// Constructs the conservative taxonomy with no test/build/lint allowlist.
148    pub fn conservative() -> Self {
149        Self::default()
150    }
151
152    /// Adds a test command allowlist prefix.
153    pub fn with_allowed_test_command<I, S>(mut self, argv_prefix: I) -> Self
154    where
155        I: IntoIterator<Item = S>,
156        S: Into<String>,
157    {
158        self.allowed_test_commands
159            .push(CommandPattern::new(argv_prefix));
160        self
161    }
162
163    /// Adds a build command allowlist prefix.
164    pub fn with_allowed_build_command<I, S>(mut self, argv_prefix: I) -> Self
165    where
166        I: IntoIterator<Item = S>,
167        S: Into<String>,
168    {
169        self.allowed_build_commands
170            .push(CommandPattern::new(argv_prefix));
171        self
172    }
173
174    /// Adds a lint command allowlist prefix.
175    pub fn with_allowed_lint_command<I, S>(mut self, argv_prefix: I) -> Self
176    where
177        I: IntoIterator<Item = S>,
178        S: Into<String>,
179    {
180        self.allowed_lint_commands
181            .push(CommandPattern::new(argv_prefix));
182        self
183    }
184
185    /// Classifies one command request.
186    pub fn classify_command(&self, request: &CommandRequest) -> Vec<CodingPolicyClass> {
187        let mut classes = Vec::new();
188        if request.pty != PtyMode::Disabled {
189            classes.push(CodingPolicyClass::PtyCommand);
190        }
191        let Some(program) = request.argv.first() else {
192            classes.push(CodingPolicyClass::UnknownCommand);
193            return classes;
194        };
195        let program = program.to_ascii_lowercase();
196        let lowered = request.argv.join(" ").to_ascii_lowercase();
197
198        if self
199            .destructive_fragments
200            .iter()
201            .any(|fragment| lowered.contains(fragment))
202        {
203            if program == "git" {
204                classes.push(CodingPolicyClass::GitDestructiveMutation);
205            }
206            classes.push(CodingPolicyClass::DestructiveCommand);
207        }
208        if self.shell_programs.iter().any(|shell| shell == &program) {
209            classes.push(CodingPolicyClass::ShellCommand);
210        }
211        if self
212            .network_programs
213            .iter()
214            .any(|network_program| network_program == &program)
215        {
216            classes.push(CodingPolicyClass::NetworkCommand);
217        }
218        if self.is_package_install(&program, &request.argv) {
219            classes.push(CodingPolicyClass::PackageInstall);
220        }
221        if program == "git" {
222            if is_git_read_only(&request.argv) {
223                classes.push(CodingPolicyClass::GitRead);
224            } else if !classes.contains(&CodingPolicyClass::GitDestructiveMutation) {
225                classes.push(CodingPolicyClass::GitMutation);
226            }
227        }
228        if matches_any(&self.allowed_test_commands, &request.argv) {
229            classes.push(CodingPolicyClass::TestCommand);
230        }
231        if matches_any(&self.allowed_build_commands, &request.argv) {
232            classes.push(CodingPolicyClass::BuildCommand);
233        }
234        if matches_any(&self.allowed_lint_commands, &request.argv) {
235            classes.push(CodingPolicyClass::LintCommand);
236        }
237        if classes.is_empty() {
238            classes.push(CodingPolicyClass::UnknownCommand);
239        }
240        classes.sort();
241        classes.dedup();
242        classes
243    }
244
245    fn is_package_install(&self, program: &str, argv: &[String]) -> bool {
246        if !self
247            .package_managers
248            .iter()
249            .any(|manager| manager == program)
250        {
251            return false;
252        }
253        argv.iter().skip(1).any(|arg| {
254            matches!(
255                arg.as_str(),
256                "install" | "add" | "update" | "upgrade" | "sync"
257            )
258        })
259    }
260}
261
262/// Typed input produced for coding policy evaluation.
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct CodingPolicyInput {
265    /// Effect id.
266    pub effect_id: String,
267    /// Effect kind.
268    pub kind: EffectKind,
269    /// Coding policy classes.
270    pub classes: Vec<CodingPolicyClass>,
271    /// Command request, for command effects.
272    pub command: Option<CommandRequest>,
273    /// Host-owned metadata.
274    pub metadata: RunMetadata,
275}
276
277impl CodingPolicyInput {
278    /// Builds policy input from a classified effect.
279    pub fn from_classified_effect(effect: &ClassifiedEffect, taxonomy: &CommandTaxonomy) -> Self {
280        let mut command = None;
281        let classes = match &effect.request.kind {
282            EffectKind::ReadFile => vec![CodingPolicyClass::ReadWorkspace],
283            EffectKind::WriteFile => vec![CodingPolicyClass::WriteWorkspace],
284            EffectKind::ApplyPatch => vec![CodingPolicyClass::ApplyPatch],
285            EffectKind::Search => {
286                if serde_json::from_value::<ListFilesPayload>(effect.request.payload.clone())
287                    .is_ok()
288                {
289                    vec![CodingPolicyClass::ListWorkspace]
290                } else {
291                    vec![CodingPolicyClass::SearchWorkspace]
292                }
293            }
294            EffectKind::Git => vec![CodingPolicyClass::GitRead],
295            EffectKind::ExecuteCommand => {
296                match serde_json::from_value::<CommandPayload>(effect.request.payload.clone()) {
297                    Ok(payload) => {
298                        let classes = taxonomy.classify_command(&payload.request);
299                        command = Some(payload.request);
300                        classes
301                    }
302                    Err(_) => vec![CodingPolicyClass::UnknownCommand],
303                }
304            }
305            EffectKind::Mcp => vec![CodingPolicyClass::McpUntrusted],
306            _ => Vec::new(),
307        };
308        Self {
309            effect_id: effect.request.id.clone(),
310            kind: effect.request.kind.clone(),
311            classes,
312            command,
313            metadata: effect.request.metadata.clone(),
314        }
315    }
316}
317
318/// Conservative coding policy wrapper around a host policy engine.
319#[derive(Debug, Clone)]
320pub struct CodingPolicyEngine<P = DefaultPolicyEngine> {
321    inner: P,
322    taxonomy: CommandTaxonomy,
323}
324
325impl CodingPolicyEngine<DefaultPolicyEngine> {
326    /// Constructs the conservative preset with the default fallback policy.
327    pub fn conservative() -> Self {
328        Self::new(DefaultPolicyEngine)
329    }
330}
331
332impl<P> CodingPolicyEngine<P> {
333    /// Constructs a coding policy wrapper around a fallback policy engine.
334    pub fn new(inner: P) -> Self {
335        Self {
336            inner,
337            taxonomy: CommandTaxonomy::conservative(),
338        }
339    }
340
341    /// Replaces the command taxonomy and host allowlists.
342    pub fn with_taxonomy(mut self, taxonomy: CommandTaxonomy) -> Self {
343        self.taxonomy = taxonomy;
344        self
345    }
346
347    /// Returns the configured taxonomy.
348    pub fn taxonomy(&self) -> &CommandTaxonomy {
349        &self.taxonomy
350    }
351}
352
353#[async_trait]
354impl<P> PolicyEngine for CodingPolicyEngine<P>
355where
356    P: PolicyEngine,
357{
358    async fn evaluate(
359        &self,
360        effect: &ClassifiedEffect,
361        context: &RunContext,
362    ) -> Result<PolicyDecision, HarnessError> {
363        let input = CodingPolicyInput::from_classified_effect(effect, &self.taxonomy);
364        if let Some(decision) = conservative_decision(&input, effect.effective_risk) {
365            return Ok(decision);
366        }
367        self.inner.evaluate(effect, context).await
368    }
369}
370
371fn conservative_decision(input: &CodingPolicyInput, risk: RiskLevel) -> Option<PolicyDecision> {
372    if risk == RiskLevel::Critical
373        || has_any(
374            input,
375            &[
376                CodingPolicyClass::DestructiveCommand,
377                CodingPolicyClass::GitDestructiveMutation,
378                CodingPolicyClass::NetworkCommand,
379            ],
380        )
381    {
382        return Some(PolicyDecision::Deny {
383            reason: "coding policy denied destructive or network command".to_string(),
384        });
385    }
386
387    if risk == RiskLevel::High
388        || has_any(
389            input,
390            &[
391                CodingPolicyClass::PackageInstall,
392                CodingPolicyClass::ShellCommand,
393                CodingPolicyClass::PtyCommand,
394                CodingPolicyClass::GitMutation,
395                CodingPolicyClass::UnknownCommand,
396            ],
397        )
398    {
399        return Some(PolicyDecision::RequireApproval {
400            reason: "coding policy requires approval".to_string(),
401        });
402    }
403
404    if has_any(
405        input,
406        &[
407            CodingPolicyClass::ReadWorkspace,
408            CodingPolicyClass::ListWorkspace,
409            CodingPolicyClass::SearchWorkspace,
410            CodingPolicyClass::WriteWorkspace,
411            CodingPolicyClass::ApplyPatch,
412            CodingPolicyClass::GitRead,
413            CodingPolicyClass::TestCommand,
414            CodingPolicyClass::BuildCommand,
415            CodingPolicyClass::LintCommand,
416        ],
417    ) {
418        return Some(PolicyDecision::Allow);
419    }
420
421    None
422}
423
424fn has_any(input: &CodingPolicyInput, classes: &[CodingPolicyClass]) -> bool {
425    classes.iter().any(|class| input.classes.contains(class))
426}
427
428fn matches_any(patterns: &[CommandPattern], argv: &[String]) -> bool {
429    patterns.iter().any(|pattern| pattern.matches(argv))
430}
431
432fn is_git_read_only(argv: &[String]) -> bool {
433    let Some(subcommand) = argv.get(1).map(String::as_str) else {
434        return false;
435    };
436    matches!(
437        subcommand,
438        "status" | "diff" | "show" | "rev-parse" | "branch" | "log" | "ls-files"
439    )
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::harness::{DefaultRiskClassifier, RiskClassifier};
446    use serde_json::json;
447
448    async fn classify(effect: crate::EffectRequest) -> ClassifiedEffect {
449        DefaultRiskClassifier
450            .classify(effect, &RunContext::new("policy"))
451            .await
452            .unwrap()
453    }
454
455    #[test]
456    fn taxonomy_classifies_network_shell_package_and_allowlisted_test() {
457        let taxonomy = CommandTaxonomy::conservative().with_allowed_test_command(["cargo", "test"]);
458        assert_eq!(
459            taxonomy.classify_command(&CommandRequest::new(["curl", "https://example.com"])),
460            vec![CodingPolicyClass::NetworkCommand]
461        );
462        assert!(
463            taxonomy
464                .classify_command(&CommandRequest::new(["sh", "-c", "echo hi"]))
465                .contains(&CodingPolicyClass::ShellCommand)
466        );
467        assert!(
468            taxonomy
469                .classify_command(&CommandRequest::new(["npm", "install"]))
470                .contains(&CodingPolicyClass::PackageInstall)
471        );
472        assert_eq!(
473            taxonomy.classify_command(&CommandRequest::new(["cargo", "test", "-q"])),
474            vec![CodingPolicyClass::TestCommand]
475        );
476    }
477
478    #[tokio::test]
479    async fn conservative_policy_denies_network_command() {
480        let payload = CommandPayload {
481            request: CommandRequest::new(["curl", "https://example.com"]),
482        };
483        let effect = classify(payload.into_effect().unwrap()).await;
484        let decision = CodingPolicyEngine::conservative()
485            .evaluate(&effect, &RunContext::new("policy"))
486            .await
487            .unwrap();
488        assert!(matches!(decision, PolicyDecision::Deny { .. }));
489    }
490
491    #[tokio::test]
492    async fn conservative_policy_requires_approval_for_unknown_command() {
493        let payload = CommandPayload {
494            request: CommandRequest::new(["custom-tool", "--flag"]),
495        };
496        let effect = classify(payload.into_effect().unwrap()).await;
497        let decision = CodingPolicyEngine::conservative()
498            .evaluate(&effect, &RunContext::new("policy"))
499            .await
500            .unwrap();
501        assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
502    }
503
504    #[tokio::test]
505    async fn conservative_policy_covers_command_hardening_cases() {
506        for argv in [
507            vec!["wget", "https://example.com"],
508            vec!["sudo", "whoami"],
509            vec!["rm", "-rf", "target"],
510            vec!["git", "reset", "--hard"],
511            vec!["git", "push", "--force"],
512        ] {
513            let payload = CommandPayload {
514                request: CommandRequest::new(argv),
515            };
516            let effect = classify(payload.into_effect().unwrap()).await;
517            let decision = CodingPolicyEngine::conservative()
518                .evaluate(&effect, &RunContext::new("policy"))
519                .await
520                .unwrap();
521            assert!(matches!(decision, PolicyDecision::Deny { .. }));
522        }
523
524        let package_install = classify(
525            CommandPayload {
526                request: CommandRequest::new(["npm", "install"]),
527            }
528            .into_effect()
529            .unwrap(),
530        )
531        .await;
532        assert!(matches!(
533            CodingPolicyEngine::conservative()
534                .evaluate(&package_install, &RunContext::new("policy"))
535                .await
536                .unwrap(),
537            PolicyDecision::RequireApproval { .. }
538        ));
539
540        let shell = classify(
541            CommandPayload {
542                request: CommandRequest::new(["sh", "-c", "echo hi"]),
543            }
544            .into_effect()
545            .unwrap(),
546        )
547        .await;
548        assert!(matches!(
549            CodingPolicyEngine::conservative()
550                .evaluate(&shell, &RunContext::new("policy"))
551                .await
552                .unwrap(),
553            PolicyDecision::RequireApproval { .. }
554        ));
555    }
556
557    #[tokio::test]
558    async fn conservative_policy_allows_read_effect() {
559        let effect = classify(crate::EffectRequest::new(
560            EffectKind::ReadFile,
561            "read",
562            json!({"path": "src/lib.rs"}),
563        ))
564        .await;
565        let decision = CodingPolicyEngine::conservative()
566            .evaluate(&effect, &RunContext::new("policy"))
567            .await
568            .unwrap();
569        assert_eq!(decision, PolicyDecision::Allow);
570    }
571}