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
267 lines
7.3 KiB
Go
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
|
|
}
|