| 1 | package permission |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "strings" |
| 6 | |
| 7 | "reasonix/internal/shellsafe" |
| 8 | ) |
| 9 | |
| 10 | // BashCommandIsReadOnly reports whether a bash tool call is a known foreground |
| 11 | // read-only command. Capability-restricted runners use this directly instead of |
| 12 | // depending on Plan mode: Plan is a collaboration workflow, while this check is |
| 13 | // an execution permission boundary. |
| 14 | func BashCommandIsReadOnly(args json.RawMessage) bool { |
| 15 | var p struct { |
| 16 | Command string `json:"command"` |
| 17 | RunInBackground bool `json:"run_in_background"` |
| 18 | PreserveBackgroundProcesses bool `json:"preserve_background_processes"` |
| 19 | } |
| 20 | if err := json.Unmarshal(args, &p); err != nil || strings.TrimSpace(p.Command) == "" { |
| 21 | return false |
| 22 | } |
| 23 | if p.RunInBackground || p.PreserveBackgroundProcesses { |
| 24 | return false |
| 25 | } |
| 26 | return isReadOnlyBashSubject(p.Command) |
| 27 | } |
| 28 | |
| 29 | // isReadOnlyBashSubject returns true when a bash command is a known read-only |
| 30 | // operation. The subject is the JSON arg value extracted by Subject() — for bash |
| 31 | // it is the raw command string. Command membership comes from the shared |
| 32 | // shellsafe tables (the shared command-classification source, #5341); the |
| 33 | // argument rigor below is permission-specific. |
| 34 | func isReadOnlyBashSubject(subject string) bool { |
| 35 | if normalized, ok := normalizeBashSafeRedirectsForMatch(subject); ok { |
| 36 | subject = normalized |
| 37 | } |
| 38 | base, sub, fields, ok := shellsafe.ClassifyReadOnlyCommand(subject) |
| 39 | if !ok { |
| 40 | return false |
| 41 | } |
| 42 | if sub == "" { |
| 43 | return !hasUnsafeReadOnlyArgs(base, fields[1:]) |
| 44 | } |
| 45 | return !hasUnsafePrefixArgs(base, sub, fields[2:]) |
| 46 | } |
| 47 | |
| 48 | // containsShellSyntax delegates to the shared classifier; retained for the other |
| 49 | // permission call sites (permission.go). |
| 50 | func containsShellSyntax(cmd string) bool { |
| 51 | return shellsafe.ContainsShellSyntax(cmd) |
| 52 | } |
| 53 | |
| 54 | func hasUnsafeReadOnlyArgs(base string, args []string) bool { |
| 55 | switch base { |
| 56 | case "find": |
| 57 | return hasAnyArg(args, "-exec", "-execdir", "-delete", "-ok", "-okdir", "-fls", "-fprint", "-fprint0", "-fprintf") |
| 58 | case "sed": |
| 59 | for _, arg := range args { |
| 60 | if strings.HasPrefix(arg, "-i") || strings.HasPrefix(arg, "--in-place") { |
| 61 | return true |
| 62 | } |
| 63 | } |
| 64 | case "sort": |
| 65 | return hasArgWithPrefix(args, "-o") || hasAnyArg(args, "--output") || hasArgWithPrefix(args, "--output=") |
| 66 | } |
| 67 | return false |
| 68 | } |
| 69 | |
| 70 | func hasUnsafePrefixArgs(base, subcmd string, args []string) bool { |
| 71 | switch base { |
| 72 | case "git": |
| 73 | switch subcmd { |
| 74 | case "diff", "show", "log": |
| 75 | return hasAnyArg(args, "--output") || hasArgWithPrefix(args, "--output=") |
| 76 | case "tag": |
| 77 | // Bare `git tag` lists; with a name it creates one, and -d deletes. |
| 78 | return !gitTagIsListing(args) |
| 79 | } |
| 80 | case "go": |
| 81 | if subcmd == "env" { |
| 82 | return hasAnyArg(args, "-w", "-u") |
| 83 | } |
| 84 | } |
| 85 | return false |
| 86 | } |
| 87 | |
| 88 | // gitTagIsListing reports whether a `git tag` invocation only lists tags. A |
| 89 | // bare `git tag` lists; a name creates one and -d deletes, so anything that |
| 90 | // isn't an explicit listing form writes the ref namespace. |
| 91 | func gitTagIsListing(args []string) bool { |
| 92 | listing := false |
| 93 | var operands []string |
| 94 | for _, arg := range args { |
| 95 | switch { |
| 96 | case arg == "-l" || arg == "--list": |
| 97 | listing = true |
| 98 | case arg == "-d" || arg == "--delete" || arg == "-a" || arg == "-s" || arg == "-f" || arg == "--force" || arg == "-m" || arg == "-F": |
| 99 | return false |
| 100 | case strings.HasPrefix(arg, "-"): |
| 101 | // Remaining flags (-n, --sort=, --format=, --contains, …) are output |
| 102 | // shaping; unknown ones fail closed below only when paired with an |
| 103 | // operand, which is the create/delete form. |
| 104 | continue |
| 105 | default: |
| 106 | operands = append(operands, arg) |
| 107 | } |
| 108 | } |
| 109 | if listing { |
| 110 | return true // operands are shell patterns for the listing filter |
| 111 | } |
| 112 | return len(operands) == 0 |
| 113 | } |
| 114 | |
| 115 | func hasArgWithPrefix(args []string, prefix string) bool { |
| 116 | for _, arg := range args { |
| 117 | if strings.HasPrefix(arg, prefix) { |
| 118 | return true |
| 119 | } |
| 120 | } |
| 121 | return false |
| 122 | } |
| 123 | |
| 124 | func hasAnyArg(args []string, unsafe ...string) bool { |
| 125 | for _, arg := range args { |
| 126 | for _, candidate := range unsafe { |
| 127 | if arg == candidate { |
| 128 | return true |
| 129 | } |
| 130 | } |
| 131 | } |
| 132 | return false |
| 133 | } |
| 134 | |
| 135 | // dangerousBashPatterns are glob-like patterns that match destructive |
| 136 | // commands. Used only for a UI warning — the deny list is the actual |
| 137 | // enforcement mechanism. |
| 138 | var dangerousBashPatterns = []struct { |
| 139 | pattern string |
| 140 | label string |
| 141 | }{ |
| 142 | {"rm -rf*", "recursive delete"}, |
| 143 | {"rm -r *", "recursive delete"}, |
| 144 | {"rm -fr*", "recursive delete"}, |
| 145 | {"git push*--force*", "force push"}, |
| 146 | {"git push*-f*", "force push"}, |
| 147 | {"git reset --hard*", "hard reset"}, |
| 148 | {"git clean -f*", "force clean"}, |
| 149 | {"git restore*", "discards uncommitted changes"}, |
| 150 | {"git checkout -- *", "discards uncommitted changes"}, |
| 151 | {"git checkout .*", "discards uncommitted changes"}, |
| 152 | {"git stash drop*", "drops stashed changes"}, |
| 153 | {"git stash clear*", "drops stashed changes"}, |
| 154 | {"chmod 777*", "world-writable"}, |
| 155 | {"chmod -R 777*", "world-writable recursive"}, |
| 156 | {"chown *", "ownership change"}, |
| 157 | {"sudo *", "superuser"}, |
| 158 | {"mkfs*", "filesystem format"}, |
| 159 | {"dd if=*", "raw device write"}, |
| 160 | {"fdisk*", "partition table"}, |
| 161 | {"> /dev/*", "device overwrite"}, |
| 162 | } |
| 163 | |
| 164 | // BashDangerWarning returns a short label if subject matches a known |
| 165 | // dangerous pattern, or "" when the command looks safe. This is a visual |
| 166 | // hint only — the Policy rules are the authority. |
| 167 | func BashDangerWarning(subject string) string { |
| 168 | s := strings.TrimSpace(subject) |
| 169 | for _, d := range dangerousBashPatterns { |
| 170 | if matchGlob(d.pattern, s) { |
| 171 | return d.label |
| 172 | } |
| 173 | } |
| 174 | return "" |
| 175 | } |
| 176 |