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

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new 388d4b88e [hotfix][java][python] Harden Bash arithmetic evaluation 
(#1083)
388d4b88e is described below

commit 388d4b88e3dbd6d7a28d5a16ed386897731c2521
Author: Wenjin Xie <[email protected]>
AuthorDate: Wed Sep 2 09:41:13 2026 +0800

    [hotfix][java][python] Harden Bash arithmetic evaluation (#1083)
    
    Generated-by: Codex (GPT-5)
    
    Co-authored-by: Codex <[email protected]>
---
 .../agents/plan/tools/bash/BashValidator.java      | 14 +++++---
 .../flink/agents/plan/tools/bash/BashToolTest.java | 31 ++++++++++++++++
 .../agents/plan/tools/bash/BashValidatorTest.java  | 29 +++++++++++++--
 .../flink_agents/plan/tools/bash/bash_validator.py | 13 +++----
 .../plan/tools/bash/tests/test_bash_tool.py        | 41 ++++++++++++++++++++++
 5 files changed, 115 insertions(+), 13 deletions(-)

diff --git 
a/plan/src/main/java/org/apache/flink/agents/plan/tools/bash/BashValidator.java 
b/plan/src/main/java/org/apache/flink/agents/plan/tools/bash/BashValidator.java
index 2d938ecfb..4ca6401bc 100644
--- 
a/plan/src/main/java/org/apache/flink/agents/plan/tools/bash/BashValidator.java
+++ 
b/plan/src/main/java/org/apache/flink/agents/plan/tools/bash/BashValidator.java
@@ -56,8 +56,6 @@ public final class BashValidator {
                     "program",
                     "command",
                     "command_name",
-                    // `export VAR=...`, `readonly`, `declare`, `local`, 
`typeset`
-                    "declaration_command",
                     "pipeline",
                     "list",
                     "redirected_statement",
@@ -76,7 +74,6 @@ public final class BashValidator {
                     "number",
                     "simple_expansion", // $VAR
                     "expansion", // ${VAR}
-                    "arithmetic_expansion", // $((...))
                     "binary_expression",
                     "unary_expression",
                     "parenthesized_expression",
@@ -143,6 +140,12 @@ public final class BashValidator {
             return Optional.of(
                     "Disallowed shell construct '" + node.getType() + "' in: 
'" + snippet + "'");
         }
+        TSNode parent = node.getParent();
+        if ("variable_assignment".equals(node.getType())
+                && (parent == null || parent.isNull() || 
!"command".equals(parent.getType()))) {
+            return Optional.of(
+                    "Standalone variable assignment without an executable is 
not allowed.");
+        }
         if ("command".equals(node.getType())) {
             Optional<String> err =
                     validateCommand(node, command, allowedCommands, 
allowedScriptDirs, cwd);
@@ -168,8 +171,9 @@ public final class BashValidator {
             @Nullable String cwd) {
         TSNode nameNode = commandNode.getChildByFieldName("name");
         if (nameNode == null || nameNode.isNull()) {
-            // Bare variable-assignment parsed as command — nothing to 
validate.
-            return Optional.empty();
+            // Fail closed for constructs such as bare variable assignments. 
Bash can later
+            // reinterpret their values in arithmetic contexts.
+            return Optional.of("Command without an executable is not 
allowed.");
         }
         String executable = nodeText(nameNode, command);
         if (allowedCommands.contains(executable)) {
diff --git 
a/plan/src/test/java/org/apache/flink/agents/plan/tools/bash/BashToolTest.java 
b/plan/src/test/java/org/apache/flink/agents/plan/tools/bash/BashToolTest.java
index 3a134923e..76698e6ec 100644
--- 
a/plan/src/test/java/org/apache/flink/agents/plan/tools/bash/BashToolTest.java
+++ 
b/plan/src/test/java/org/apache/flink/agents/plan/tools/bash/BashToolTest.java
@@ -23,12 +23,15 @@ import 
org.apache.flink.agents.api.resource.ResourceDescriptor;
 import org.apache.flink.agents.api.tools.ToolParameters;
 import org.apache.flink.agents.api.tools.ToolResponse;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 
+import java.nio.file.Path;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class BashToolTest {
@@ -70,6 +73,34 @@ class BashToolTest {
         assertTrue(out.startsWith("Command rejected:"));
     }
 
+    @Test
+    void integerDeclarationCannotReevaluateCommandSubstitution(@TempDir Path 
tempDir) {
+        Path marker = tempDir.resolve("integer-declaration-marker");
+        ToolResponse r =
+                tool().call(
+                                args(
+                                        "declare -i VALUE='$(touch " + marker 
+ ")'",
+                                        List.of(),
+                                        List.of()));
+        String out = (String) r.getResult();
+        assertTrue(out.startsWith("Command rejected:"));
+        assertFalse(marker.toFile().exists());
+    }
+
+    @Test
+    void arithmeticExpansionCannotReevaluateAssignedValue(@TempDir Path 
tempDir) {
+        Path marker = tempDir.resolve("arithmetic-expansion-marker");
+        ToolResponse r =
+                tool().call(
+                                args(
+                                        "VALUE='$(touch " + marker + ")'; echo 
$((VALUE))",
+                                        List.of("echo"),
+                                        List.of()));
+        String out = (String) r.getResult();
+        assertTrue(out.startsWith("Command rejected:"));
+        assertFalse(marker.toFile().exists());
+    }
+
     @Test
     void successfulCommandWithEmptyOutput() {
         ToolResponse r = tool().call(args("true", List.of("true"), List.of()));
diff --git 
a/plan/src/test/java/org/apache/flink/agents/plan/tools/bash/BashValidatorTest.java
 
b/plan/src/test/java/org/apache/flink/agents/plan/tools/bash/BashValidatorTest.java
index 7ff28f071..6dc967df0 100644
--- 
a/plan/src/test/java/org/apache/flink/agents/plan/tools/bash/BashValidatorTest.java
+++ 
b/plan/src/test/java/org/apache/flink/agents/plan/tools/bash/BashValidatorTest.java
@@ -76,10 +76,35 @@ class BashValidatorTest {
     }
 
     @Test
-    void arithmeticExpansionAllowed() {
+    void arithmeticExpansionRejected() {
+        Optional<String> r =
+                BashValidator.validate("echo $((1+2))", List.of("echo"), 
List.of(), null);
+        assertTrue(r.isPresent());
+        assertTrue(r.get().contains("arithmetic_expansion"));
+    }
+
+    @Test
+    void declarationCommandRejected() {
+        Optional<String> r =
+                BashValidator.validate(
+                        "declare -i VALUE='1 + 2'", List.of("echo"), 
List.of(), null);
+        assertTrue(r.isPresent());
+        assertTrue(r.get().contains("declaration_command"));
+    }
+
+    @Test
+    void standaloneVariableAssignmentRejected() {
+        Optional<String> r =
+                BashValidator.validate("VALUE='1 + 2'", List.of("echo"), 
List.of(), null);
+        assertTrue(r.isPresent());
+        assertTrue(r.get().contains("executable"));
+    }
+
+    @Test
+    void variableAssignmentPrefixWithAllowedCommandPasses() {
         assertEquals(
                 Optional.empty(),
-                BashValidator.validate("echo $((1+2))", List.of("echo"), 
List.of(), null));
+                BashValidator.validate("VALUE=abc echo hi", List.of("echo"), 
List.of(), null));
     }
 
     @Test
diff --git a/python/flink_agents/plan/tools/bash/bash_validator.py 
b/python/flink_agents/plan/tools/bash/bash_validator.py
index d5b9c20f8..c87dea757 100644
--- a/python/flink_agents/plan/tools/bash/bash_validator.py
+++ b/python/flink_agents/plan/tools/bash/bash_validator.py
@@ -47,8 +47,6 @@ _ALLOWED_NAMED = frozenset(
         "program",
         "command",
         "command_name",
-        # `export VAR=...`, `readonly`, `declare`, `local`, `typeset`
-        "declaration_command",
         "pipeline",
         "list",
         "redirected_statement",
@@ -67,7 +65,6 @@ _ALLOWED_NAMED = frozenset(
         "number",
         "simple_expansion",  # $VAR
         "expansion",  # ${VAR}
-        "arithmetic_expansion",  # $((...))
         "binary_expression",
         "unary_expression",
         "parenthesized_expression",
@@ -121,6 +118,10 @@ def _walk(
     if node.is_named and node.type not in _ALLOWED_NAMED:
         snippet = node.text.decode("utf-8", errors="replace")[:80]
         return f"Disallowed shell construct '{node.type}' in: {snippet!r}"
+    if node.type == "variable_assignment" and (
+        node.parent is None or node.parent.type != "command"
+    ):
+        return "Standalone variable assignment without an executable is not 
allowed."
     if node.type == "command":
         err = _validate_command_node(node, allowed_commands, 
allowed_script_dirs, cwd)
         if err is not None:
@@ -140,9 +141,9 @@ def _validate_command_node(
 ) -> str | None:
     name_node = node.child_by_field_name("name")
     if name_node is None:
-        # Commands without a resolvable name (edge case, e.g. bare
-        # variable-assignment parsed as `command`) — nothing to validate.
-        return None
+        # Fail closed for constructs such as bare variable assignments. Bash
+        # can later reinterpret their values in arithmetic contexts.
+        return "Command without an executable is not allowed."
     executable = name_node.text.decode("utf-8", errors="replace")
     if executable in allowed_commands:
         return None
diff --git a/python/flink_agents/plan/tools/bash/tests/test_bash_tool.py 
b/python/flink_agents/plan/tools/bash/tests/test_bash_tool.py
index e05936a0d..3f6048fec 100644
--- a/python/flink_agents/plan/tools/bash/tests/test_bash_tool.py
+++ b/python/flink_agents/plan/tools/bash/tests/test_bash_tool.py
@@ -116,6 +116,11 @@ class TestValidateCommand:
     def test_reject_env_prefix_command_not_whitelisted(self) -> None:
         assert validate_command("FOO=bar rm -rf /", ["echo"], []) is not None
 
+    def test_reject_standalone_variable_assignment(self) -> None:
+        error = validate_command("VALUE='1 + 2'", ["echo"], [])
+        assert error is not None
+        assert "executable" in error
+
     # -- injection vectors: MUST be rejected ------------------------------
 
     def test_reject_dollar_paren_substitution(self) -> None:
@@ -165,6 +170,16 @@ class TestValidateCommand:
         error = validate_command("f() { echo hi; }", ["echo"], [])
         assert error is not None
 
+    def test_reject_declaration_command(self) -> None:
+        error = validate_command("declare -i VALUE='1 + 2'", ["echo"], [])
+        assert error is not None
+        assert "declaration_command" in error
+
+    def test_reject_arithmetic_expansion(self) -> None:
+        error = validate_command("echo $((1 + 2))", ["echo"], [])
+        assert error is not None
+        assert "arithmetic_expansion" in error
+
     def test_reject_heredoc(self) -> None:
         error = validate_command("cat <<EOF\n$(rm /)\nEOF", ["cat"], [])
         assert error is not None
@@ -237,6 +252,32 @@ class TestBashTool:
         )
         assert "Command rejected" in result
 
+    def test_reject_substitution_re_evaluated_by_integer_declaration(
+        self, tool: BashTool, tmp_path: Path
+    ) -> None:
+        marker = tmp_path / "integer-declaration-marker"
+        result = tool.call(
+            command=f"declare -i VALUE='$(touch {marker})'",
+            timeout=10,
+            allowed_commands=[],
+            allowed_script_dirs=[],
+        )
+        assert "Command rejected" in result
+        assert not marker.exists()
+
+    def test_reject_substitution_re_evaluated_by_arithmetic_expansion(
+        self, tool: BashTool, tmp_path: Path
+    ) -> None:
+        marker = tmp_path / "arithmetic-expansion-marker"
+        result = tool.call(
+            command=f"VALUE='$(touch {marker})'; echo $((VALUE))",
+            timeout=10,
+            allowed_commands=["echo"],
+            allowed_script_dirs=[],
+        )
+        assert "Command rejected" in result
+        assert not marker.exists()
+
     def test_no_allowed_commands_rejects_everything(self, tool: BashTool) -> 
None:
         result = tool.call(command="echo hello", timeout=10)
         assert "Command rejected" in result

Reply via email to