You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@commons.apache.org by he...@apache.org on 2026/10/15 18:09:46 UTC

(commons-jexl) branch master updated: JEXL-473 : Add feature to restrict allowed pragmas

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

henrib pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-jexl.git


The following commit(s) were added to refs/heads/master by this push:
     new a1bd8054 JEXL-473 : Add feature to restrict allowed pragmas
a1bd8054 is described below

commit a1bd805466173a1394e9f130d7b1c295aaff6525
Author: Henrib <hb...@gmail.com>
AuthorDate: Thu Oct 15 20:08:01 2026 +0200

    JEXL-473 : Add feature to restrict allowed pragmas
    
    Co-Authored-By: Claude Code <no...@anthropic.com>
---
 src/changes/changes.xml                            |   1 +
 .../org/apache/commons/jexl3/JexlConfigLoader.java |  56 +++++++
 .../org/apache/commons/jexl3/JexlFeatures.java     | 142 ++++++++++++++++++
 .../apache/commons/jexl3/parser/JexlParser.java    |  26 ++++
 .../apache/commons/jexl3/JexlConfigLoaderTest.java | 166 +++++++++++++++++++++
 5 files changed, 391 insertions(+)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 6d1b8ea3..fb7c3bf7 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -29,6 +29,7 @@
     <body>
         <release version="3.7.1" date="YYYY-MM-DD" description="This is a feature and maintenance release. Java 8 or later is required.">
             <!-- ADD -->
+            <action dev="henrib" due-to="henrib" type="add" issue="JEXL-473">Add feature to restrict allowed pragmas.</action>
             <action dev="henrib" due-to="Aurelien Mino" type="add" issue="JEXL-472">Add property access support for Java record component accessors.</action>
             <action dev="henrib" due-to="Claude" type="add" issue="JEXL-469">Add JexlFeatures.namespaceInstantiation(boolean) to control whether a namespace bound to a Class or class-name string is reflectively auto-instantiated into a functor; string namespaces now resolve through the permission-aware uberspect.</action>
             <action dev="henrib" type="add" issue="JEXL-467">IntelliJ and VSCode editors (TextMate bundle) support for JEXL.</action>
diff --git a/src/main/java/org/apache/commons/jexl3/JexlConfigLoader.java b/src/main/java/org/apache/commons/jexl3/JexlConfigLoader.java
index ac8bcb3d..31d342db 100644
--- a/src/main/java/org/apache/commons/jexl3/JexlConfigLoader.java
+++ b/src/main/java/org/apache/commons/jexl3/JexlConfigLoader.java
@@ -27,9 +27,11 @@ import java.math.MathContext;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Predicate;
 
 import org.apache.commons.jexl3.introspection.JexlPermissions;
 import org.apache.commons.jexl3.introspection.JexlUberspect;
@@ -172,6 +174,25 @@ import org.apache.commons.jexl3.introspection.JexlUberspect;
  *   - com.example.api
  * </pre>
  *
+ * <h2>{@code pragmas:} section</h2>
+ * <p>A list of allowed pragma key patterns, turned into the predicate passed to
+ * {@link JexlFeatures#pragmaTest(java.util.function.Predicate)}.  A pattern ending with {@code *}
+ * matches any key starting with what precedes it; any other pattern must match the key exactly.
+ * Scripts declaring a pragma whose key matches no pattern fail to parse with a
+ * {@link JexlException.Feature}.  The section is an allow-list: when present with no items, no pragma is
+ * allowed; when absent, all pragmas are allowed (subject to the {@code pragma}, {@code importPragma} and
+ * {@code namespacePragma} features).  The list applies to JEXL's own pragmas too: {@code jexl.options},
+ * {@code jexl.import}, {@code jexl.namespace.*} and {@code jexl.module.*} must be listed to be allowed.
+ * A single pattern may also be written inline
+ * ({@code pragmas: dlm.*}); entries that are not {@code - pattern} items, or flow-style lists, are
+ * rejected with an {@link IllegalArgumentException}.</p>
+ * <pre>
+ * pragmas:
+ *   - dlm.*              # any pragma key starting with 'dlm.'
+ *   - jexl.import        # exactly 'jexl.import'
+ *   - jexl.namespace.*
+ * </pre>
+ *
  * <h2>Complete annotated example</h2>
  * <p>Every flag is listed explicitly so the configuration does not depend on any library default
  * (which may change between releases).  The {@code features:} block below reproduces the pre-3.7
@@ -313,6 +334,9 @@ public final class JexlConfigLoader {
                 if (val.isEmpty()) {
                     section = key;
                     sectionData = new LinkedHashMap<>();
+                } else if ("pragmas".equals(key)) {
+                    // a single pattern on the same line
+                    features.pragmaTest(parsePragmaPatterns(Collections.singletonList(unquote(val))));
                 } else {
                     applyTopLevel(key, val, builder);
                 }
@@ -394,6 +418,21 @@ public final class JexlConfigLoader {
                 }
                 break;
             }
+            case "pragmas": {
+                // only list items are expected; anything else is a configuration mistake
+                for (final String key : data.keySet()) {
+                    if (!section.equals(key)) {
+                        throw new IllegalArgumentException(
+                            "pragmas: expects a list of '- pattern' items, found '" + key + "'");
+                    }
+                }
+                // an allow-list: a section with no items allows no pragma
+                final Object list = data.get(section);
+                features.pragmaTest(parsePragmaPatterns(list instanceof List
+                    ? (List<String>) list
+                    : Collections.emptyList()));
+                break;
+            }
             default:
                 break;
         }
