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() }