| 78 | } |
| 79 | |
| 80 | func ClassifyCommand(command string) ClassifierResult { |
| 81 | cleaned := strings.TrimSpace(command) |
| 82 | segments := SplitPipeline(cleaned) |
| 83 | if len(segments) > 1 { |
| 84 | for _, segment := range segments { |
| 85 | result := ClassifyCommand(segment) |
| 86 | if result.CommandClass != CommandClassSafe { |
| 87 | return ClassifierResult{CommandClass: CommandClassNeedsPermission, Reason: "pipeline contains non-safe command"} |
| 88 | } |
| 89 | } |
| 90 | return ClassifierResult{CommandClass: CommandClassSafe, Reason: "all pipeline segments are safe"} |
| 91 | } |
| 92 | |
| 93 | securityResult := RunAllSecurityChecks(cleaned) |
| 94 | if IsBlocking(securityResult) { |
| 95 | return ClassifierResult{CommandClass: CommandClassDangerous, Reason: "blocked by shell security checks"} |
| 96 | } |
| 97 | |
| 98 | base, subcommand, hasSudo := extractClassifierBaseCommand(cleaned) |
| 99 | if base == "" { |
| 100 | return ClassifierResult{CommandClass: CommandClassNeedsPermission, Reason: "could not parse command"} |
| 101 | } |
| 102 | if hasSudo { |
| 103 | return ClassifierResult{CommandClass: CommandClassDangerous, Reason: "sudo/doas elevates privileges"} |
| 104 | } |
| 105 | if _, ok := dangerousEnvPrefixes[base]; ok { |
| 106 | return ClassifierResult{CommandClass: CommandClassDangerous, Reason: base + " assignment changes shell parsing"} |
| 107 | } |
| 108 | if strings.Contains(base, "=") { |
| 109 | return ClassifierResult{CommandClass: CommandClassNeedsPermission, Reason: "command has unsafe env prefix"} |
| 110 | } |
| 111 | if hasOutputRedirection(cleaned) { |
| 112 | return ClassifierResult{CommandClass: CommandClassNeedsPermission, Reason: "command writes output"} |
| 113 | } |
| 114 | if sensitive := classifySensitiveRead(base, cleaned); sensitive != nil { |
| 115 | return *sensitive |
| 116 | } |
| 117 | if _, ok := classifierSafeCommands[base]; ok { |
| 118 | return ClassifierResult{CommandClass: CommandClassSafe, SafeCommand: base, Reason: "'" + base + "' is read-only"} |
| 119 | } |
| 120 | if safeSubs, ok := classifierSafeSubcommands[base]; ok { |
| 121 | if _, ok := safeSubs[subcommand]; subcommand != "" && ok { |
| 122 | return ClassifierResult{CommandClass: CommandClassSafe, SafeCommand: base, Reason: "'" + base + " " + subcommand + "' is read-only"} |
| 123 | } |
| 124 | } |
| 125 | if appsecurity.IsDangerous(cleaned) { |
| 126 | return ClassifierResult{CommandClass: CommandClassDangerous, Reason: "matches dangerous pattern"} |
| 127 | } |
| 128 | return ClassifierResult{CommandClass: CommandClassNeedsPermission, Reason: "may have side effects"} |
| 129 | } |
| 130 | |
| 131 | func extractClassifierBaseCommand(command string) (string, string, bool) { |
| 132 | cleaned := StripAllSafeEnvPrefixes(strings.TrimSpace(command)) |