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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
13#[non_exhaustive]
14pub enum CodingPolicyClass {
15 ReadWorkspace,
17 ListWorkspace,
19 SearchWorkspace,
21 WriteWorkspace,
23 ApplyPatch,
25 GitRead,
27 GitMutation,
29 GitDestructiveMutation,
31 TestCommand,
33 BuildCommand,
35 LintCommand,
37 PackageInstall,
39 NetworkCommand,
41 ShellCommand,
43 PtyCommand,
45 DestructiveCommand,
47 UnknownCommand,
49 McpTrusted,
51 McpUntrusted,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct CommandPattern {
58 pub argv_prefix: Vec<String>,
60}
61
62impl CommandPattern {
63 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(default)]
88#[non_exhaustive]
89pub struct CommandTaxonomy {
90 pub allowed_test_commands: Vec<CommandPattern>,
92 pub allowed_build_commands: Vec<CommandPattern>,
94 pub allowed_lint_commands: Vec<CommandPattern>,
96 pub network_programs: Vec<String>,
98 pub shell_programs: Vec<String>,
100 pub package_managers: Vec<String>,
102 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 pub fn conservative() -> Self {
149 Self::default()
150 }
151
152 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct CodingPolicyInput {
265 pub effect_id: String,
267 pub kind: EffectKind,
269 pub classes: Vec<CodingPolicyClass>,
271 pub command: Option<CommandRequest>,
273 pub metadata: RunMetadata,
275}
276
277impl CodingPolicyInput {
278 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#[derive(Debug, Clone)]
320pub struct CodingPolicyEngine<P = DefaultPolicyEngine> {
321 inner: P,
322 taxonomy: CommandTaxonomy,
323}
324
325impl CodingPolicyEngine<DefaultPolicyEngine> {
326 pub fn conservative() -> Self {
328 Self::new(DefaultPolicyEngine)
329 }
330}
331
332impl<P> CodingPolicyEngine<P> {
333 pub fn new(inner: P) -> Self {
335 Self {
336 inner,
337 taxonomy: CommandTaxonomy::conservative(),
338 }
339 }
340
341 pub fn with_taxonomy(mut self, taxonomy: CommandTaxonomy) -> Self {
343 self.taxonomy = taxonomy;
344 self
345 }
346
347 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}