Files
esengine--deepseek-reasonix/internal/hook/command_normalize.go
T
2026-07-13 13:00:08 +08:00

397 lines
9.5 KiB
Go

package hook
import (
"strings"
"reasonix/internal/shellparse"
)
// NormalizeCommand repairs narrow classes of copied JSON-escaped hook commands.
// It is intentionally conservative: hook commands can be security controls, so
// only commands that were already malformed and match a known hook shape are
// rewritten.
func NormalizeCommand(command string) string {
if fixed, ok := normalizeStaticNodeEval(command); ok {
return fixed
}
if fixed, ok := normalizeEscapedNodeEval(command); ok {
return fixed
}
if fixed, ok := normalizeEscapedPowerShellFile(command); ok {
return fixed
}
return command
}
func normalizeStaticNodeEval(command string) (string, bool) {
node, flag, script, ok := repairableNodeEvalArgs(command)
if !ok {
return "", false
}
return renderNodeEvalCommand(node, flag, script), true
}
func directNodeEvalArgs(command string) (string, string, string, bool) {
fields, malformed := shellparse.StaticFields(command)
if malformed == "" && len(fields) == 3 && isNodeCommand(fields[0]) && isNodeEvalFlag(fields[1]) {
script, ok := repairQuotedNodeEvalScript(fields[2])
if ok {
return fields[0], fields[1], script, true
}
if isHookStdinNodeEval(fields[2]) {
return fields[0], fields[1], fields[2], true
}
}
node, flag, script, ok := escapedNodeEvalArgs(command)
return node, flag, script, ok
}
func repairableNodeEvalArgs(command string) (string, string, string, bool) {
fields, malformed := shellparse.StaticFields(command)
if malformed == "" && len(fields) == 3 && isNodeCommand(fields[0]) && isNodeEvalFlag(fields[1]) {
script, ok := repairQuotedNodeEvalScript(fields[2])
if ok {
return fields[0], fields[1], script, true
}
}
return escapedNodeEvalArgs(command)
}
func normalizeEscapedPowerShellFile(command string) (string, bool) {
trimmed := strings.TrimSpace(command)
_, spans, ok := repairablePowerShellFileParse(trimmed)
if !ok || len(spans) == 0 {
return "", false
}
// Replace only the escaped-quote sequences found during parsing, so
// well-formed sibling arguments keep their bytes verbatim.
var b strings.Builder
prev := 0
for _, span := range spans {
b.WriteString(trimmed[prev:span[0]])
b.WriteByte('"')
prev = span[1]
}
b.WriteString(trimmed[prev:])
return b.String(), true
}
func repairablePowerShellFileArgs(command string) (string, []string, bool) {
fields, _, ok := repairablePowerShellFileParse(command)
if !ok {
return "", nil, false
}
return fields[0], fields[1:], true
}
func repairablePowerShellFileParse(command string) ([]string, [][2]int, bool) {
fields, repaired, spans, ok := parseSimpleHookCommandFields(command)
if !ok || len(fields) < 3 || !isPowerShellCommand(fields[0]) {
return nil, nil, false
}
fileIdx := powerShellFileFlagIndex(fields)
if fileIdx < 0 || fileIdx+1 >= len(fields) {
return nil, nil, false
}
if !powerShellFileRepairApplies(repaired, fileIdx) {
return nil, nil, false
}
return fields, spans, true
}
func powerShellFileRepairApplies(repaired []bool, fileIdx int) bool {
for i, ok := range repaired {
if !ok {
continue
}
if i == 0 || i > fileIdx {
return true
}
}
return false
}
func powerShellFileFlagIndex(fields []string) int {
for i := 1; i < len(fields); i++ {
if strings.EqualFold(fields[i], "-File") {
return i
}
}
return -1
}
func parseSimpleHookCommandFields(command string) ([]string, []bool, [][2]int, bool) {
// A newline is a shell command separator, not argument whitespace; leave
// multi-command strings alone like other compound commands.
if strings.ContainsAny(command, "\n\r") {
return nil, nil, nil, false
}
s := strings.TrimSpace(command)
fields := []string{}
repaired := []bool{}
spans := [][2]int{}
for i := 0; i < len(s); {
for i < len(s) && isShellWhitespace(s[i]) {
i++
}
if i >= len(s) {
break
}
var b strings.Builder
fieldStarted := false
fieldRepaired := false
var quote byte
escapedQuote := false
for i < len(s) {
c := s[i]
if quote == 0 {
if isShellWhitespace(c) {
break
}
if isShellControl(c) {
return nil, nil, nil, false
}
if n := escapedShellQuoteLen(s, i); n > 0 {
quote = '"'
escapedQuote = true
fieldStarted = true
fieldRepaired = true
spans = append(spans, [2]int{i, i + n})
i += n
continue
}
if c == '"' || c == '\'' {
quote = c
escapedQuote = false
fieldStarted = true
i++
continue
}
b.WriteByte(c)
fieldStarted = true
i++
continue
}
if escapedQuote {
if n := escapedShellQuoteLen(s, i); n > 0 {
quote = 0
escapedQuote = false
fieldRepaired = true
spans = append(spans, [2]int{i, i + n})
i += n
continue
}
b.WriteByte(c)
i++
continue
}
if c == quote {
quote = 0
i++
continue
}
if quote == '"' && c == '\\' && i+1 < len(s) && isDoubleQuoteEscapedByte(s[i+1]) {
b.WriteByte(s[i+1])
i += 2
continue
}
b.WriteByte(c)
i++
}
if quote != 0 {
return nil, nil, nil, false
}
if !fieldStarted {
return nil, nil, nil, false
}
fields = append(fields, b.String())
repaired = append(repaired, fieldRepaired)
for i < len(s) && isShellWhitespace(s[i]) {
i++
}
}
return fields, repaired, spans, len(fields) > 0
}
func escapedShellQuoteLen(s string, i int) int {
j := i
for j < len(s) && s[j] == '\\' {
j++
}
if j == i || j >= len(s) || s[j] != '"' {
return 0
}
return j - i + 1
}
func isShellWhitespace(c byte) bool {
return c == ' ' || c == '\t'
}
func isShellControl(c byte) bool {
switch c {
case '&', '|', ';', '<', '>':
return true
default:
return false
}
}
func isDoubleQuoteEscapedByte(c byte) bool {
switch c {
case '"', '\\', '$', '`', '\n':
return true
default:
return false
}
}
func normalizeEscapedNodeEval(command string) (string, bool) {
node, flag, script, ok := escapedNodeEvalArgs(command)
if !ok {
return "", false
}
return renderNodeEvalCommand(node, flag, script), true
}
func escapedNodeEvalArgs(command string) (string, string, string, bool) {
command = strings.TrimSpace(command)
for _, prefix := range []struct {
raw string
node string
flag string
}{
{raw: `node -e `, node: "node", flag: "-e"},
{raw: `node --eval `, node: "node", flag: "--eval"},
{raw: `node.exe -e `, node: "node.exe", flag: "-e"},
{raw: `node.exe --eval `, node: "node.exe", flag: "--eval"},
} {
rest, ok := strings.CutPrefix(command, prefix.raw)
if !ok {
continue
}
script, ok := trimEscapedQuotes(strings.TrimSpace(rest))
if !ok || !isHookStdinNodeEval(script) {
return "", "", "", false
}
return prefix.node, prefix.flag, script, true
}
return "", "", "", false
}
func repairQuotedNodeEvalScript(script string) (string, bool) {
for _, trim := range []func(string) (string, bool){
trimDoubleQuotes,
trimEscapedQuotes,
trimBackslashEscapedQuotes,
} {
if candidate, ok := trim(strings.TrimSpace(script)); ok && isHookStdinNodeEval(candidate) {
return candidate, true
}
}
return "", false
}
func trimDoubleQuotes(s string) (string, bool) {
if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
return "", false
}
return s[1 : len(s)-1], true
}
func trimEscapedQuotes(s string) (string, bool) {
if len(s) < 4 || !strings.HasPrefix(s, `\"`) || !strings.HasSuffix(s, `\"`) {
return "", false
}
return unescapeJSONStyleQuotes(s[2 : len(s)-2]), true
}
func trimBackslashEscapedQuotes(s string) (string, bool) {
if len(s) < 6 || !strings.HasPrefix(s, `\\"`) || !strings.HasSuffix(s, `\\"`) {
return "", false
}
return unescapeJSONStyleQuotes(s[3 : len(s)-3]), true
}
func unescapeJSONStyleQuotes(s string) string {
return strings.ReplaceAll(s, `\"`, `"`)
}
func isHookStdinNodeEval(script string) bool {
script = strings.TrimSpace(script)
if script == "" {
return false
}
if !startsLikeJSStatement(script) {
return false
}
return strings.Contains(script, "JSON.parse") &&
(strings.Contains(script, "readFileSync(0") || strings.Contains(script, "readFileSync( 0") || strings.Contains(script, "process.stdin"))
}
func startsLikeJSStatement(script string) bool {
for _, prefix := range []string{
"const ", "const\t", "let ", "let\t", "var ", "var\t",
"import ", "require(", "if ", "if(", "try ", "(async ", "async ",
} {
if strings.HasPrefix(script, prefix) {
return true
}
}
return false
}
func isNodeCommand(command string) bool {
base := strings.ToLower(shellparse.WordBase(command))
return base == "node" || base == "node.exe"
}
func isPowerShellCommand(command string) bool {
base := strings.ToLower(command)
if i := strings.LastIndexAny(base, `/\`); i >= 0 {
base = base[i+1:]
}
return base == "powershell" || base == "powershell.exe" || base == "pwsh" || base == "pwsh.exe"
}
func isNodeEvalFlag(flag string) bool {
return flag == "-e" || flag == "--eval"
}
func renderNodeEvalCommand(node, flag, script string) string {
return shellField(node) + " " + shellField(flag) + " " + shellDoubleQuote(script)
}
func shellField(s string) string {
if s != "" {
safe := true
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
r == '_' || r == '-' || r == '.' || r == '/' || r == ':' || r == '\\' {
continue
}
safe = false
break
}
if safe {
return s
}
}
return shellDoubleQuote(s)
}
func shellDoubleQuote(s string) string {
var b strings.Builder
b.Grow(len(s) + 2)
b.WriteByte('"')
for _, r := range s {
switch r {
case '\\', '"', '$', '`':
b.WriteByte('\\')
}
b.WriteRune(r)
}
b.WriteByte('"')
return b.String()
}