| 1 | package shellsafe |
| 2 | |
| 3 | import ( |
| 4 | "path/filepath" |
| 5 | "slices" |
| 6 | "strings" |
| 7 | |
| 8 | "mvdan.cc/sh/v3/syntax" |
| 9 | |
| 10 | "reasonix/internal/shellparse" |
| 11 | ) |
| 12 | |
| 13 | // Certainty reports whether the host could statically prove a command's |
| 14 | // effects. Unknown commands fail closed at mutation and permission boundaries. |
| 15 | type Certainty uint8 |
| 16 | |
| 17 | const ( |
| 18 | EffectUnknown Certainty = iota |
| 19 | EffectKnown |
| 20 | ) |
| 21 | |
| 22 | // WriteDomain identifies durable state a command can change. |
| 23 | type WriteDomain uint8 |
| 24 | |
| 25 | const ( |
| 26 | WriteWorkspaceContent WriteDomain = 1 << iota |
| 27 | WriteRepositoryMetadata |
| 28 | WriteHostState |
| 29 | WriteExternalState |
| 30 | ) |
| 31 | |
| 32 | // CommandEffect is the shared, host-derived effect classification for one Bash |
| 33 | // invocation. CommandFamily and Reason are deliberately argument-free so they |
| 34 | // are safe to surface in policy diagnostics. |
| 35 | type CommandEffect struct { |
| 36 | Certainty Certainty |
| 37 | Writes WriteDomain |
| 38 | PermissionSafe bool |
| 39 | ExecutesCode bool |
| 40 | UsesNetwork bool |
| 41 | CommandFamily string |
| 42 | Reason string |
| 43 | } |
| 44 | |
| 45 | func (e CommandEffect) AnyMutation() bool { return e.Certainty != EffectKnown || e.Writes != 0 } |
| 46 | func (e CommandEffect) WorkspaceMutation() bool { |
| 47 | return e.Certainty != EffectKnown || e.Writes&(WriteWorkspaceContent|WriteRepositoryMetadata) != 0 |
| 48 | } |
| 49 | func (e CommandEffect) ContentMutation() bool { |
| 50 | return e.Certainty != EffectKnown || e.Writes&WriteWorkspaceContent != 0 |
| 51 | } |
| 52 | func (e CommandEffect) RepositoryMutation() bool { return e.Writes&WriteRepositoryMetadata != 0 } |
| 53 | func (e CommandEffect) IsPermissionReader() bool { |
| 54 | return e.Certainty == EffectKnown && e.Writes == 0 && e.PermissionSafe |
| 55 | } |
| 56 | |
| 57 | // ClassifyBash statically classifies every top-level command segment and |
| 58 | // combines their effects. It never executes expansions or helper programs. |
| 59 | func ClassifyBash(command string) CommandEffect { |
| 60 | command = strings.TrimSpace(command) |
| 61 | if command == "" { |
| 62 | return unknownEffect("shell", "empty command") |
| 63 | } |
| 64 | if bashHasUnsafeLifecycle(command) { |
| 65 | return unknownEffect(commandFamilyFromSource(command), "background or process lifecycle syntax") |
| 66 | } |
| 67 | segments, _, ok := shellparse.SplitTopLevel(command) |
| 68 | if !ok || len(segments) == 0 { |
| 69 | return unknownEffect(commandFamilyFromSource(command), "unsupported shell syntax") |
| 70 | } |
| 71 | |
| 72 | var aggregate CommandEffect |
| 73 | aggregate.Certainty = EffectKnown |
| 74 | aggregate.PermissionSafe = true |
| 75 | for _, segment := range segments { |
| 76 | effect := classifyBashSegment(segment) |
| 77 | aggregate.Writes |= effect.Writes |
| 78 | aggregate.ExecutesCode = aggregate.ExecutesCode || effect.ExecutesCode |
| 79 | aggregate.UsesNetwork = aggregate.UsesNetwork || effect.UsesNetwork |
| 80 | aggregate.PermissionSafe = aggregate.PermissionSafe && effect.IsPermissionReader() |
| 81 | if aggregate.CommandFamily == "" || effect.AnyMutation() || !effect.PermissionSafe { |
| 82 | aggregate.CommandFamily = effect.CommandFamily |
| 83 | } |
| 84 | if aggregate.Reason == "" && effect.Reason != "" { |
| 85 | aggregate.Reason = effect.Reason |
| 86 | } |
| 87 | if effect.Certainty != EffectKnown { |
| 88 | aggregate.Certainty = EffectUnknown |
| 89 | } |
| 90 | } |
| 91 | if aggregate.CommandFamily == "" { |
| 92 | aggregate.CommandFamily = "shell" |
| 93 | } |
| 94 | return aggregate |
| 95 | } |
| 96 | |
| 97 | func classifyBashSegment(segment string) CommandEffect { |
| 98 | if normalized, ok := NormalizeBashSafeRedirectsForMatch(segment); ok { |
| 99 | if fields, envPrefixed, ok := staticArgv(normalized); ok { |
| 100 | return applyEnvPrefix(classifyStaticFields(fields), envPrefixed) |
| 101 | } |
| 102 | // Preserve the existing narrow, recursively proven command-substitution |
| 103 | // reader path. Its returned fields contain only opaque placeholders. |
| 104 | if base, sub, fields, ok := ClassifyReadOnlyCommand(normalized); ok { |
| 105 | effect := classifyStaticFields(fields) |
| 106 | if effect.Certainty == EffectKnown && effect.Writes == 0 { |
| 107 | effect.CommandFamily = joinFamily(base, sub) |
| 108 | return effect |
| 109 | } |
| 110 | } |
| 111 | return unknownEffect(commandFamilyFromSource(normalized), "dynamic command shape") |
| 112 | } |
| 113 | |
| 114 | fields, writesFile, ok := staticFieldsWithOutputRedirect(segment) |
| 115 | if !ok { |
| 116 | return unknownEffect(commandFamilyFromSource(segment), "unsafe or unsupported redirection") |
| 117 | } |
| 118 | effect := classifyStaticFields(fields) |
| 119 | if writesFile { |
| 120 | effect.Writes |= WriteWorkspaceContent |
| 121 | effect.PermissionSafe = false |
| 122 | if effect.Reason == "" { |
| 123 | effect.Reason = "writes command output" |
| 124 | } |
| 125 | } |
| 126 | return effect |
| 127 | } |
| 128 | |
| 129 | // staticArgv reduces one statically proven command to argv after stripping a |
| 130 | // leading env wrapper. Env flags and expansions fail closed. |
| 131 | func staticArgv(command string) ([]string, bool, bool) { |
| 132 | cmd, err := shellparse.ParseStaticCommand(command, shellparse.StaticCommandPolicy{ |
| 133 | AllowEnvAssignments: true, |
| 134 | AllowStderrToStdout: true, |
| 135 | }) |
| 136 | if err != nil || len(cmd.Argv) == 0 { |
| 137 | return nil, false, false |
| 138 | } |
| 139 | fields, envPrefixed, ok := unwrapEnvArgv(cmd.Argv) |
| 140 | if !ok { |
| 141 | return nil, envPrefixed, false |
| 142 | } |
| 143 | return fields, envPrefixed || len(cmd.Env) > 0, true |
| 144 | } |
| 145 | |
| 146 | func unwrapEnvArgv(argv []string) ([]string, bool, bool) { |
| 147 | if len(argv) == 0 { |
| 148 | return nil, false, false |
| 149 | } |
| 150 | base := strings.ToLower(strings.TrimSuffix(filepath.Base(argv[0]), ".exe")) |
| 151 | if base != "env" { |
| 152 | return argv, false, true |
| 153 | } |
| 154 | rest := argv[1:] |
| 155 | for len(rest) > 0 { |
| 156 | tok := rest[0] |
| 157 | if strings.HasPrefix(tok, "-") { |
| 158 | return nil, true, false |
| 159 | } |
| 160 | if !strings.Contains(tok, "=") { |
| 161 | break |
| 162 | } |
| 163 | rest = rest[1:] |
| 164 | } |
| 165 | if len(rest) == 0 { |
| 166 | return nil, true, false |
| 167 | } |
| 168 | return rest, true, true |
| 169 | } |
| 170 | |
| 171 | func applyEnvPrefix(effect CommandEffect, envPrefixed bool) CommandEffect { |
| 172 | if !envPrefixed { |
| 173 | return effect |
| 174 | } |
| 175 | effect.PermissionSafe = false |
| 176 | if effect.Reason == "" { |
| 177 | effect.Reason = "environment prefix requires permission" |
| 178 | } |
| 179 | return effect |
| 180 | } |
| 181 | |
| 182 | // CommandArgv returns statically proven argv after stripping a leading env |
| 183 | // prefix. ok is false for expansions, env flags, or unsupported syntax. |
| 184 | func CommandArgv(command string) (argv []string, envPrefixed bool, ok bool) { |
| 185 | return staticArgv(strings.TrimSpace(command)) |
| 186 | } |
| 187 | |
| 188 | func classifyStaticFields(fields []string) CommandEffect { |
| 189 | if len(fields) == 0 { |
| 190 | return unknownEffect("shell", "empty command") |
| 191 | } |
| 192 | base := strings.ToLower(strings.TrimSuffix(filepath.Base(fields[0]), ".exe")) |
| 193 | args := fields[1:] |
| 194 | switch base { |
| 195 | case "git": |
| 196 | return classifyGit(args) |
| 197 | case "date": |
| 198 | return classifyDate(args) |
| 199 | case "go": |
| 200 | return classifyGo(args) |
| 201 | case "npm": |
| 202 | return classifyNPM(args) |
| 203 | case "cargo": |
| 204 | return classifyCargo(args) |
| 205 | case "test-netconnection": |
| 206 | return CommandEffect{Certainty: EffectKnown, UsesNetwork: true, CommandFamily: base, Reason: "network probe requires permission"} |
| 207 | } |
| 208 | |
| 209 | if readOnlyCommands[base] { |
| 210 | effect := knownReader(base) |
| 211 | switch base { |
| 212 | case "find": |
| 213 | if hasEffectArg(args, "-exec", "-execdir", "-ok", "-okdir") { |
| 214 | return unknownCodeEffect(base, "executes a nested command") |
| 215 | } |
| 216 | if hasEffectArg(args, "-delete", "-fls", "-fprint", "-fprint0", "-fprintf") { |
| 217 | return knownWriter(base, WriteWorkspaceContent, "writes filesystem state") |
| 218 | } |
| 219 | case "sort": |
| 220 | if hasOutputArg(args) { |
| 221 | return knownWriter(base, WriteWorkspaceContent, "writes command output") |
| 222 | } |
| 223 | } |
| 224 | return effect |
| 225 | } |
| 226 | if len(args) > 0 && readOnlyPrefixes[base][strings.ToLower(args[0])] { |
| 227 | effect := knownReader(joinFamily(base, strings.ToLower(args[0]))) |
| 228 | if base == "docker" || base == "kubectl" { |
| 229 | effect.UsesNetwork = true |
| 230 | } |
| 231 | return effect |
| 232 | } |
| 233 | return unknownEffect(base, "command effects are not statically known") |
| 234 | } |
| 235 | |
| 236 | func classifyGit(args []string) CommandEffect { |
| 237 | args = skipGitGlobalArgs(args) |
| 238 | if len(args) == 0 { |
| 239 | return unknownEffect("git", "missing git subcommand") |
| 240 | } |
| 241 | sub := strings.ToLower(args[0]) |
| 242 | rest := args[1:] |
| 243 | switch sub { |
| 244 | case "branch": |
| 245 | return classifyGitBranch(rest) |
| 246 | case "tag": |
| 247 | return classifyGitTag(rest) |
| 248 | case "remote": |
| 249 | return classifyGitRemote(rest) |
| 250 | case "config": |
| 251 | return classifyGitConfig(rest) |
| 252 | case "worktree": |
| 253 | return classifyGitWorktree(rest) |
| 254 | case "stash": |
| 255 | return classifyGitStash(rest) |
| 256 | case "clean": |
| 257 | return classifyGitClean(rest) |
| 258 | case "submodule": |
| 259 | return classifyGitSubmodule(rest) |
| 260 | case "commit": |
| 261 | return classifyGitCommit(rest) |
| 262 | case "push": |
| 263 | effect := knownWriter("git push", WriteExternalState, "updates remote refs") |
| 264 | effect.UsesNetwork = true |
| 265 | return effect |
| 266 | case "fetch": |
| 267 | effect := knownWriter("git fetch", WriteRepositoryMetadata, "updates remote-tracking refs") |
| 268 | effect.UsesNetwork = true |
| 269 | return effect |
| 270 | case "pull": |
| 271 | effect := knownWriter("git pull", WriteWorkspaceContent|WriteRepositoryMetadata, "updates workspace content and refs") |
| 272 | effect.UsesNetwork = true |
| 273 | return effect |
| 274 | case "reflog": |
| 275 | if len(rest) > 0 && slices.Contains([]string{"expire", "delete", "drop"}, strings.ToLower(rest[0])) { |
| 276 | return knownWriter("git reflog", WriteRepositoryMetadata, "updates reflog metadata") |
| 277 | } |
| 278 | return knownReader("git reflog") |
| 279 | case "diff", "show", "log": |
| 280 | if hasOutputArg(rest) { |
| 281 | return knownWriter("git "+sub, WriteWorkspaceContent, "writes command output") |
| 282 | } |
| 283 | if hasEffectArg(rest, "--ext-diff", "--textconv") { |
| 284 | return unknownCodeEffect("git "+sub, "may execute an external diff helper") |
| 285 | } |
| 286 | return knownReader("git " + sub) |
| 287 | case "grep": |
| 288 | if hasArgPrefix(rest, "--open-files-in-pager") { |
| 289 | return unknownCodeEffect("git grep", "may execute an external pager") |
| 290 | } |
| 291 | return knownReader("git grep") |
| 292 | case "cat-file": |
| 293 | if hasEffectArg(rest, "--filters", "--textconv") { |
| 294 | return unknownCodeEffect("git cat-file", "may execute an external content filter") |
| 295 | } |
| 296 | return knownReader("git cat-file") |
| 297 | } |
| 298 | if readOnlyPrefixes["git"][sub] { |
| 299 | return knownReader("git " + sub) |
| 300 | } |
| 301 | return unknownEffect("git "+sub, "git subcommand effects are not statically known") |
| 302 | } |
| 303 | |
| 304 | func skipGitGlobalArgs(args []string) []string { |
| 305 | for len(args) > 0 { |
| 306 | if args[0] == "--no-pager" { |
| 307 | args = args[1:] |
| 308 | continue |
| 309 | } |
| 310 | if len(args) >= 2 && (args[0] == "-C" || args[0] == "--git-dir" || args[0] == "--work-tree") { |
| 311 | args = args[2:] |
| 312 | continue |
| 313 | } |
| 314 | break |
| 315 | } |
| 316 | return args |
| 317 | } |
| 318 | |
| 319 | func classifyGitBranch(args []string) CommandEffect { |
| 320 | family := "git branch" |
| 321 | if slices.ContainsFunc(args, branchMutationArg) { |
| 322 | return knownWriter(family, WriteRepositoryMetadata, "updates branch refs or configuration") |
| 323 | } |
| 324 | listing := len(args) == 0 |
| 325 | operands := 0 |
| 326 | for i := 0; i < len(args); i++ { |
| 327 | arg := args[i] |
| 328 | switch { |
| 329 | case arg == "-l" || arg == "--list" || arg == "--all" || arg == "-a" || arg == "--remotes" || arg == "-r" || arg == "--show-current": |
| 330 | listing = true |
| 331 | case arg == "-v" || arg == "-vv" || arg == "--verbose" || arg == "-q" || arg == "--quiet" || arg == "-i" || arg == "--ignore-case" || arg == "--omit-empty" || arg == "--no-omit-empty": |
| 332 | listing = true |
| 333 | case branchValueOption(arg): |
| 334 | listing = true |
| 335 | if !strings.Contains(arg, "=") { |
| 336 | i++ |
| 337 | if i >= len(args) { |
| 338 | return unknownEffect(family, "missing branch filter value") |
| 339 | } |
| 340 | } |
| 341 | case strings.HasPrefix(arg, "-"): |
| 342 | return unknownEffect(family, "unknown branch option") |
| 343 | default: |
| 344 | operands++ |
| 345 | } |
| 346 | } |
| 347 | if operands > 0 && !hasEffectArg(args, "-l", "--list") { |
| 348 | return knownWriter(family, WriteRepositoryMetadata, "creates or resets a branch ref") |
| 349 | } |
| 350 | if listing { |
| 351 | return knownReader(family) |
| 352 | } |
| 353 | return unknownEffect(family, "ambiguous branch operation") |
| 354 | } |
| 355 | |
| 356 | func classifyGitTag(args []string) CommandEffect { |
| 357 | family := "git tag" |
| 358 | listing := len(args) == 0 |
| 359 | verify := false |
| 360 | operands := 0 |
| 361 | for i := 0; i < len(args); i++ { |
| 362 | arg := args[i] |
| 363 | if tagMutationArg(arg) { |
| 364 | return knownWriter(family, WriteRepositoryMetadata, "updates tag refs") |
| 365 | } |
| 366 | switch { |
| 367 | case arg == "-l" || arg == "--list": |
| 368 | listing = true |
| 369 | case arg == "-v" || arg == "--verify": |
| 370 | verify = true |
| 371 | case arg == "-i" || arg == "--ignore-case" || strings.HasPrefix(arg, "-n") || tagFlagOption(arg): |
| 372 | listing = true |
| 373 | if tagOptionConsumesNext(arg) { |
| 374 | i++ |
| 375 | if i >= len(args) { |
| 376 | return unknownEffect(family, "missing tag filter value") |
| 377 | } |
| 378 | } |
| 379 | case strings.HasPrefix(arg, "-"): |
| 380 | return unknownEffect(family, "unknown tag option") |
| 381 | default: |
| 382 | operands++ |
| 383 | } |
| 384 | } |
| 385 | if verify { |
| 386 | if operands == 0 { |
| 387 | return unknownEffect(family, "missing tag verification operand") |
| 388 | } |
| 389 | effect := CommandEffect{Certainty: EffectKnown, ExecutesCode: true, CommandFamily: family, Reason: "tag verification may execute a signature helper"} |
| 390 | return effect |
| 391 | } |
| 392 | if operands > 0 && !hasEffectArg(args, "-l", "--list") { |
| 393 | return knownWriter(family, WriteRepositoryMetadata, "creates a tag ref") |
| 394 | } |
| 395 | if listing { |
| 396 | return knownReader(family) |
| 397 | } |
| 398 | return unknownEffect(family, "ambiguous tag operation") |
| 399 | } |
| 400 | |
| 401 | func classifyGitRemote(args []string) CommandEffect { |
| 402 | family := "git remote" |
| 403 | if len(args) == 0 || allEffectArgs(args, "-v", "--verbose") { |
| 404 | return knownReader(family) |
| 405 | } |
| 406 | action := strings.ToLower(args[0]) |
| 407 | if action == "get-url" { |
| 408 | return knownReader(family) |
| 409 | } |
| 410 | if slices.Contains([]string{"add", "remove", "rm", "rename", "set-url", "prune", "update"}, action) { |
| 411 | return knownWriter(family, WriteRepositoryMetadata, "updates remote configuration or refs") |
| 412 | } |
| 413 | return unknownEffect(family, "remote operation is not a proven local reader") |
| 414 | } |
| 415 | |
| 416 | func classifyGitConfig(args []string) CommandEffect { |
| 417 | family := "git config" |
| 418 | hostScope := hasEffectArg(args, "--global", "--system") |
| 419 | for _, arg := range args { |
| 420 | name := strings.ToLower(strings.SplitN(arg, "=", 2)[0]) |
| 421 | if slices.Contains([]string{"--edit", "-e"}, name) { |
| 422 | domain := WriteRepositoryMetadata |
| 423 | if hostScope { |
| 424 | domain = WriteHostState |
| 425 | } |
| 426 | return knownWriter(family, domain, "opens an editor that can write git configuration") |
| 427 | } |
| 428 | if slices.Contains([]string{"set", "unset", "unset-all", "add", "replace-all", "rename-section", "remove-section", "--unset", "--unset-all", "--add", "--replace-all", "--rename-section", "--remove-section"}, name) { |
| 429 | domain := WriteRepositoryMetadata |
| 430 | if hostScope { |
| 431 | domain = WriteHostState |
| 432 | } |
| 433 | return knownWriter(family, domain, "updates git configuration") |
| 434 | } |
| 435 | } |
| 436 | positional := 0 |
| 437 | for i := 0; i < len(args); i++ { |
| 438 | arg := args[i] |
| 439 | if configOptionConsumesNext(arg) { |
| 440 | i++ |
| 441 | continue |
| 442 | } |
| 443 | if strings.HasPrefix(arg, "-") || configReadAction(arg) { |
| 444 | continue |
| 445 | } |
| 446 | positional++ |
| 447 | } |
| 448 | if positional <= 1 || containsConfigReadAction(args) { |
| 449 | return knownReader(family) |
| 450 | } |
| 451 | domain := WriteRepositoryMetadata |
| 452 | if hostScope { |
| 453 | domain = WriteHostState |
| 454 | } |
| 455 | return knownWriter(family, domain, "updates git configuration") |
| 456 | } |
| 457 | |
| 458 | func classifyGitWorktree(args []string) CommandEffect { |
| 459 | family := "git worktree" |
| 460 | if len(args) > 0 && strings.EqualFold(args[0], "list") { |
| 461 | return knownReader(family) |
| 462 | } |
| 463 | if len(args) > 0 && slices.Contains([]string{"add", "remove", "move", "prune"}, strings.ToLower(args[0])) { |
| 464 | return knownWriter(family, WriteWorkspaceContent|WriteRepositoryMetadata, "updates worktrees") |
| 465 | } |
| 466 | if len(args) > 0 && slices.Contains([]string{"lock", "unlock", "repair"}, strings.ToLower(args[0])) { |
| 467 | return knownWriter(family, WriteRepositoryMetadata, "updates worktree metadata") |
| 468 | } |
| 469 | return unknownEffect(family, "worktree operation is not a proven reader") |
| 470 | } |
| 471 | |
| 472 | func classifyGitStash(args []string) CommandEffect { |
| 473 | family := "git stash" |
| 474 | if len(args) > 0 && slices.Contains([]string{"list", "show"}, strings.ToLower(args[0])) { |
| 475 | return knownReader(family) |
| 476 | } |
| 477 | if len(args) > 0 && slices.Contains([]string{"push", "save", "pop", "apply", "drop", "clear", "branch", "store", "create"}, strings.ToLower(args[0])) { |
| 478 | return knownWriter(family, WriteWorkspaceContent|WriteRepositoryMetadata, "updates stash or working tree state") |
| 479 | } |
| 480 | return unknownEffect(family, "stash operation is not a proven reader") |
| 481 | } |
| 482 | |
| 483 | func classifyGitClean(args []string) CommandEffect { |
| 484 | family := "git clean" |
| 485 | dryRun := false |
| 486 | force := false |
| 487 | for _, arg := range args { |
| 488 | dryRun = dryRun || arg == "--dry-run" || shortOptionContains(arg, 'n') |
| 489 | force = force || arg == "--force" || shortOptionContains(arg, 'f') |
| 490 | } |
| 491 | if dryRun && !force { |
| 492 | return knownReader(family) |
| 493 | } |
| 494 | return knownWriter(family, WriteWorkspaceContent, "removes untracked workspace content") |
| 495 | } |
| 496 | |
| 497 | func classifyGitSubmodule(args []string) CommandEffect { |
| 498 | family := "git submodule" |
| 499 | if len(args) > 0 && slices.Contains([]string{"status", "summary"}, strings.ToLower(args[0])) { |
| 500 | return knownReader(family) |
| 501 | } |
| 502 | if len(args) > 0 && slices.Contains([]string{"add", "init", "update", "deinit", "sync", "set-url", "set-branch", "foreach", "absorbgitdirs"}, strings.ToLower(args[0])) { |
| 503 | return knownWriter(family, WriteWorkspaceContent|WriteRepositoryMetadata, "updates submodule state") |
| 504 | } |
| 505 | return unknownEffect(family, "submodule operation is not a proven reader") |
| 506 | } |
| 507 | |
| 508 | func classifyGitCommit(args []string) CommandEffect { |
| 509 | family := "git commit" |
| 510 | pure := false |
| 511 | for i := 0; i < len(args); i++ { |
| 512 | arg := args[i] |
| 513 | switch { |
| 514 | case arg == "-q" || arg == "--quiet": |
| 515 | case arg == "-m" || arg == "--message": |
| 516 | if i+1 >= len(args) { |
| 517 | return knownWriter(family, WriteWorkspaceContent|WriteRepositoryMetadata, "ambiguous commit form") |
| 518 | } |
| 519 | pure = true |
| 520 | i++ |
| 521 | case strings.HasPrefix(arg, "--message="): |
| 522 | pure = true |
| 523 | default: |
| 524 | return knownWriter(family, WriteWorkspaceContent|WriteRepositoryMetadata, "commit form may include workspace content") |
| 525 | } |
| 526 | } |
| 527 | if pure { |
| 528 | return knownWriter(family, WriteRepositoryMetadata, "creates a commit and moves refs") |
| 529 | } |
| 530 | return knownWriter(family, WriteWorkspaceContent|WriteRepositoryMetadata, "ambiguous commit form") |
| 531 | } |
| 532 | |
| 533 | func classifyDate(args []string) CommandEffect { |
| 534 | family := "date" |
| 535 | for i := 0; i < len(args); i++ { |
| 536 | arg := args[i] |
| 537 | switch { |
| 538 | case arg == "-s" || arg == "--set" || strings.HasPrefix(arg, "--set="): |
| 539 | return knownWriter(family, WriteHostState, "sets the host clock") |
| 540 | case strings.HasPrefix(arg, "+"): |
| 541 | case slices.Contains([]string{"-u", "--utc", "--universal", "-R", "--rfc-email", "--resolution", "--debug", "-j"}, arg): |
| 542 | case strings.HasPrefix(arg, "--iso-8601") || strings.HasPrefix(arg, "--rfc-3339"): |
| 543 | case slices.Contains([]string{"-d", "--date", "-r", "--reference", "-f", "--file"}, arg): |
| 544 | i++ |
| 545 | if i >= len(args) { |
| 546 | return unknownEffect(family, "missing date reader value") |
| 547 | } |
| 548 | case strings.HasPrefix(arg, "--date=") || strings.HasPrefix(arg, "--reference=") || strings.HasPrefix(arg, "--file="): |
| 549 | case strings.HasPrefix(arg, "-"): |
| 550 | return unknownEffect(family, "unknown date option") |
| 551 | default: |
| 552 | return knownWriter(family, WriteHostState, "may set the host clock") |
| 553 | } |
| 554 | } |
| 555 | return knownReader(family) |
| 556 | } |
| 557 | |
| 558 | func classifyGo(args []string) CommandEffect { |
| 559 | if len(args) == 0 { |
| 560 | return unknownEffect("go", "missing go subcommand") |
| 561 | } |
| 562 | sub := strings.ToLower(args[0]) |
| 563 | rest := args[1:] |
| 564 | family := "go " + sub |
| 565 | if sub == "env" && hasEffectArg(rest, "-w", "-u") { |
| 566 | return knownWriter(family, WriteHostState, "updates Go environment configuration") |
| 567 | } |
| 568 | if sub == "list" { |
| 569 | for i, arg := range rest { |
| 570 | if arg == "-modfile" || strings.HasPrefix(arg, "-modfile=") || arg == "-mod=mod" || (arg == "-mod" && i+1 < len(rest) && rest[i+1] == "mod") { |
| 571 | return knownWriter(family, WriteWorkspaceContent, "may update module files") |
| 572 | } |
| 573 | } |
| 574 | } |
| 575 | if readOnlyPrefixes["go"][sub] { |
| 576 | return knownReader(family) |
| 577 | } |
| 578 | return unknownEffect(family, "go subcommand effects are not statically known") |
| 579 | } |
| 580 | |
| 581 | func classifyNPM(args []string) CommandEffect { |
| 582 | if len(args) == 0 { |
| 583 | return unknownEffect("npm", "missing npm subcommand") |
| 584 | } |
| 585 | sub := strings.ToLower(args[0]) |
| 586 | family := "npm " + sub |
| 587 | if sub == "audit" { |
| 588 | effect := knownReader(family) |
| 589 | effect.UsesNetwork = true |
| 590 | if hasEffectArg(args[1:], "fix", "--fix") { |
| 591 | effect.Writes = WriteWorkspaceContent |
| 592 | effect.PermissionSafe = false |
| 593 | effect.Reason = "updates dependency manifests or lockfiles" |
| 594 | } |
| 595 | return effect |
| 596 | } |
| 597 | if readOnlyPrefixes["npm"][sub] { |
| 598 | effect := knownReader(family) |
| 599 | effect.UsesNetwork = sub == "view" || sub == "info" || sub == "outdated" |
| 600 | return effect |
| 601 | } |
| 602 | return unknownEffect(family, "npm subcommand effects are not statically known") |
| 603 | } |
| 604 | |
| 605 | func classifyCargo(args []string) CommandEffect { |
| 606 | if len(args) == 0 { |
| 607 | return unknownEffect("cargo", "missing cargo subcommand") |
| 608 | } |
| 609 | sub := strings.ToLower(args[0]) |
| 610 | family := "cargo " + sub |
| 611 | if sub == "check" || sub == "doc" { |
| 612 | effect := knownWriter(family, WriteWorkspaceContent, "writes build artifacts and may run build scripts") |
| 613 | effect.ExecutesCode = true |
| 614 | return effect |
| 615 | } |
| 616 | if sub == "search" { |
| 617 | effect := knownReader(family) |
| 618 | effect.UsesNetwork = true |
| 619 | return effect |
| 620 | } |
| 621 | return unknownEffect(family, "cargo subcommand effects are not statically known") |
| 622 | } |
| 623 | |
| 624 | func knownReader(family string) CommandEffect { |
| 625 | return CommandEffect{Certainty: EffectKnown, PermissionSafe: true, CommandFamily: family} |
| 626 | } |
| 627 | func knownWriter(family string, writes WriteDomain, reason string) CommandEffect { |
| 628 | return CommandEffect{Certainty: EffectKnown, Writes: writes, CommandFamily: family, Reason: reason} |
| 629 | } |
| 630 | func unknownEffect(family, reason string) CommandEffect { |
| 631 | return CommandEffect{Certainty: EffectUnknown, CommandFamily: strings.TrimSpace(family), Reason: reason} |
| 632 | } |
| 633 | |
| 634 | func unknownCodeEffect(family, reason string) CommandEffect { |
| 635 | effect := unknownEffect(family, reason) |
| 636 | effect.ExecutesCode = true |
| 637 | return effect |
| 638 | } |
| 639 | |
| 640 | func joinFamily(base, sub string) string { |
| 641 | if sub == "" { |
| 642 | return base |
| 643 | } |
| 644 | return base + " " + sub |
| 645 | } |
| 646 | |
| 647 | func commandFamilyFromSource(command string) string { |
| 648 | features, ok := shellparse.AnalyzeApprovalFeatures(command) |
| 649 | if !ok || len(features.CommandPrefix) == 0 { |
| 650 | return "shell" |
| 651 | } |
| 652 | base := strings.ToLower(strings.TrimSuffix(filepath.Base(features.CommandPrefix[0]), ".exe")) |
| 653 | if len(features.CommandPrefix) > 1 && slices.Contains([]string{"git", "go", "npm", "cargo", "docker", "kubectl"}, base) { |
| 654 | return joinFamily(base, strings.ToLower(features.CommandPrefix[1])) |
| 655 | } |
| 656 | return base |
| 657 | } |
| 658 | |
| 659 | func bashHasUnsafeLifecycle(command string) bool { |
| 660 | file, err := shellparse.ParseBash(command) |
| 661 | if err != nil { |
| 662 | return false |
| 663 | } |
| 664 | unsafe := false |
| 665 | syntax.Walk(file, func(node syntax.Node) bool { |
| 666 | stmt, ok := node.(*syntax.Stmt) |
| 667 | if ok && (stmt.Background || stmt.Coprocess || stmt.Disown) { |
| 668 | unsafe = true |
| 669 | return false |
| 670 | } |
| 671 | return !unsafe |
| 672 | }) |
| 673 | return unsafe |
| 674 | } |
| 675 | |
| 676 | func staticFieldsWithOutputRedirect(command string) ([]string, bool, bool) { |
| 677 | file, err := shellparse.ParseBash(command) |
| 678 | if err != nil || shellparse.HasHereDoc(file) || len(file.Stmts) != 1 { |
| 679 | return nil, false, false |
| 680 | } |
| 681 | stmt := file.Stmts[0] |
| 682 | call, ok := stmt.Cmd.(*syntax.CallExpr) |
| 683 | if !ok || len(call.Assigns) > 0 || stmt.Background || stmt.Negated || stmt.Coprocess || stmt.Disown { |
| 684 | return nil, false, false |
| 685 | } |
| 686 | fields := make([]string, 0, len(call.Args)) |
| 687 | for _, word := range call.Args { |
| 688 | field, static := shellparse.StaticWord(word) |
| 689 | if !static { |
| 690 | return nil, false, false |
| 691 | } |
| 692 | fields = append(fields, field) |
| 693 | } |
| 694 | writes := false |
| 695 | for _, redirect := range stmt.Redirs { |
| 696 | switch redirect.Op { |
| 697 | case syntax.RdrOut, syntax.AppOut, syntax.RdrClob, syntax.AppClob, syntax.RdrAll, syntax.AppAll, syntax.RdrAllClob, syntax.AppAllClob: |
| 698 | writes = true |
| 699 | default: |
| 700 | return nil, false, false |
| 701 | } |
| 702 | } |
| 703 | return fields, writes, len(fields) > 0 |
| 704 | } |
| 705 | |
| 706 | func branchMutationArg(arg string) bool { |
| 707 | return slices.Contains([]string{"-d", "-D", "--delete", "--no-delete", "-m", "-M", "--move", "--no-move", "-c", "-C", "--copy", "--no-copy", "-f", "--force", "--no-force", "-t", "--track", "--no-track", "-u", "--set-upstream-to", "--unset-upstream", "--edit-description", "--create-reflog", "--recurse-submodules"}, strings.SplitN(arg, "=", 2)[0]) |
| 708 | } |
| 709 | func branchValueOption(arg string) bool { |
| 710 | return slices.Contains([]string{"--contains", "--no-contains", "--merged", "--no-merged", "--points-at", "--sort", "--format", "--color", "--column", "--abbrev"}, strings.SplitN(arg, "=", 2)[0]) |
| 711 | } |
| 712 | func tagMutationArg(arg string) bool { |
| 713 | return slices.Contains([]string{"-d", "--delete", "-a", "--annotate", "-s", "--sign", "-u", "--local-user", "-f", "--force", "-m", "--message", "-F", "--file", "-e", "--edit", "--trailer", "--cleanup", "--create-reflog"}, strings.SplitN(arg, "=", 2)[0]) |
| 714 | } |
| 715 | func tagFlagOption(arg string) bool { |
| 716 | return slices.Contains([]string{"--contains", "--no-contains", "--merged", "--no-merged", "--points-at", "--sort", "--format", "--column", "--no-column", "--color", "--no-color", "--omit-empty", "--no-omit-empty"}, strings.SplitN(arg, "=", 2)[0]) |
| 717 | } |
| 718 | |
| 719 | func tagOptionConsumesNext(arg string) bool { |
| 720 | return !strings.Contains(arg, "=") && slices.Contains([]string{"--contains", "--no-contains", "--merged", "--no-merged", "--points-at", "--sort", "--format"}, arg) |
| 721 | } |
| 722 | |
| 723 | func configReadAction(arg string) bool { |
| 724 | return slices.Contains([]string{"get", "get-all", "get-regexp", "get-urlmatch", "list", "--get", "--get-all", "--get-regexp", "--get-urlmatch", "--list", "--get-color", "--get-colorbool"}, strings.ToLower(arg)) |
| 725 | } |
| 726 | |
| 727 | func containsConfigReadAction(args []string) bool { return slices.ContainsFunc(args, configReadAction) } |
| 728 | func configOptionConsumesNext(arg string) bool { |
| 729 | return slices.Contains([]string{"--file", "-f", "--blob", "--type", "--default", "--fixed-value"}, strings.ToLower(arg)) |
| 730 | } |
| 731 | |
| 732 | func hasEffectArg(args []string, candidates ...string) bool { |
| 733 | return slices.ContainsFunc(args, func(arg string) bool { |
| 734 | return slices.Contains(candidates, strings.SplitN(arg, "=", 2)[0]) |
| 735 | }) |
| 736 | } |
| 737 | |
| 738 | func hasArgPrefix(args []string, prefix string) bool { |
| 739 | return slices.ContainsFunc(args, func(arg string) bool { return strings.HasPrefix(arg, prefix) }) |
| 740 | } |
| 741 | func allEffectArgs(args []string, allowed ...string) bool { |
| 742 | return len(args) > 0 && !slices.ContainsFunc(args, func(arg string) bool { return !slices.Contains(allowed, arg) }) |
| 743 | } |
| 744 | func hasOutputArg(args []string) bool { |
| 745 | return hasEffectArg(args, "-o", "--output") || hasArgPrefix(args, "-o") |
| 746 | } |
| 747 | func shortOptionContains(arg string, flag byte) bool { |
| 748 | return len(arg) > 1 && arg[0] == '-' && arg[1] != '-' && strings.ContainsRune(arg[1:], rune(flag)) |
| 749 | } |
| 750 |