@@ -534,6 +573,23 @@ public final class JexlConfigLoader {
             : JexlUberspect.JEXL_STRATEGY;
     }
 
+    /**
+     * Creates a pragma key predicate from a list of patterns.
+     *
+     * @see JexlFeatures#pragmaPatterns(java.util.Collection)
+     */
+    private static Predicate<String> parsePragmaPatterns(final List<String> patterns) {
+        for (final String pattern : patterns) {
+            final String trimmed = pattern.trim();
+            // flow-style lists ([a, b] or a, b) are not supported
+            if (trimmed.startsWith("[") || trimmed.indexOf(',') >= 0) {
+                throw new IllegalArgumentException(
+                    "pragmas: expects one pattern per '- pattern' item, found '" + trimmed + "'");
+            }
+        }
+        return JexlFeatures.pragmaPatterns(patterns);
+    }
+
     private static boolean parseBool(final String s) {
         return "true".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s) || "on".equalsIgnoreCase(s);
     }
diff --git a/src/main/java/org/apache/commons/jexl3/JexlFeatures.java b/src/main/java/org/apache/commons/jexl3/JexlFeatures.java
index f7802a06..55b5f401 100644
--- a/src/main/java/org/apache/commons/jexl3/JexlFeatures.java
+++ b/src/main/java/org/apache/commons/jexl3/JexlFeatures.java
@@ -70,6 +70,13 @@ public final class JexlFeatures {
     /** The false predicate. */
     public static final Predicate<String> TEST_STR_FALSE = s -> false;
 
+    /**
+     * The true predicate.
+     *
+     * @since 3.7.1
+     */
+    public static final Predicate<String> TEST_STR_TRUE = s -> true;
+
     /** The feature names (for toString()). */
     private static final String[] F_NAMES = {
         "register", "reserved variable", "local variable", "assign/modify",
@@ -322,6 +329,9 @@ public final class JexlFeatures {
     /** The namespace names. */
     private Predicate<String> nameSpaces;
 
+    /** The allowed pragma keys. */
+    private Predicate<String> pragmas = TEST_STR_TRUE;
+
     /**
      * Creates default instance, equivalent to the result of calling the preferred alternative
      * {@link JexlFeatures#createDefault()}
@@ -337,6 +347,7 @@ public final class JexlFeatures {
      */
     public JexlFeatures(final JexlFeatures features) {
         this(features.flags, features.reservedNames, features.nameSpaces);
+        this.pragmas = features.pragmas;
     }
 
     /**
@@ -470,6 +481,9 @@ public final class JexlFeatures {
         if (this.nameSpaces != other.nameSpaces) {
             return false;
         }
+        if (!this.pragmas.equals(other.pragmas)) {
+            return false;
+        }
         if (!Objects.equals(this.reservedNames, other.reservedNames)) {
             return false;
         }
@@ -525,6 +539,7 @@ public final class JexlFeatures {
         int hash = 3;
         hash = 53 * hash + Long.hashCode(this.flags);
         hash = 53 * hash + (this.reservedNames != null ? this.reservedNames.hashCode() : 0);
+        hash = 53 * hash + this.pragmas.hashCode();
         return hash;
     }
 
@@ -779,6 +794,133 @@ public final class JexlFeatures {
         return this;
     }
 
+    /**
+     * Gets the allowed pragma keys test.
+     *
+     * @return The allowed pragma keys test, never null.
+     * @since 3.7.1
+     */
+    public Predicate<String> pragmaTest() {
+        return pragmas;
+    }
+
+    /**
+     * Sets a test to determine which pragma keys are allowed.
+     * <p>
+     * When set, parsing a script/expression declaring a pragma (#pragma key value) whose key does not satisfy
+     * the predicate will throw a parsing exception. This applies to all pragmas, including
+     * <code>jexl.options</code>, <code>jexl.import</code>, <code>jexl.namespace.*</code> and
+     * <code>jexl.module.*</code>.
+     * </p>
+     * <p>
+     * An engine built with these features applies the test to every script, expression and template it parses.
+     * As with every other feature, scripts created with explicit (non-null) features through
+     * {@link JexlEngine#createScript(JexlFeatures, JexlInfo, String, String...)} use those features' pragma
+     * test, not the engine's.
+     * </p>
+     *
+     * @param keys The pragma key predicate; must be stateless or thread-safe; null allows all pragma keys
+     * @return this features instance
+     * @since 3.7.1
+     */
+    public JexlFeatures pragmaTest(final Predicate<String> keys) {
+        pragmas = keys == null ? TEST_STR_TRUE : keys;
+        return this;
+    }
+
+    /**
+     * Creates a pragma key test from a collection of patterns, meant for {@link #pragmaTest(Predicate)}.
+     * <p>
+     * A pattern ending with <code>*</code> matches any key starting with what precedes it, any other
+     * pattern matches the key exactly. For instance, <code>dlm.*</code> matches <code>dlm.foo</code> and
+     * <code>dlm.foo.bar</code>; <code>*</code> matches any key. Patterns are trimmed, null or empty
+     * patterns are ignored.
+     * </p>
+     * <p>Tests created from the same patterns are equal, so are the features using them.</p>
+     *
+     * @param patterns The allowed pragma key patterns; null or empty allows no pragma key
+     * @return The pragma key test
+     * @since 3.7.1
+     */
+    public static Predicate<String> pragmaPatterns(final Collection<String> patterns) {
+        final Set<String> exact = new TreeSet<>();
+        final Set<String> prefixes = new TreeSet<>();
+        if (patterns != null) {
+            for (final String pattern : patterns) {
+                final String trimmed = pattern == null ? "" : pattern.trim();
+                if ("*".equals(trimmed)) {
+                    return TEST_STR_TRUE;
+                }
+                if (trimmed.endsWith("*")) {
+                    prefixes.add(trimmed.substring(0, trimmed.length() - 1));
+                } else if (!trimmed.isEmpty()) {
+                    exact.add(trimmed);
+                }
+            }
+        }
+        return exact.isEmpty() && prefixes.isEmpty() ? TEST_STR_FALSE : new PragmaPatterns(exact, prefixes);
+    }
+
+    /**
+     * A pragma key test made of exact keys and key prefixes.
+     */
+    private static final class PragmaPatterns implements Predicate<String> {
+        /** The exactly matched keys. */
+        private final Set<String> exact;
+        /** The matched key prefixes. */
+        private final Set<String> prefixes;
+
+        PragmaPatterns(final Set<String> exact, final Set<String> prefixes) {
+            this.exact = exact;
+            this.prefixes = prefixes;
+        }
+
+        @Override
+        public boolean test(final String key) {
+            if (key == null) {
+                return false;
+            }
+            if (exact.contains(key)) {
+                return true;
+            }
+            for (final String prefix : prefixes) {
+                if (key.startsWith(prefix)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        @Override
+        public boolean equals(final Object obj) {
+            if (this == obj) {
+                return true;
+            }
+            if (!(obj instanceof PragmaPatterns)) {
+                return false;
+            }
+            final PragmaPatterns other = (PragmaPatterns) obj;
+            return exact.equals(other.exact) && prefixes.equals(other.prefixes);
+        }
+
+        @Override
+        public int hashCode() {
+            return 31 * exact.hashCode() + prefixes.hashCode(); //CSOFF: MagicNumber
+        }
+
+        @Override
+        public String toString() {
+            final StringBuilder strb = new StringBuilder();
+            for (final String key : exact) {
+                strb.append(strb.length() > 0 ? ", " : "").append(key);
+            }
+            for (final String prefix : prefixes) {
+                strb.append(strb.length() > 0 ? ", " : "").append(prefix).append('*');
+            }
+            return strb.toString();
+        }
+    }
+
     /**
      * Sets whether register are enabled.
      * <p>
diff --git a/src/main/java/org/apache/commons/jexl3/parser/JexlParser.java b/src/main/java/org/apache/commons/jexl3/parser/JexlParser.java
index 8996bd53..c4511574 100644
--- a/src/main/java/org/apache/commons/jexl3/parser/JexlParser.java
+++ b/src/main/java/org/apache/commons/jexl3/parser/JexlParser.java
@@ -105,6 +105,26 @@ public abstract class JexlParser extends StringParser implements JexlScriptParse
      */
     public static final String PRAGMA_IMPORT = "jexl.import";
 
+    /**
+     * Thrown when a pragma key is not allowed by the features pragma test.
+     */
+    private static final class PragmaKeyException extends JexlException.Feature {
+        private static final long serialVersionUID = 20261015L;
+
+        /** The refused pragma key. */
+        private final String key;
+
+        PragmaKeyException(final JexlInfo info, final String key, final String expr) {
+            super(info, JexlFeatures.PRAGMA, expr);
+            this.key = key;
+        }
+
+        @Override
+        protected String detailedMessage() {
+            return parserError("pragma '" + key + "' not allowed,", getDetail());
+        }
+    }
+
     /**
      * The set of assignment operators as classes.
      */
@@ -633,6 +653,12 @@ public abstract class JexlParser extends StringParser implements JexlScriptParse
         if (PRAGMA_IMPORT.equals(key) && !features.supportsImportPragma()) {
             throwFeatureException(JexlFeatures.IMPORT_PRAGMA, getToken(0));
         }
+        if (!features.pragmaTest().test(key)) {
+            final Token token = getToken(0);
+            final JexlInfo xinfo = token != null ? info.at(token.beginLine, token.beginColumn) : info;
+            final String line = xinfo != null ? readSourceLine(source, xinfo.getLine()) : null;
+            throw new PragmaKeyException(xinfo, key, line).clean();
+        }
         if (pragmas == null) {
             pragmas = new TreeMap<>();
         }
diff --git a/src/test/java/org/apache/commons/jexl3/JexlConfigLoaderTest.java b/src/test/java/org/apache/commons/jexl3/JexlConfigLoaderTest.java
index db6c01c4..0c4e1837 100644
--- a/src/test/java/org/apache/commons/jexl3/JexlConfigLoaderTest.java
+++ b/src/test/java/org/apache/commons/jexl3/JexlConfigLoaderTest.java
@@ -20,6 +20,9 @@ import java.io.ByteArrayInputStream;
 import java.io.InputStream;
 import java.math.MathContext;
 import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.function.Predicate;
 
 import org.apache.commons.jexl3.introspection.JexlUberspect;
 import org.junit.jupiter.api.Assertions;
@@ -340,4 +343,167 @@ public class JexlConfigLoaderTest {
             Assertions.assertNull(JexlConfigLoader.load(in).strategy());
         }
     }
+
+    @Test
+    void testPragmasFromYaml() throws Exception {
+        final String config = "pragmas:\n  - dlm.*   # any dlm pragma\n  - jexl.import\n";
+        try (InputStream in = yaml(config)) {
+            final JexlBuilder builder = JexlConfigLoader.load(in);
+            final Predicate<String> test = builder.features().pragmaTest();
+            Assertions.assertNotSame(JexlFeatures.TEST_STR_TRUE, test);
+            Assertions.assertTrue(test.test("dlm.foo"));
+            Assertions.assertTrue(test.test("dlm.foo.bar"));
+            Assertions.assertTrue(test.test("jexl.import"));
+            Assertions.assertFalse(test.test("dlm"));
+            Assertions.assertFalse(test.test("jexl.importer"));
+            Assertions.assertFalse(test.test("jexl.options"));
+            final JexlEngine engine = builder.create();
+            final JexlScript script = engine.createScript("#pragma dlm.foo 42\n#pragma jexl.import 'java.util'\n1");
+            Assertions.assertEquals(42, script.getPragmas().get("dlm.foo"));
+            final JexlException.Feature xfeature = Assertions.assertThrows(JexlException.Feature.class,
+                () -> engine.createScript("#pragma dlm.foo 42\n#pragma jexl.options '+strict'\n1"));
+            // the parser reports the offending pragma line and key
+            Assertions.assertEquals(2, xfeature.getInfo().getLine());
+            Assertions.assertTrue(xfeature.getMessage().contains("pragma 'jexl.options' not allowed"),
+                xfeature.getMessage());
+            Assertions.assertThrows(JexlException.Feature.class,
+                () -> engine.createExpression("#pragma other.foo 42\n1"));
+        }
+    }
+
+    @Test
+    void testInlinePragmaFromYaml() throws Exception {
+        try (InputStream in = yaml("pragmas: dlm.*   # a single pattern\nstrict: true\n")) {
+            final Predicate<String> test = JexlConfigLoader.load(in).features().pragmaTest();
+            Assertions.assertTrue(test.test("dlm.foo"));
+            Assertions.assertFalse(test.test("jexl.options"));
+        }
+        try (InputStream in = yaml("pragmas: 'jexl.import'\n")) {
+            final Predicate<String> test = JexlConfigLoader.load(in).features().pragmaTest();
+            Assertions.assertTrue(test.test("jexl.import"));
+            Assertions.assertFalse(test.test("dlm.foo"));
+        }
+    }
+
+    @Test
+    void testInvalidPragmasFromYaml() throws Exception {
+        final String[] configs = {
+            "pragmas:\n  dlm: true\n",
+            "pragmas:\n  - dlm.*\n  rules:\n    - jexl.import\n",
+            "pragmas: [dlm.*, jexl.import]\n",
+            "pragmas: dlm.*, jexl.import\n",
+            "pragmas:\n  - dlm.*, jexl.import\n"
+        };
+        for (final String config : configs) {
+            try (InputStream in = yaml(config)) {
+                Assertions.assertThrows(IllegalArgumentException.class, () -> JexlConfigLoader.load(in), config);
+            }
+        }
+    }
+
+    @Test
+    void testEmptyPragmaItemFromYaml() throws Exception {
+        // an empty item allows nothing
+        try (InputStream in = yaml("pragmas:\n  - \n")) {
+            Assertions.assertSame(JexlFeatures.TEST_STR_FALSE, JexlConfigLoader.load(in).features().pragmaTest());
+        }
+        // and is ignored among other items
+        try (InputStream in = yaml("pragmas:\n  - dlm.*\n  - \n")) {
+            final Predicate<String> test = JexlConfigLoader.load(in).features().pragmaTest();
+            Assertions.assertTrue(test.test("dlm.foo"));
+            Assertions.assertFalse(test.test(""));
+        }
+    }
+
+    @Test
+    void testPragmasAnywhereFromYaml() throws Exception {
+        final String config = "features:\n  pragmaAnywhere: true\npragmas:\n  - dlm.*\n";
+        try (InputStream in = yaml(config)) {
+            final JexlEngine engine = JexlConfigLoader.load(in).create();
+            final JexlScript script = engine.createScript("var x = 1;\n#pragma dlm.foo 42\nx");
+            Assertions.assertEquals(42, script.getPragmas().get("dlm.foo"));
+            final JexlException.Feature xfeature = Assertions.assertThrows(JexlException.Feature.class,
+                () -> engine.createScript("var x = 1;\n#pragma dlm.foo 42\nx;\n#pragma other.foo 43\nx"));
+            Assertions.assertEquals(4, xfeature.getInfo().getLine());
+            Assertions.assertTrue(xfeature.getMessage().contains("pragma 'other.foo' not allowed"),
+                xfeature.getMessage());
+        }
+    }
+
+    @Test
+    void testStarPragmaFromYamlAllowsAll() throws Exception {
+        try (InputStream in = yaml("pragmas:\n  - '*'\n")) {
+            Assertions.assertSame(JexlFeatures.TEST_STR_TRUE, JexlConfigLoader.load(in).features().pragmaTest());
+        }
+    }
+
+    @Test
+    void testEmptyPragmasFromYamlAllowsNone() throws Exception {
+        try (InputStream in = yaml("pragmas:\nstrict: true\n")) {
+            final JexlEngine engine = JexlConfigLoader.load(in).create();
+            Assertions.assertThrows(JexlException.Feature.class,
+                () -> engine.createScript("#pragma dlm.foo 42\n1"));
+            Assertions.assertEquals(1, engine.createScript("1").execute(null));
+        }
+    }
+
+    @Test
+    void testNoPragmasFromYamlAllowsAll() throws Exception {
+        try (InputStream in = yaml("strict: true\n")) {
+            final JexlBuilder builder = JexlConfigLoader.load(in);
+            Assertions.assertSame(JexlFeatures.TEST_STR_TRUE, builder.features().pragmaTest());
+            final JexlScript script = builder.create().createScript("#pragma any.thing 42\n1");
+            Assertions.assertEquals(42, script.getPragmas().get("any.thing"));
+        }
+    }
+
+    @Test
+    void testPragmasFromYamlEquality() throws Exception {
+        final String config = "pragmas:\n  - dlm.*\n  - jexl.import\n";
+        final JexlFeatures f0;
+        final JexlFeatures f1;
+        try (InputStream in = yaml(config)) {
+            f0 = JexlConfigLoader.load(in).features();
+        }
+        try (InputStream in = yaml(config)) {
+            f1 = JexlConfigLoader.load(in).features();
+        }
+        // same patterns, same features
+        Assertions.assertNotSame(f0.pragmaTest(), f1.pragmaTest());
+        Assertions.assertEquals(f0, f1);
+        Assertions.assertEquals(f0.hashCode(), f1.hashCode());
+        // pattern order and spacing do not matter, the patterns do
+        final JexlFeatures f2 = new JexlFeatures(f0)
+            .pragmaTest(JexlFeatures.pragmaPatterns(Arrays.asList(" jexl.import", "dlm.* ")));
+        Assertions.assertEquals(f0, f2);
+        final JexlFeatures f3 = new JexlFeatures(f0)
+            .pragmaTest(JexlFeatures.pragmaPatterns(Collections.singletonList("dlm.*")));
+        Assertions.assertNotEquals(f0, f3);
+    }
+
+    @Test
+    void testPragmaPatterns() {
+        Assertions.assertSame(JexlFeatures.TEST_STR_FALSE, JexlFeatures.pragmaPatterns(null));
+        Assertions.assertSame(JexlFeatures.TEST_STR_FALSE, JexlFeatures.pragmaPatterns(Collections.emptyList()));
+        Assertions.assertSame(JexlFeatures.TEST_STR_FALSE, JexlFeatures.pragmaPatterns(Arrays.asList("", " ", null)));
+        Assertions.assertSame(JexlFeatures.TEST_STR_TRUE, JexlFeatures.pragmaPatterns(Arrays.asList("dlm.*", "*")));
+        final Predicate<String> test = JexlFeatures.pragmaPatterns(Arrays.asList("dlm.*", "jexl.import"));
+        Assertions.assertTrue(test.test("dlm.foo"));
+        Assertions.assertTrue(test.test("jexl.import"));
+        Assertions.assertFalse(test.test("jexl.importer"));
+        Assertions.assertFalse(test.test(null));
+        Assertions.assertEquals("jexl.import, dlm.*", test.toString());
+    }
+
+    @Test
+    void testFeaturesPragmaTest() {
+        final JexlFeatures features = new JexlFeatures().pragmaTest(k -> k.startsWith("dlm."));
+        // copies keep the pragma test
+        Assertions.assertEquals(features, new JexlFeatures(features));
+        final JexlEngine engine = new JexlBuilder().features(features).create();
+        Assertions.assertNotNull(engine.createScript("#pragma dlm.good 1\n1"));
+        Assertions.assertThrows(JexlException.Feature.class, () -> engine.createScript("#pragma other.good 1\n1"));
+        // explicit features use their own pragma test
+        Assertions.assertNotNull(engine.createScript(new JexlFeatures(), null, "#pragma other.good 1\n1"));
+    }
 }