Skip to main content

agent_orchestrator/resource/
runtime_policy.rs

1use crate::cli_types::{
2    OrchestratorResource, ResourceKind, ResourceSpec, ResumeSpec, RunnerSpec, RuntimePolicySpec,
3};
4use crate::config::{
5    OrchestratorConfig, ResumeConfig, RunnerConfig, RunnerExecutorKind, RunnerPolicy,
6};
7use anyhow::{Result, anyhow};
8
9use super::{ApplyResult, RegisteredResource, Resource, ResourceMetadata};
10
11#[derive(Debug, Clone)]
12/// Builtin manifest adapter for the global `RuntimePolicy` singleton.
13pub struct RuntimePolicyResource {
14    /// Resource metadata from the manifest.
15    pub metadata: ResourceMetadata,
16    /// Manifest spec payload for runtime policy.
17    pub spec: RuntimePolicySpec,
18}
19
20impl Resource for RuntimePolicyResource {
21    fn kind(&self) -> ResourceKind {
22        ResourceKind::RuntimePolicy
23    }
24
25    fn name(&self) -> &str {
26        &self.metadata.name
27    }
28
29    fn validate(&self) -> Result<()> {
30        super::validate_resource_name(self.name())?;
31        if self.spec.runner.policy == "allowlist" {
32            let mut errors = Vec::new();
33            if self.spec.runner.allowed_shells.is_empty() {
34                errors.push("runner.allowed_shells cannot be empty when policy=allowlist");
35            }
36            if self.spec.runner.allowed_shell_args.is_empty() {
37                errors.push("runner.allowed_shell_args cannot be empty when policy=allowlist");
38            }
39            if !errors.is_empty() {
40                return Err(anyhow!(errors.join("; ")));
41            }
42        }
43        Ok(())
44    }
45
46    fn apply(&self, config: &mut OrchestratorConfig) -> Result<ApplyResult> {
47        use crate::crd::projection::{CrdProjectable, RuntimePolicyProjection};
48        let incoming_runner = runner_spec_to_config(&self.spec.runner);
49        let incoming_resume = ResumeConfig {
50            auto: self.spec.resume.auto,
51        };
52        let rp = RuntimePolicyProjection {
53            runner: incoming_runner,
54            resume: incoming_resume,
55            observability: crate::config::ObservabilityConfig::default(),
56        };
57        let spec_value = rp.to_cr_spec();
58        Ok(super::apply_to_store(
59            config,
60            "RuntimePolicy",
61            "runtime",
62            &self.metadata,
63            spec_value,
64        ))
65    }
66
67    fn to_yaml(&self) -> Result<String> {
68        super::manifest_yaml(
69            ResourceKind::RuntimePolicy,
70            &self.metadata,
71            ResourceSpec::RuntimePolicy(self.spec.clone()),
72        )
73    }
74
75    fn get_from_project(
76        config: &OrchestratorConfig,
77        name: &str,
78        project_id: Option<&str>,
79    ) -> Option<Self> {
80        use crate::config_ext::OrchestratorConfigExt as _;
81        let pid = project_id.unwrap_or(crate::config::DEFAULT_PROJECT_ID);
82        let rp = config.runtime_policy_for_project(pid);
83        let metadata = super::metadata_from_store(config, "RuntimePolicy", name, project_id);
84        Some(Self {
85            metadata,
86            spec: RuntimePolicySpec {
87                runner: runner_config_to_spec(&rp.runner),
88                resume: ResumeSpec {
89                    auto: rp.resume.auto,
90                },
91                observability: serde_json::to_value(&rp.observability).ok(),
92            },
93        })
94    }
95
96    fn delete_from_project(
97        _config: &mut OrchestratorConfig,
98        _name: &str,
99        _project_id: Option<&str>,
100    ) -> bool {
101        // RuntimePolicy cannot be deleted.
102        false
103    }
104}
105
106/// Builds a typed `RuntimePolicyResource` from a generic manifest wrapper.
107pub(super) fn build_runtime_policy(resource: OrchestratorResource) -> Result<RegisteredResource> {
108    let OrchestratorResource {
109        kind,
110        metadata,
111        spec,
112        ..
113    } = resource;
114    if kind != ResourceKind::RuntimePolicy {
115        return Err(anyhow!("resource kind/spec mismatch for RuntimePolicy"));
116    }
117    match spec {
118        ResourceSpec::RuntimePolicy(spec) => {
119            Ok(RegisteredResource::RuntimePolicy(RuntimePolicyResource {
120                metadata,
121                spec,
122            }))
123        }
124        _ => Err(anyhow!("resource kind/spec mismatch for RuntimePolicy")),
125    }
126}
127
128/// Converts a runtime-policy manifest runner spec into runtime config.
129pub(crate) fn runner_spec_to_config(spec: &RunnerSpec) -> RunnerConfig {
130    RunnerConfig {
131        shell: spec.shell.clone(),
132        shell_arg: spec.shell_arg.clone(),
133        policy: match spec.policy.as_str() {
134            "unsafe" | "legacy" => RunnerPolicy::Unsafe,
135            _ => RunnerPolicy::Allowlist,
136        },
137        executor: match spec.executor.as_str() {
138            "shell" => RunnerExecutorKind::Shell,
139            _ => RunnerExecutorKind::Shell,
140        },
141        allowed_shells: spec.allowed_shells.clone(),
142        allowed_shell_args: spec.allowed_shell_args.clone(),
143        env_allowlist: spec.env_allowlist.clone(),
144        redaction_patterns: spec.redaction_patterns.clone(),
145    }
146}
147
148/// Converts runtime runner config into its manifest spec representation.
149pub(crate) fn runner_config_to_spec(config: &RunnerConfig) -> RunnerSpec {
150    RunnerSpec {
151        shell: config.shell.clone(),
152        shell_arg: config.shell_arg.clone(),
153        policy: match config.policy {
154            RunnerPolicy::Unsafe => "unsafe".to_string(),
155            RunnerPolicy::Allowlist => "allowlist".to_string(),
156        },
157        executor: match config.executor {
158            RunnerExecutorKind::Shell => "shell".to_string(),
159        },
160        allowed_shells: config.allowed_shells.clone(),
161        allowed_shell_args: config.allowed_shell_args.clone(),
162        env_allowlist: config.env_allowlist.clone(),
163        redaction_patterns: config.redaction_patterns.clone(),
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::cli_types::{ProjectSpec, ResourceMetadata, ResourceSpec};
171    use crate::resource::{API_VERSION, dispatch_resource};
172
173    use super::super::test_fixtures::{make_config, runtime_policy_manifest};
174
175    #[test]
176    fn runtime_policy_dispatch_and_kind() {
177        let resource =
178            dispatch_resource(runtime_policy_manifest()).expect("dispatch should succeed");
179        assert_eq!(resource.kind(), ResourceKind::RuntimePolicy);
180        assert_eq!(resource.name(), "runtime");
181    }
182
183    #[test]
184    fn runtime_policy_apply_unchanged_when_same() {
185        let mut config = make_config();
186        let r1 = dispatch_resource(runtime_policy_manifest()).expect("dispatch should succeed");
187        r1.apply(&mut config).expect("apply");
188        assert_eq!(
189            r1.apply(&mut config).expect("apply"),
190            ApplyResult::Unchanged
191        );
192    }
193
194    #[test]
195    fn runtime_policy_apply_configured_on_change() {
196        let mut config = make_config();
197        let r1 = dispatch_resource(runtime_policy_manifest()).expect("dispatch should succeed");
198        r1.apply(&mut config).expect("apply");
199
200        // Change the runner policy
201        let mut manifest = runtime_policy_manifest();
202        if let ResourceSpec::RuntimePolicy(ref mut spec) = manifest.spec {
203            spec.runner.policy = "allowlist".to_string();
204        }
205        let r2 = dispatch_resource(manifest).expect("dispatch should succeed");
206        assert_eq!(
207            r2.apply(&mut config).expect("apply"),
208            ApplyResult::Configured
209        );
210    }
211
212    #[test]
213    fn runtime_policy_get_from_always_returns_some() {
214        let config = make_config();
215        let loaded = RuntimePolicyResource::get_from(&config, "runtime");
216        let loaded = loaded.expect("runtime policy should always be present");
217        assert_eq!(loaded.metadata.name, "runtime");
218    }
219
220    #[test]
221    fn runtime_policy_delete_returns_false() {
222        let mut config = make_config();
223        assert!(!RuntimePolicyResource::delete_from(&mut config, "runtime"));
224    }
225
226    #[test]
227    fn runtime_policy_to_yaml() {
228        let resource =
229            dispatch_resource(runtime_policy_manifest()).expect("dispatch should succeed");
230        let yaml = resource.to_yaml().expect("should serialize");
231        assert!(yaml.contains("kind: RuntimePolicy"));
232        assert!(yaml.contains("/bin/bash"));
233    }
234
235    #[test]
236    fn build_runtime_policy_rejects_wrong_kind() {
237        let resource = OrchestratorResource {
238            api_version: API_VERSION.to_string(),
239            kind: ResourceKind::RuntimePolicy,
240            metadata: ResourceMetadata {
241                name: "bad".to_string(),
242                project: None,
243                labels: None,
244                annotations: None,
245            },
246            spec: ResourceSpec::Project(ProjectSpec { description: None }),
247        };
248        let err = dispatch_resource(resource).expect_err("operation should fail");
249        assert!(err.to_string().contains("mismatch"));
250    }
251
252    // ── runner_spec_to_config / runner_config_to_spec roundtrip ──────
253
254    #[test]
255    fn runner_spec_config_roundtrip() {
256        let spec = RunnerSpec {
257            shell: "/bin/zsh".to_string(),
258            shell_arg: "-c".to_string(),
259            policy: "allowlist".to_string(),
260            executor: "shell".to_string(),
261            allowed_shells: vec!["/bin/bash".to_string()],
262            allowed_shell_args: vec!["-c".to_string()],
263            env_allowlist: vec!["PATH".to_string()],
264            redaction_patterns: vec!["SECRET_.*".to_string()],
265        };
266
267        let config = runner_spec_to_config(&spec);
268        assert_eq!(config.shell, "/bin/zsh");
269        assert!(matches!(config.policy, RunnerPolicy::Allowlist));
270        assert!(matches!(config.executor, RunnerExecutorKind::Shell));
271        assert_eq!(config.allowed_shells, vec!["/bin/bash".to_string()]);
272        assert_eq!(config.env_allowlist, vec!["PATH".to_string()]);
273
274        let roundtripped = runner_config_to_spec(&config);
275        assert_eq!(roundtripped.shell, "/bin/zsh");
276        assert_eq!(roundtripped.policy, "allowlist");
277        assert_eq!(roundtripped.executor, "shell");
278        assert_eq!(roundtripped.allowed_shells, vec!["/bin/bash".to_string()]);
279    }
280
281    #[test]
282    fn validate_rejects_allowlist_with_empty_shells() {
283        let mut manifest = runtime_policy_manifest();
284        if let ResourceSpec::RuntimePolicy(ref mut spec) = manifest.spec {
285            spec.runner.policy = "allowlist".to_string();
286            spec.runner.allowed_shells = vec![];
287            spec.runner.allowed_shell_args = vec!["-lc".to_string()];
288        }
289        let resource = dispatch_resource(manifest).expect("dispatch should succeed");
290        let err = resource.validate().expect_err("operation should fail");
291        assert!(
292            err.to_string()
293                .contains("runner.allowed_shells cannot be empty")
294        );
295    }
296
297    #[test]
298    fn validate_rejects_allowlist_with_empty_shell_args() {
299        let mut manifest = runtime_policy_manifest();
300        if let ResourceSpec::RuntimePolicy(ref mut spec) = manifest.spec {
301            spec.runner.policy = "allowlist".to_string();
302            spec.runner.allowed_shells = vec!["/bin/bash".to_string()];
303            spec.runner.allowed_shell_args = vec![];
304        }
305        let resource = dispatch_resource(manifest).expect("dispatch should succeed");
306        let err = resource.validate().expect_err("operation should fail");
307        assert!(
308            err.to_string()
309                .contains("runner.allowed_shell_args cannot be empty")
310        );
311    }
312
313    #[test]
314    fn validate_accepts_allowlist_with_populated_lists() {
315        let mut manifest = runtime_policy_manifest();
316        if let ResourceSpec::RuntimePolicy(ref mut spec) = manifest.spec {
317            spec.runner.policy = "allowlist".to_string();
318            spec.runner.allowed_shells = vec!["/bin/bash".to_string()];
319            spec.runner.allowed_shell_args = vec!["-lc".to_string()];
320        }
321        let resource = dispatch_resource(manifest).expect("dispatch should succeed");
322        assert!(resource.validate().is_ok());
323    }
324
325    #[test]
326    fn validate_accepts_unsafe_with_empty_lists() {
327        let manifest = runtime_policy_manifest();
328        let resource = dispatch_resource(manifest).expect("dispatch should succeed");
329        assert!(resource.validate().is_ok());
330    }
331
332    #[test]
333    fn runner_spec_unsafe_policy() {
334        let spec = RunnerSpec {
335            shell: "/bin/sh".to_string(),
336            shell_arg: "-c".to_string(),
337            policy: "unsafe".to_string(),
338            executor: "shell".to_string(),
339            allowed_shells: vec![],
340            allowed_shell_args: vec![],
341            env_allowlist: vec![],
342            redaction_patterns: vec![],
343        };
344        let config = runner_spec_to_config(&spec);
345        assert!(matches!(config.policy, RunnerPolicy::Unsafe));
346        let back = runner_config_to_spec(&config);
347        assert_eq!(back.policy, "unsafe");
348    }
349
350    #[test]
351    fn runner_spec_legacy_policy_normalizes_to_unsafe() {
352        let spec = RunnerSpec {
353            shell: "/bin/sh".to_string(),
354            shell_arg: "-c".to_string(),
355            policy: "legacy".to_string(),
356            executor: "shell".to_string(),
357            allowed_shells: vec![],
358            allowed_shell_args: vec![],
359            env_allowlist: vec![],
360            redaction_patterns: vec![],
361        };
362        let config = runner_spec_to_config(&spec);
363        assert!(matches!(config.policy, RunnerPolicy::Unsafe));
364        let back = runner_config_to_spec(&config);
365        assert_eq!(back.policy, "unsafe");
366    }
367
368    #[test]
369    fn runner_spec_unknown_policy_falls_back_to_allowlist() {
370        let spec = RunnerSpec {
371            shell: "/bin/sh".to_string(),
372            shell_arg: "-c".to_string(),
373            policy: "unknown".to_string(),
374            executor: "shell".to_string(),
375            allowed_shells: vec![],
376            allowed_shell_args: vec![],
377            env_allowlist: vec![],
378            redaction_patterns: vec![],
379        };
380        let config = runner_spec_to_config(&spec);
381        assert!(matches!(config.policy, RunnerPolicy::Allowlist));
382    }
383
384    #[test]
385    fn default_runner_spec_produces_allowlist() {
386        let json = r#"{"shell":"/bin/bash"}"#;
387        let spec: RunnerSpec =
388            serde_json::from_str(json).expect("runner spec json should deserialize");
389        assert_eq!(spec.policy, "allowlist");
390        assert!(!spec.allowed_shells.is_empty());
391        assert!(!spec.allowed_shell_args.is_empty());
392        let config = runner_spec_to_config(&spec);
393        assert!(matches!(config.policy, RunnerPolicy::Allowlist));
394    }
395}