Files
zzet--gortex/internal/hooks/bash_classify.go
T
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

267 lines
7.3 KiB
Go

package hooks
import "strings"
// BashAction describes what an enrichBash caller should do with a Bash command.
type BashAction int
const (
// BashActionPassthrough means the command isn't a codebase search — allow.
BashActionPassthrough BashAction = iota
// BashActionGrepLike means the command is a primary grep/rg/ag invocation.
// Pattern holds the extracted search pattern for the daemon probe.
BashActionGrepLike
// BashActionFindName means the command is `find … -name "<symbol>…"`.
// Pattern holds the name with leading/trailing `*`/`?` stripped.
BashActionFindName
// BashActionReadSource means the command reads an indexed-looking source
// file (cat/head/tail of .go/.ts/…). Path holds the file path.
BashActionReadSource
)
// BashClassification is the result of classifyBashCommand.
type BashClassification struct {
Action BashAction
Pattern string // for GrepLike / FindName
Path string // for ReadSource
Primary string // the primary command token (grep, rg, cat, …) — for messages
}
// classifyBashCommand inspects a Bash tool_input.command and returns the first
// actionable classification it finds across the command's *primary* segments
// (start-of-line or after ; && ||; a segment after a single `|` is a filter on
// upstream output and is ignored).
//
// The parser is intentionally small. It respects single/double quotes but
// does NOT attempt to handle escapes, subshells ($() / backticks), heredocs,
// or redirects. Anything it can't classify confidently falls through to
// BashActionPassthrough — the conservative answer since a false deny is more
// disruptive than a miss.
func classifyBashCommand(cmd string) BashClassification {
for _, seg := range primarySegments(cmd) {
tokens := tokenize(seg)
if len(tokens) == 0 {
continue
}
// Skip over `sudo` / `time` / env assignments (FOO=bar cmd).
for len(tokens) > 0 && (tokens[0] == "sudo" || tokens[0] == "time" || strings.Contains(tokens[0], "=")) {
if strings.Contains(tokens[0], "=") && !strings.HasPrefix(tokens[0], "-") {
tokens = tokens[1:]
continue
}
if tokens[0] == "sudo" || tokens[0] == "time" {
tokens = tokens[1:]
continue
}
break
}
if len(tokens) == 0 {
continue
}
switch tokens[0] {
case "grep", "rg", "ag", "egrep", "fgrep":
if p, ok := extractGrepPattern(tokens); ok {
return BashClassification{Action: BashActionGrepLike, Pattern: p, Primary: tokens[0]}
}
case "find":
if p, ok := extractFindName(tokens); ok {
return BashClassification{Action: BashActionFindName, Pattern: p, Primary: tokens[0]}
}
case "cat", "head", "tail":
if path, ok := extractReadFile(tokens); ok {
return BashClassification{Action: BashActionReadSource, Path: path, Primary: tokens[0]}
}
}
}
return BashClassification{Action: BashActionPassthrough}
}
// primarySegments splits cmd on ; && || and returns the segments that are
// primary commands (everything up to the first `|` inside each statement).
// Segments that appear after a single `|` are not primary and are dropped.
func primarySegments(cmd string) []string {
var out []string
var cur strings.Builder
inSingle, inDouble := false, false
primary := true
i, n := 0, len(cmd)
flush := func() {
if primary {
s := strings.TrimSpace(cur.String())
if s != "" {
out = append(out, s)
}
}
cur.Reset()
}
for i < n {
c := cmd[i]
if c == '\'' && !inDouble {
inSingle = !inSingle
cur.WriteByte(c)
i++
continue
}
if c == '"' && !inSingle {
inDouble = !inDouble
cur.WriteByte(c)
i++
continue
}
if inSingle || inDouble {
cur.WriteByte(c)
i++
continue
}
// Statement boundaries reset "primary" to true.
if c == ';' {
flush()
primary = true
i++
continue
}
if c == '&' && i+1 < n && cmd[i+1] == '&' {
flush()
primary = true
i += 2
continue
}
if c == '|' && i+1 < n && cmd[i+1] == '|' {
flush()
primary = true
i += 2
continue
}
// Single `|` ends primary; stay non-primary until next statement.
if c == '|' {
flush()
primary = false
i++
continue
}
cur.WriteByte(c)
i++
}
flush()
return out
}
// tokenize splits a primary-segment string on whitespace, treating
// single/double-quoted runs as one token (with the quotes stripped).
func tokenize(seg string) []string {
var out []string
var cur strings.Builder
inSingle, inDouble := false, false
hasContent := false
flush := func() {
if hasContent {
out = append(out, cur.String())
}
cur.Reset()
hasContent = false
}
for i := 0; i < len(seg); i++ {
c := seg[i]
if c == '\'' && !inDouble {
inSingle = !inSingle
hasContent = true // empty quoted string is still a token
continue
}
if c == '"' && !inSingle {
inDouble = !inDouble
hasContent = true
continue
}
if !inSingle && !inDouble && (c == ' ' || c == '\t') {
flush()
continue
}
cur.WriteByte(c)
hasContent = true
}
flush()
return out
}
// grepFlagsTakingArg is the short-form flag set for grep/rg where the next
// token is consumed as an argument, not a positional. Kept small — covers
// the flags I actually see in practice.
var grepFlagsTakingArg = map[string]bool{
"-e": true, "-f": true, "-A": true, "-B": true, "-C": true,
"-m": true, "--max-count": true,
"--include": true, "--exclude": true,
"--regexp": true, "--file": true,
"-t": true, "-T": true, // rg type / type-not
"-g": true, // rg glob
}
// extractGrepPattern walks tokens after `grep`/`rg` and returns the search
// pattern — either the argument of `-e`/`--regexp` (which IS the pattern),
// or the first non-flag positional. Returns ok=false if no pattern is
// present (e.g. `grep -h` help invocation).
func extractGrepPattern(tokens []string) (string, bool) {
// tokens[0] is the command itself.
i := 1
for i < len(tokens) {
t := tokens[i]
// -e PATTERN / --regexp PATTERN / --file PATH: the next token is the
// pattern itself (except --file, but we treat it the same for our
// purposes — it'll be gated by classifyGrepPattern anyway).
if t == "-e" || t == "--regexp" {
if i+1 < len(tokens) {
return tokens[i+1], true
}
return "", false
}
// --flag=value: one token, skip.
if strings.HasPrefix(t, "--") && strings.Contains(t, "=") {
i++
continue
}
if grepFlagsTakingArg[t] {
i += 2
continue
}
if strings.HasPrefix(t, "-") && t != "-" {
i++
continue
}
return t, true
}
return "", false
}
// extractFindName walks tokens looking for `-name`/`-iname` and returns the
// next token with leading/trailing `*`?“ stripped. Non-symbol-looking
// residues still return ok=true; the caller (enrichBash) gates on
// classifyGrepPattern.
func extractFindName(tokens []string) (string, bool) {
for i := 1; i < len(tokens)-1; i++ {
if tokens[i] == "-name" || tokens[i] == "-iname" {
val := tokens[i+1]
val = strings.Trim(val, "*?")
return val, true
}
}
return "", false
}
// extractReadFile returns the last token that looks like a source file path,
// skipping flags. For cat/head/tail the file is usually the last positional,
// so walking in reverse is the simplest match.
func extractReadFile(tokens []string) (string, bool) {
for i := len(tokens) - 1; i >= 1; i-- {
t := tokens[i]
if strings.HasPrefix(t, "-") {
continue
}
if looksLikeSourceFile(t) {
return t, true
}
// First non-flag, non-source token — not a source read.
return "", false
}
return "", false
}