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 ce89eadec [plan][java][python] Reject unsafe Bash redirects (#1079)
ce89eadec is described below

commit ce89eadecace583ce1005f96b38ed923130d4e71
Author: hylin <[email protected]>
AuthorDate: Wed Sep 2 15:39:59 2026 +0800

    [plan][java][python] Reject unsafe Bash redirects (#1079)
    
    Generated-by: TRAE (proprietary model)
    
    Co-authored-by: linhongyu510 <[email protected]>
---
 docs/content/docs/development/skills.md            |  2 +
 .../docs/get-started/quickstart/skills_agent.md    |  1 +
 .../flink/agents/plan/tools/bash/BashTool.java     |  4 +-
 .../agents/plan/tools/bash/BashValidator.java      | 58 ++++++++++++++++++++++
 .../agents/plan/tools/bash/BashValidatorTest.java  | 54 ++++++++++++++++++--
 python/flink_agents/plan/tools/bash/bash_tool.py   |  3 ++
 .../flink_agents/plan/tools/bash/bash_validator.py | 49 +++++++++++++++++-
 .../plan/tools/bash/tests/test_bash_tool.py        | 50 +++++++++++++++++--
 8 files changed, 209 insertions(+), 12 deletions(-)

diff --git a/docs/content/docs/development/skills.md 
b/docs/content/docs/development/skills.md
index d07c84ed1..22c067492 100644
--- a/docs/content/docs/development/skills.md
+++ b/docs/content/docs/development/skills.md
@@ -197,6 +197,8 @@ public static ResourceDescriptor mathModel() {
 **Key points:**
 - `skills` lists the skill names (matching the `name` field in each 
`SKILL.md`) the agent may use.
 - `allowed_commands` is a whitelist of shell command names the built-in `bash` 
tool may execute. Any command not on the list is rejected, so keep it as narrow 
as the skills require (for example `echo` and `bc` for arithmetic).
+- The `bash` tool rejects redirects to files by default. File-descriptor 
duplication and closure, such as `2>&1` and `2>&-`, remain available.
+- Assignments to environment variables that can change command resolution or 
executable loading, including `PATH`, `BASH_ENV`, `ENV`, `SHELLOPTS`, `CDPATH`, 
`LD_*`, and `DYLD_*`, are rejected. The command allowlist is a validation 
boundary, not a general-purpose operating-system sandbox; do not allow shells, 
privilege wrappers, or other commands that can launch arbitrary executables.
 - The `load_skill` and `bash` tools are added automatically — you do not 
declare them in `tools`. They are added alongside any tools you do declare.
 - Make sure the system prompt instructs the agent to load the relevant skill 
before acting, for example: *"You must load the skill first and strictly follow 
its instructions."* Without this nudge, smaller models may answer directly 
instead of consulting the skill.
 
diff --git a/docs/content/docs/get-started/quickstart/skills_agent.md 
b/docs/content/docs/get-started/quickstart/skills_agent.md
index a95a276a9..df7d63196 100644
--- a/docs/content/docs/get-started/quickstart/skills_agent.md
+++ b/docs/content/docs/get-started/quickstart/skills_agent.md
@@ -191,6 +191,7 @@ public class MathAgent extends Agent {
 - `@skills`/`@Skills` declares a skill source. `Skills.from_package` (Python) 
loads skills bundled inside an installed package by `(package, resource)`; 
`Skills.fromClasspath` (Java) loads them from a classpath resource packaged in 
the jar.
 - A declared skill is exposed to a model only when the model lists it in 
`skills`. The `load_skill` and `bash` tools are then added automatically.
 - `allowed_commands` whitelists the shell commands the `bash` tool may run — 
keep it as narrow as the skill requires.
+- The built-in `bash` tool rejects file redirects and execution-changing 
environment assignments. File-descriptor duplication and closure remain 
supported; see the [Skills]({{< ref "docs/development/skills" >}}) 
documentation for the complete security contract.
 
 ### Integrate the Agent with Flink
 
diff --git 
a/plan/src/main/java/org/apache/flink/agents/plan/tools/bash/BashTool.java 
b/plan/src/main/java/org/apache/flink/agents/plan/tools/bash/BashTool.java
index 57f1dc08f..38cad1cf8 100644
--- a/plan/src/main/java/org/apache/flink/agents/plan/tools/bash/BashTool.java
+++ b/plan/src/main/java/org/apache/flink/agents/plan/tools/bash/BashTool.java
@@ -42,7 +42,9 @@ import java.util.concurrent.TimeUnit;
  *
  * <p>Mirrors the Python {@code 
flink_agents.plan.tools.bash.bash_tool.BashTool}. The framework
  * (e.g. {@code ChatModelAction}) injects {@code allowed_commands} and {@code 
allowed_script_dirs}
- * at call time; the model only sees {@code command}, {@code timeout} and 
{@code cwd}.
+ * at call time; the model only sees {@code command}, {@code timeout} and 
{@code cwd}. File
+ * redirects are rejected except for file-descriptor duplication and closure, 
and assignments to
+ * execution-changing environment variables are rejected.
  */
 public class BashTool extends Tool {
 
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 4ca6401bc..0a6b4ee08 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
@@ -79,6 +79,12 @@ public final class BashValidator {
                     "parenthesized_expression",
                     "array");
 
+    private static final Set<String> BLOCKED_ENVIRONMENT_VARIABLES =
+            Set.of("PATH", "BASH_ENV", "ENV", "SHELLOPTS", "CDPATH");
+    private static final Set<String> DYNAMIC_LOADER_VARIABLE_PREFIXES = 
Set.of("LD_", "DYLD_");
+    private static final Set<String> FD_REDIRECT_OPERATORS = Set.of("<&", 
">&");
+    private static final Set<String> FD_CLOSE_OPERATORS = Set.of("<&-", ">&-");
+
     private static final Object PARSER_LOCK = new Object();
     private static volatile TSParser parser;
 
@@ -146,6 +152,21 @@ public final class BashValidator {
             return Optional.of(
                     "Standalone variable assignment without an executable is 
not allowed.");
         }
+        if ("file_redirect".equals(node.getType()) && !isFdOnlyRedirect(node, 
command)) {
+            return Optional.of(
+                    "File redirects are not allowed; only file-descriptor 
duplication and closure "
+                            + "are permitted.");
+        }
+        if ("variable_assignment".equals(node.getType())) {
+            TSNode nameNode = node.getChildByFieldName("name");
+            if (nameNode != null && !nameNode.isNull()) {
+                String name = nodeText(nameNode, command);
+                if (isBlockedEnvironmentVariable(name)) {
+                    return Optional.of(
+                            "Environment variable assignment '" + name + "' is 
not allowed.");
+                }
+            }
+        }
         if ("command".equals(node.getType())) {
             Optional<String> err =
                     validateCommand(node, command, allowedCommands, 
allowedScriptDirs, cwd);
@@ -163,6 +184,43 @@ public final class BashValidator {
         return Optional.empty();
     }
 
+    private static boolean isFdOnlyRedirect(TSNode node, String command) {
+        String operator = null;
+        for (int i = 0; i < node.getChildCount(); i++) {
+            TSNode child = node.getChild(i);
+            if (!child.isNamed()) {
+                operator = child.getType();
+                break;
+            }
+        }
+        TSNode destination = node.getChildByFieldName("destination");
+        if (FD_CLOSE_OPERATORS.contains(operator)) {
+            return destination == null || destination.isNull();
+        }
+        if (!FD_REDIRECT_OPERATORS.contains(operator)
+                || destination == null
+                || destination.isNull()) {
+            return false;
+        }
+        if ("number".equals(destination.getType())) {
+            return true;
+        }
+        String destinationText = nodeText(destination, command);
+        return "word".equals(destination.getType()) && 
destinationText.matches("\\d+-");
+    }
+
+    private static boolean isBlockedEnvironmentVariable(String name) {
+        if (BLOCKED_ENVIRONMENT_VARIABLES.contains(name)) {
+            return true;
+        }
+        for (String prefix : DYNAMIC_LOADER_VARIABLE_PREFIXES) {
+            if (name.startsWith(prefix)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     private static Optional<String> validateCommand(
             TSNode commandNode,
             String command,
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 6dc967df0..3946b4c8b 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
@@ -107,6 +107,25 @@ class BashValidatorTest {
                 BashValidator.validate("VALUE=abc echo hi", List.of("echo"), 
List.of(), null));
     }
 
+    @Test
+    void executionChangingVariableAssignmentsRejected() {
+        for (String name :
+                List.of(
+                        "PATH",
+                        "BASH_ENV",
+                        "ENV",
+                        "SHELLOPTS",
+                        "CDPATH",
+                        "LD_PRELOAD",
+                        "LD_LIBRARY_PATH",
+                        "DYLD_INSERT_LIBRARIES")) {
+            assertEquals(
+                    Optional.of("Environment variable assignment '" + name + 
"' is not allowed."),
+                    BashValidator.validate(
+                            name + "=unsafe echo hi", List.of("echo"), 
List.of(), null));
+        }
+    }
+
     @Test
     void commandSubstitutionRejected() {
         Optional<String> r =
@@ -132,10 +151,35 @@ class BashValidatorTest {
     }
 
     @Test
-    void redirectAllowed() {
-        // basic redirect of allowed command should pass
-        Optional<String> r =
-                BashValidator.validate("echo hi > /tmp/x", List.of("echo"), 
List.of(), null);
-        assertEquals(Optional.empty(), r);
+    void fileRedirectsRejected() {
+        for (String command :
+                List.of(
+                        "echo hi > /tmp/out",
+                        "echo hi >> /tmp/out",
+                        "echo hi 2> /tmp/err",
+                        "echo hi < /tmp/in",
+                        "echo hi >2",
+                        "echo hi >&/tmp/out")) {
+            assertEquals(
+                    Optional.of(
+                            "File redirects are not allowed; only 
file-descriptor duplication and "
+                                    + "closure are permitted."),
+                    BashValidator.validate(command, List.of("echo"), 
List.of(), null));
+        }
+    }
+
+    @Test
+    void fileDescriptorOnlyRedirectsAllowed() {
+        for (String command :
+                List.of(
+                        "echo hi 2>&1",
+                        "echo hi >&2",
+                        "echo hi <&0",
+                        "echo hi 2>&-",
+                        "echo hi 3>&1-")) {
+            assertEquals(
+                    Optional.empty(),
+                    BashValidator.validate(command, List.of("echo"), 
List.of(), null));
+        }
     }
 }
diff --git a/python/flink_agents/plan/tools/bash/bash_tool.py 
b/python/flink_agents/plan/tools/bash/bash_tool.py
index c9fd1c887..40246b62e 100644
--- a/python/flink_agents/plan/tools/bash/bash_tool.py
+++ b/python/flink_agents/plan/tools/bash/bash_tool.py
@@ -67,6 +67,9 @@ class BashTool(Tool):
     Safety:
     - The first token of each sub-command must be in ``allowed_commands``, or
       resolve to a file under one of ``allowed_script_dirs``.
+    - File redirects are rejected; only file-descriptor duplication and closure
+      are allowed.
+    - Assignments to execution-changing environment variables are rejected.
     - ``allowed_commands`` and ``allowed_script_dirs`` are injected at call
       time by the framework (not visible to the LLM through ``args_schema``).
     """
diff --git a/python/flink_agents/plan/tools/bash/bash_validator.py 
b/python/flink_agents/plan/tools/bash/bash_validator.py
index c87dea757..ec28935cc 100644
--- a/python/flink_agents/plan/tools/bash/bash_validator.py
+++ b/python/flink_agents/plan/tools/bash/bash_validator.py
@@ -24,8 +24,9 @@ command to be rejected. Every ``command`` node's name is 
checked against the
 ``allowed_commands`` allowlist or resolved under ``allowed_script_dirs``.
 
 This lets the tool accept natural shell constructs like pipes, ``&&`` / ``||``
-chains and simple redirections while blocking common injection vectors 
(``$()``,
-backticks, heredoc bodies containing substitutions, control flow, etc.).
+chains and file-descriptor-only redirections while blocking common injection
+vectors (``$()``, backticks, file redirects, execution-changing environment
+assignments, control flow, etc.).
 """
 
 from __future__ import annotations
@@ -72,6 +73,13 @@ _ALLOWED_NAMED = frozenset(
     }
 )
 
+_BLOCKED_ENVIRONMENT_VARIABLES = frozenset(
+    {"PATH", "BASH_ENV", "ENV", "SHELLOPTS", "CDPATH"}
+)
+_DYNAMIC_LOADER_VARIABLE_PREFIXES = ("LD_", "DYLD_")
+_FD_REDIRECT_OPERATORS = frozenset({"<&", ">&"})
+_FD_CLOSE_OPERATORS = frozenset({"<&-", ">&-"})
+
 
 @lru_cache(maxsize=1)
 def _get_parser() -> Parser:
@@ -122,6 +130,17 @@ def _walk(
         node.parent is None or node.parent.type != "command"
     ):
         return "Standalone variable assignment without an executable is not 
allowed."
+    if node.type == "file_redirect" and not _is_fd_only_redirect(node):
+        return (
+            "File redirects are not allowed; only file-descriptor duplication "
+            "and closure are permitted."
+        )
+    if node.type == "variable_assignment":
+        name_node = node.child_by_field_name("name")
+        if name_node is not None:
+            name = name_node.text.decode("utf-8", errors="replace")
+            if _is_blocked_environment_variable(name):
+                return f"Environment variable assignment '{name}' is not 
allowed."
     if node.type == "command":
         err = _validate_command_node(node, allowed_commands, 
allowed_script_dirs, cwd)
         if err is not None:
@@ -133,6 +152,32 @@ def _walk(
     return None
 
 
+def _is_fd_only_redirect(node: Node) -> bool:
+    """Return whether a redirect only duplicates or closes a file 
descriptor."""
+    operator = next((child.type for child in node.children if not 
child.is_named), None)
+    destination = node.child_by_field_name("destination")
+    if operator in _FD_CLOSE_OPERATORS:
+        return destination is None
+    return (
+        operator in _FD_REDIRECT_OPERATORS
+        and destination is not None
+        and (
+            destination.type == "number"
+            or (
+                destination.type == "word"
+                and destination.text.endswith(b"-")
+                and destination.text[:-1].isdigit()
+            )
+        )
+    )
+
+
+def _is_blocked_environment_variable(name: str) -> bool:
+    return name in _BLOCKED_ENVIRONMENT_VARIABLES or name.startswith(
+        _DYNAMIC_LOADER_VARIABLE_PREFIXES
+    )
+
+
 def _validate_command_node(
     node: Node,
     allowed_commands: List[str],
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 3f6048fec..58eb8022e 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
@@ -113,6 +113,24 @@ class TestValidateCommand:
     def test_allow_env_prefix(self) -> None:
         assert validate_command("FOO=bar echo hi", ["echo"], []) is None
 
+    @pytest.mark.parametrize(
+        "name",
+        [
+            "PATH",
+            "BASH_ENV",
+            "ENV",
+            "SHELLOPTS",
+            "CDPATH",
+            "LD_PRELOAD",
+            "LD_LIBRARY_PATH",
+            "DYLD_INSERT_LIBRARIES",
+        ],
+    )
+    def test_reject_execution_changing_env_prefix(self, name: str) -> None:
+        assert validate_command(f"{name}=unsafe echo hi", ["echo"], []) == (
+            f"Environment variable assignment '{name}' is not allowed."
+        )
+
     def test_reject_env_prefix_command_not_whitelisted(self) -> None:
         assert validate_command("FOO=bar rm -rf /", ["echo"], []) is not None
 
@@ -192,11 +210,35 @@ class TestValidateCommand:
     def test_allow_brace_expansion(self) -> None:
         assert validate_command("echo {a,b,c}", ["echo"], []) is None
 
-    def test_allow_redirect_to_file(self) -> None:
-        assert validate_command("echo hi > /tmp/out", ["echo"], []) is None
+    @pytest.mark.parametrize(
+        "command",
+        [
+            "echo hi > /tmp/out",
+            "echo hi >> /tmp/out",
+            "echo hi 2> /tmp/err",
+            "echo hi < /tmp/in",
+            "echo hi >2",
+            "echo hi >&/tmp/out",
+        ],
+    )
+    def test_reject_file_redirect(self, command: str) -> None:
+        assert validate_command(command, ["echo"], []) == (
+            "File redirects are not allowed; only file-descriptor duplication "
+            "and closure are permitted."
+        )
 
-    def test_allow_stderr_redirect(self) -> None:
-        assert validate_command("echo hi 2>&1", ["echo"], []) is None
+    @pytest.mark.parametrize(
+        "command",
+        [
+            "echo hi 2>&1",
+            "echo hi >&2",
+            "echo hi <&0",
+            "echo hi 2>&-",
+            "echo hi 3>&1-",
+        ],
+    )
+    def test_allow_fd_only_redirect(self, command: str) -> None:
+        assert validate_command(command, ["echo"], []) is None
 
 
 class TestBashTool:

Reply via email to