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

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


The following commit(s) were added to refs/heads/master by this push:
     new f6dd1c7701 hrw4u: Adds sandbox protection feature for the compiler 
(#12950)
f6dd1c7701 is described below

commit f6dd1c770156473750107ce05960091d340924c2
Author: Leif Hedstrom <[email protected]>
AuthorDate: Mon Mar 16 08:16:28 2026 -0700

    hrw4u: Adds sandbox protection feature for the compiler (#12950)
    
    * hrw4u: Adds sandbox protection feature for the compiler
    
    A new sandbox mechanism allows administrators to restrict which hrw4u
    language features are available at compile time. Policy is defined in a
    YAML configuration file that can deny specific sections, functions,
    conditions, operators, and language constructs such as break and
    variables. Violations are reported as compilation errors with a
    configurable policy message, and per-input sandbox overrides are also
    supported for testing purposes.
    
    Co-author and ideas: Miles Libbey
    
    * Address Copilot's review comments
    
    - Check sandbox.check_language("variables") when a declared variable
      is referenced as a condition, for defense-in-depth.
    - Wrap visitVarSection()'s variables check in _sandbox_check() so
      denials are collected rather than propagated.
    - Narrow _sandbox_check() to catch SandboxDenialError only (not
      broad Exception) and use bare raise to preserve traceback.
    - Add "required": ["sandbox"] to schema to match loader behavior.
    
    * hrw4u: Add sandbox warn mode and refactor policy internals
    
    Add a warn: block alongside deny: in sandbox YAML.
    Warned features compile successfully but emit
    warnings to stderr. Refactor SandboxConfig to use a
    PolicySets dataclass, unify diagnostic formatting,
    and eliminate duplicated check logic.
    
    Ideas-by: Juan Posadas Castillo
    
    * Address Copilot's review comments
    
    - Surface bracket-modifier warnings from _parse_op_tails() to
      the error_collector via format_diagnostic.
    - Clarify docs: omitting --sandbox permits everything; a provided
      file must have a 'sandbox:' key.
    - Emit sandbox warnings as LSP severity=2 diagnostics in hrw4u-lsp.
    - Use parse_known_args() in hrw4u-lsp to tolerate editor-injected
      flags.
    - Enforce check_section("VARS") in visitVarSection() so
      deny/warn.sections: [VARS] works as documented.
    
    * Some more cleanup per Claude
---
 doc/admin-guide/configuration/hrw4u.en.rst         | 228 ++++++++++++++++++++-
 tools/hrw4u/.gitignore                             |   1 +
 tools/hrw4u/Makefile                               |  13 +-
 tools/hrw4u/pyproject.toml                         |   2 +
 tools/hrw4u/schema/sandbox.schema.json             | 136 ++++++++++++
 tools/hrw4u/scripts/hrw4u                          |   4 +
 tools/hrw4u/scripts/hrw4u-lsp                      |  47 ++++-
 tools/hrw4u/src/common.py                          |  40 ++--
 tools/hrw4u/src/errors.py                          | 107 +++++++---
 tools/hrw4u/src/sandbox.py                         | 180 ++++++++++++++++
 tools/hrw4u/src/symbols.py                         |  15 +-
 tools/hrw4u/src/symbols_base.py                    |  16 +-
 tools/hrw4u/src/visitor.py                         |  62 +++++-
 tools/hrw4u/src/visitor_base.py                    |  18 +-
 tools/hrw4u/tests/data/sandbox/allowed.ast.txt     |   1 +
 tools/hrw4u/tests/data/sandbox/allowed.input.txt   |   3 +
 tools/hrw4u/tests/data/sandbox/allowed.output.txt  |   2 +
 .../tests/data/sandbox/denied-function.ast.txt     |   1 +
 .../tests/data/sandbox/denied-function.error.txt   |   2 +
 .../tests/data/sandbox/denied-function.input.txt   |   3 +
 .../data/sandbox/denied-language-break.ast.txt     |   1 +
 .../data/sandbox/denied-language-break.error.txt   |   2 +
 .../data/sandbox/denied-language-break.input.txt   |   4 +
 .../data/sandbox/denied-language-elif.ast.txt      |   1 +
 .../data/sandbox/denied-language-elif.error.txt    |   1 +
 .../data/sandbox/denied-language-elif.input.txt    |   7 +
 .../data/sandbox/denied-language-elif.sandbox.yaml |   6 +
 .../data/sandbox/denied-language-else.ast.txt      |   1 +
 .../data/sandbox/denied-language-else.error.txt    |   1 +
 .../data/sandbox/denied-language-else.input.txt    |   7 +
 .../data/sandbox/denied-language-else.sandbox.yaml |   6 +
 .../tests/data/sandbox/denied-language-in.ast.txt  |   1 +
 .../data/sandbox/denied-language-in.error.txt      |   1 +
 .../data/sandbox/denied-language-in.input.txt      |   5 +
 .../data/sandbox/denied-language-in.sandbox.yaml   |   6 +
 .../data/sandbox/denied-modifier-nocase.ast.txt    |   1 +
 .../data/sandbox/denied-modifier-nocase.error.txt  |   1 +
 .../data/sandbox/denied-modifier-nocase.input.txt  |   5 +
 .../sandbox/denied-modifier-nocase.sandbox.yaml    |   6 +
 .../tests/data/sandbox/denied-modifier-or.ast.txt  |   1 +
 .../data/sandbox/denied-modifier-or.error.txt      |   1 +
 .../data/sandbox/denied-modifier-or.input.txt      |   5 +
 .../data/sandbox/denied-modifier-or.sandbox.yaml   |   6 +
 .../tests/data/sandbox/denied-section.ast.txt      |   1 +
 .../tests/data/sandbox/denied-section.error.txt    |   2 +
 .../tests/data/sandbox/denied-section.input.txt    |   3 +
 tools/hrw4u/tests/data/sandbox/exceptions.txt      |  10 +
 .../tests/data/sandbox/multiple-denials.ast.txt    |   1 +
 .../tests/data/sandbox/multiple-denials.error.txt  |   4 +
 .../tests/data/sandbox/multiple-denials.input.txt  |   4 +
 .../tests/data/sandbox/per-test-sandbox.error.txt  |   1 +
 .../tests/data/sandbox/per-test-sandbox.input.txt  |   3 +
 .../data/sandbox/per-test-sandbox.sandbox.yaml     |   4 +
 tools/hrw4u/tests/data/sandbox/sandbox.yaml        |  12 ++
 .../tests/data/sandbox/warned-function.ast.txt     |   1 +
 .../tests/data/sandbox/warned-function.input.txt   |   3 +
 .../tests/data/sandbox/warned-function.output.txt  |   2 +
 .../data/sandbox/warned-function.sandbox.yaml      |   6 +
 .../tests/data/sandbox/warned-function.warning.txt |   2 +
 tools/hrw4u/tests/test_lsp.py                      |  84 +++++++-
 tools/hrw4u/tests/test_sandbox.py                  |  43 ++++
 tools/hrw4u/tests/utils.py                         | 148 +++++++++++++
 62 files changed, 1234 insertions(+), 57 deletions(-)

diff --git a/doc/admin-guide/configuration/hrw4u.en.rst 
b/doc/admin-guide/configuration/hrw4u.en.rst
index 1b7d2d2fcd..af294dfefa 100644
--- a/doc/admin-guide/configuration/hrw4u.en.rst
+++ b/doc/admin-guide/configuration/hrw4u.en.rst
@@ -529,10 +529,58 @@ Groups
      ...
    }
 
+Control Flow
+------------
+
+HRW4U conditionals use ``if``, ``elif``, and ``else`` blocks. Each branch
+takes a condition expression followed by a ``{ ... }`` body of statements:
+
+.. code-block:: none
+
+   if condition {
+     statement;
+   } elif other-condition {
+     statement;
+   } else {
+     statement;
+   }
+
+``elif`` and ``else`` are optional and can be chained. Branches can be nested
+to arbitrary depth:
+
+.. code-block:: none
+
+   REMAP {
+     if inbound.status > 399 {
+       if inbound.status < 500 {
+         if inbound.status == 404 {
+           inbound.resp.X-Error = "not-found";
+         } elif inbound.status == 403 {
+           inbound.resp.X-Error = "forbidden";
+         }
+       } else {
+         inbound.resp.X-Error = "server-error";
+       }
+     }
+   }
+
+The ``break;`` statement exits the current section immediately, skipping any
+remaining statements and branches:
+
+.. code-block:: none
+
+   REMAP {
+     if inbound.req.X-Internal != "1" {
+       break;
+     }
+     # Only reached for internal requests
+     inbound.req.X-Debug = "on";
+   }
+
 Condition operators
 -------------------
 
-HRW4U supports the following condition operators, which are used in `if (...)` 
expressions:
+HRW4U supports the following condition operators, which are used in ``if`` 
expressions:
 
 ==================== ========================= 
============================================
 Operator             HRW4U Syntax              Description
@@ -589,6 +637,184 @@ Run with `--debug all` to trace:
 - Condition evaluations
 - State and output emission
 
+Sandbox Policy Enforcement
+==========================
+
+Organizations deploying HRW4U across teams can restrict which language features
+are permitted using a sandbox configuration file. Features can be **denied**
+(compilation fails with an error) or **warned** (compilation succeeds but a
+warning is emitted). Both modes support the same feature categories.
+
+Pass the sandbox file with ``--sandbox``:
+
+.. code-block:: none
+
+   hrw4u --sandbox /etc/trafficserver/hrw4u-sandbox.yaml rules.hrw4u
+
+The sandbox file is YAML with a single top-level ``sandbox`` key. A JSON
+Schema for editor validation and autocomplete is provided at
+``tools/hrw4u/schema/sandbox.schema.json``.
+
+.. code-block:: yaml
+
+   sandbox:
+     message: |      # optional: shown once after all errors/warnings
+       ...
+     deny:
+       sections:    [ ... ]   # section names, e.g. TXN_START
+       functions:   [ ... ]   # function names, e.g. run-plugin
+       conditions:  [ ... ]   # condition keys, e.g. geo.
+       operators:   [ ... ]   # operator keys, e.g. inbound.conn.dscp
+       language:    [ ... ]   # break, variables, in, else, elif
+     warn:
+       functions:   [ ... ]   # same categories as deny
+       conditions:  [ ... ]
+
+All lists are optional. If ``--sandbox`` is omitted, all features are 
permitted.
+When a sandbox file is provided it must contain a top-level ``sandbox:`` key;
+an empty policy can be expressed as ``sandbox: {}``.
+A feature may not appear in both ``deny`` and ``warn``.
+
+Denied Sections
+---------------
+
+The ``sections`` list accepts any of the HRW4U section names listed in the
+`Sections`_ table, plus ``VARS`` to deny the variable declaration block.
+A denied section causes the entire block to be rejected; the body is not
+validated.
+
+Functions
+---------
+
+The ``functions`` list accepts any of the statement-function names used in
+HRW4U source. The complete set of deniable functions is:
+
+====================== =============================================
+Function               Description
+====================== =============================================
+``add-header``         Add a header (``+=`` operator equivalent)
+``counter``            Increment an ATS statistics counter
+``keep_query``         Keep only specified query parameters
+``no-op``              Explicit no-op statement
+``remove_query``       Remove specified query parameters
+``run-plugin``         Invoke an external remap plugin
+``set-body-from``      Set response body from a URL
+``set-config``         Override an ATS configuration variable
+``set-debug``          Enable per-transaction ATS debug logging
+``set-plugin-cntl``    Set a plugin control flag
+``set-redirect``       Issue an HTTP redirect response
+``skip-remap``         Skip remap processing (open proxy)
+====================== =============================================
+
+Conditions and Operators
+------------------------
+
+The ``conditions`` and ``operators`` lists use the same dot-notation keys shown
+in the `Conditions`_ and `Operators`_ tables above (e.g. ``inbound.req.``,
+``geo.``, ``outbound.conn.``).
+
+Entries ending with ``.`` use **prefix matching** — ``geo.`` denies all
+``geo.*`` lookups (``geo.city``, ``geo.ASN``, etc.). Entries without a trailing
+``.`` are matched exactly, which allows fine-grained control over sub-values:
+
+.. code-block:: yaml
+
+   sandbox:
+     deny:
+       operators:
+         - http.cntl.SKIP_REMAP        # deny just this sub-value
+       conditions:
+         - geo.ASN                      # deny ASN lookups specifically
+     warn:
+       operators:
+         - http.cntl.                   # warn on all other http.cntl.* usage
+       conditions:
+         - geo.                         # warn on remaining geo.* lookups
+
+Prefix entries and exact entries can be combined across ``deny`` and ``warn``
+to create graduated policies — deny the dangerous sub-values while warning on
+the rest.
+
+Language Constructs
+-------------------
+
+The ``language`` list accepts a fixed set of constructs:
+
+================ ===================================================
+Construct        What it controls
+================ ===================================================
+``break``        The ``break;`` statement (early section exit)
+``variables``    The entire ``VARS`` section and all variable usage
+``else``         The ``else { ... }`` branch of conditionals
+``elif``         The ``elif ... { ... }`` branch of conditionals
+``in``           The ``in [...]`` and ``!in [...]`` set membership operators
+================ ===================================================
+
+Output
+------
+
+When a denied feature is used the error output looks like:
+
+.. code-block:: none
+
+   rules.hrw4u:3:4: error: 'set-debug' is denied by sandbox policy (function)
+
+   This feature is restricted by CDN-SRE policy.
+   Contact [email protected] for exceptions.
+
+When a warned feature is used the compiler emits a warning but succeeds:
+
+.. code-block:: none
+
+   rules.hrw4u:5:4: warning: 'set-config' is warned by sandbox policy 
(function)
+
+   This feature is restricted by CDN-SRE policy.
+   Contact [email protected] for exceptions.
+
+The sandbox message is shown once at the end of the output, regardless of how
+many denial errors or warnings were found. Warnings alone do not cause a
+non-zero exit code.
+
+Example Configuration
+---------------------
+
+A typical policy for a CDN team where remap plugin authors should not have
+access to low-level or dangerous features, with transitional warnings for
+features being phased out:
+
+.. code-block:: yaml
+
+   sandbox:
+     message: |
+       This feature is not permitted by CDN-SRE policy.
+       To request an exception, file a ticket at https://help.example.com/cdn
+
+     deny:
+       # Disallow hooks that run outside the normal remap context
+       sections:
+         - TXN_START
+         - TXN_CLOSE
+         - PRE_REMAP
+
+       # Disallow functions that affect ATS internals or load arbitrary code
+       functions:
+         - run-plugin
+         - skip-remap
+
+       # Deny a specific dangerous sub-value
+       operators:
+         - http.cntl.SKIP_REMAP
+
+     warn:
+       # These functions will be denied in a future release
+       functions:
+         - set-debug
+         - set-config
+
+       # Warn on all remaining http.cntl usage
+       operators:
+         - http.cntl.
+
 Examples
 ========
 
diff --git a/tools/hrw4u/.gitignore b/tools/hrw4u/.gitignore
index c61b1049d7..7bd07cb17d 100644
--- a/tools/hrw4u/.gitignore
+++ b/tools/hrw4u/.gitignore
@@ -1,3 +1,4 @@
 build/
 dist/
 uv.lock
+*.spec
diff --git a/tools/hrw4u/Makefile b/tools/hrw4u/Makefile
index 826025451c..0f9c1f636c 100644
--- a/tools/hrw4u/Makefile
+++ b/tools/hrw4u/Makefile
@@ -51,8 +51,9 @@ UTILS_FILES=src/symbols_base.py \
 SRC_FILES_HRW4U=src/visitor.py \
        src/symbols.py \
        src/suggestions.py \
-       src/kg_visitor.py \
-       src/procedures.py
+       src/procedures.py \
+       src/sandbox.py \
+       src/kg_visitor.py
 
 ALL_HRW4U_FILES=$(SHARED_FILES) $(UTILS_FILES) $(SRC_FILES_HRW4U)
 
@@ -170,10 +171,10 @@ test:
 
 # Build standalone binaries (optional)
 build: gen
-       uv run pyinstaller --onefile --name hrw4u --strip $(SCRIPT_HRW4U)
-       uv run pyinstaller --onefile --name u4wrh --strip $(SCRIPT_U4WRH)
-       uv run pyinstaller --onefile --name hrw4u-lsp --strip $(SCRIPT_LSP)
-       uv run pyinstaller --onefile --name hrw4u-kg --strip $(SCRIPT_KG)
+       uv run pyinstaller --onedir --name hrw4u --strip $(SCRIPT_HRW4U)
+       uv run pyinstaller --onedir --name u4wrh --strip $(SCRIPT_U4WRH)
+       uv run pyinstaller --onedir --name hrw4u-lsp --strip $(SCRIPT_LSP)
+       uv run pyinstaller --onedir --name hrw4u-kg --strip $(SCRIPT_KG)
 
 # Wheel packaging (adjust pyproject to include both packages if desired)
 package: gen
diff --git a/tools/hrw4u/pyproject.toml b/tools/hrw4u/pyproject.toml
index 4398e35c7d..498ca6b7bb 100644
--- a/tools/hrw4u/pyproject.toml
+++ b/tools/hrw4u/pyproject.toml
@@ -42,6 +42,7 @@ classifiers = [
 ]
 dependencies = [
     "antlr4-python3-runtime>=4.9,<5.0",
+    "pyyaml>=6.0,<7.0",
     "rapidfuzz>=3.0,<4.0",
 ]
 
@@ -77,6 +78,7 @@ markers = [
     "reverse: marks tests for reverse conversion (header_rewrite -> hrw4u)",
     "ast: marks tests for AST validation",
     "procedures: marks tests for procedure expansion",
+    "sandbox: marks tests for sandbox policy enforcement",
 ]
 
 [dependency-groups]
diff --git a/tools/hrw4u/schema/sandbox.schema.json 
b/tools/hrw4u/schema/sandbox.schema.json
new file mode 100644
index 0000000000..516064ceee
--- /dev/null
+++ b/tools/hrw4u/schema/sandbox.schema.json
@@ -0,0 +1,136 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#";,
+  "$id": "https://trafficserver.apache.org/schemas/hrw4u-sandbox.schema.json";,
+  "title": "HRW4U Sandbox Configuration",
+  "description": "Policy deny-list and warn-list for the hrw4u compiler 
(--sandbox FILE).",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["sandbox"],
+  "properties": {
+    "sandbox": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "message": {
+          "type": "string",
+          "description": "Free-form text appended once after all denial errors 
and warnings. Use this to explain the policy and provide a contact or ticket 
link."
+        },
+        "deny": {
+          "$ref": "#/$defs/categoryBlock",
+          "description": "Features listed here are denied: compilation fails 
with an error."
+        },
+        "warn": {
+          "$ref": "#/$defs/categoryBlock",
+          "description": "Features listed here produce warnings but 
compilation succeeds."
+        }
+      }
+    }
+  },
+  "$defs": {
+    "categoryBlock": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "sections": {
+          "type": "array",
+          "description": "HRW4U section names. A denied section rejects the 
entire block; a warned section emits a warning.",
+          "items": {
+            "type": "string",
+            "enum": [
+              "TXN_START",
+              "PRE_REMAP",
+              "REMAP",
+              "READ_REQUEST",
+              "SEND_REQUEST",
+              "READ_RESPONSE",
+              "SEND_RESPONSE",
+              "TXN_CLOSE",
+              "VARS"
+            ]
+          },
+          "uniqueItems": true
+        },
+        "functions": {
+          "type": "array",
+          "description": "Statement function names.",
+          "items": {
+            "type": "string",
+            "enum": [
+              "add-header",
+              "counter",
+              "keep_query",
+              "no-op",
+              "remove_query",
+              "run-plugin",
+              "set-body-from",
+              "set-config",
+              "set-debug",
+              "set-plugin-cntl",
+              "set-redirect",
+              "skip-remap"
+            ]
+          },
+          "uniqueItems": true
+        },
+        "conditions": {
+          "type": "array",
+          "description": "Condition keys. Entries ending with '.' use prefix 
matching (e.g. 'geo.' matches all geo.* lookups).",
+          "items": {
+            "type": "string"
+          },
+          "uniqueItems": true,
+          "examples": [
+            ["geo.", "tcp.info", "inbound.conn.", "outbound.conn."]
+          ]
+        },
+        "operators": {
+          "type": "array",
+          "description": "Operator (assignment target) keys. Entries ending 
with '.' use prefix matching.",
+          "items": {
+            "type": "string"
+          },
+          "uniqueItems": true,
+          "examples": [
+            ["inbound.conn.dscp", "inbound.conn.mark", "outbound.conn.dscp", 
"outbound.conn.mark"]
+          ]
+        },
+        "language": {
+          "type": "array",
+          "description": "Language constructs.",
+          "items": {
+            "type": "string",
+            "enum": [
+              "break",
+              "variables",
+              "in",
+              "else",
+              "elif"
+            ]
+          },
+          "uniqueItems": true
+        },
+        "modifiers": {
+          "type": "array",
+          "description": "Condition and operator modifiers.",
+          "items": {
+            "type": "string",
+            "enum": [
+              "AND",
+              "OR",
+              "NOT",
+              "NOCASE",
+              "PRE",
+              "SUF",
+              "EXT",
+              "MID",
+              "I",
+              "L",
+              "QSA"
+            ]
+          },
+          "uniqueItems": true
+        }
+      }
+    }
+  }
+}
diff --git a/tools/hrw4u/scripts/hrw4u b/tools/hrw4u/scripts/hrw4u
index 2940a4c970..91a586a181 100755
--- a/tools/hrw4u/scripts/hrw4u
+++ b/tools/hrw4u/scripts/hrw4u
@@ -28,6 +28,7 @@ from hrw4u.hrw4uLexer import hrw4uLexer
 from hrw4u.hrw4uParser import hrw4uParser
 from hrw4u.visitor import HRW4UVisitor
 from hrw4u.common import run_main
+from hrw4u.sandbox import SandboxConfig
 
 
 def _add_args(parser: argparse.ArgumentParser, output_group: 
argparse._MutuallyExclusiveGroup) -> None:
@@ -42,12 +43,15 @@ def _add_args(parser: argparse.ArgumentParser, 
output_group: argparse._MutuallyE
         dest="procedures_path",
         default="",
         help="Colon-separated list of directories to search for procedure 
files")
+    parser.add_argument("--sandbox", metavar="FILE", type=Path, help="Path to 
sandbox YAML configuration file")
 
 
 def _visitor_kwargs(args: argparse.Namespace) -> dict[str, Any]:
     kwargs: dict[str, Any] = {}
     if args.procedures_path:
         kwargs['proc_search_paths'] = [Path(p) for p in 
args.procedures_path.split(os.pathsep) if p]
+    if args.sandbox:
+        kwargs['sandbox'] = SandboxConfig.load(args.sandbox)
     return kwargs
 
 
diff --git a/tools/hrw4u/scripts/hrw4u-lsp b/tools/hrw4u/scripts/hrw4u-lsp
index ce78da8e4c..e17c1886a4 100755
--- a/tools/hrw4u/scripts/hrw4u-lsp
+++ b/tools/hrw4u/scripts/hrw4u-lsp
@@ -19,6 +19,7 @@
 
 from __future__ import annotations
 
+import argparse
 import json
 import os
 import sys
@@ -29,6 +30,7 @@ from typing import Any
 from hrw4u.hrw4uLexer import hrw4uLexer
 from hrw4u.hrw4uParser import hrw4uParser
 from hrw4u.visitor import HRW4UVisitor, ProcSig
+from hrw4u.sandbox import SandboxConfig
 from hrw4u.common import create_parse_tree
 from hrw4u.types import VarType, LanguageKeyword
 from hrw4u.procedures import resolve_use_path
@@ -52,6 +54,7 @@ class DocumentManager:
         self._completion_provider = CompletionProvider()
         self._uri_path_cache: dict[str, str] = {}
         self.proc_search_paths: list[Path] = []
+        self.sandbox: SandboxConfig | None = None
 
     def _add_operator_completions(self, completions: list, base_prefix: str, 
current_section, context: CompletionContext) -> None:
         operator_completions = 
self._completion_provider.get_operator_completions(
@@ -119,7 +122,10 @@ class DocumentManager:
 
             if tree is not None:
                 visitor = HRW4UVisitor(
-                    filename=filename, error_collector=error_collector, 
proc_search_paths=self.proc_search_paths or None)
+                    filename=filename,
+                    error_collector=error_collector,
+                    proc_search_paths=self.proc_search_paths or None,
+                    sandbox=self.sandbox)
                 try:
                     visitor.visit(tree)
                     self.proc_registries[uri] = dict(visitor._proc_registry)
@@ -217,6 +223,29 @@ class DocumentManager:
                             "source": "hrw4u"
                         })
 
+            # Emit sandbox warnings as LSP severity=2 (Warning) diagnostics
+            if error_collector and error_collector.has_warnings():
+                for w in error_collector.warnings:
+                    line_num = max(0, w.line - 1)
+                    col_num = max(0, w.column)
+                    diagnostics.append(
+                        {
+                            "range":
+                                {
+                                    "start": {
+                                        "line": line_num,
+                                        "character": col_num
+                                    },
+                                    "end": {
+                                        "line": line_num,
+                                        "character": col_num + 1
+                                    }
+                                },
+                            "severity": 2,
+                            "message": w.message,
+                            "source": "hrw4u-sandbox"
+                        })
+
             # Add parser errors only if they don't overlap with semantic errors
             for parser_error in parser_errors:
                 error_line = parser_error.get("range", {}).get("start", 
{}).get("line", -1)
@@ -566,7 +595,12 @@ class HRW4ULanguageServer:
             procedures_path = init_options.get("proceduresPath", "")
             if procedures_path:
                 self.document_manager.proc_search_paths = [Path(p) for p in 
procedures_path.split(os.pathsep) if p]
-
+            sandbox_path = init_options.get("sandboxPath", "")
+            if sandbox_path:
+                try:
+                    self.document_manager.sandbox = 
SandboxConfig.load(Path(sandbox_path))
+                except Exception as e:
+                    print(f"hrw4u-lsp: warning: could not load sandbox config: 
{e}", file=sys.stderr)
         response = {
             "jsonrpc": "2.0",
             "id": message["id"],
@@ -716,7 +750,16 @@ class HRW4ULanguageServer:
 
 def main() -> None:
     """Main entry point for the LSP server."""
+    parser = argparse.ArgumentParser(description="HRW4U Language Server")
+    parser.add_argument("--sandbox", metavar="FILE", type=Path, help="Path to 
sandbox YAML configuration file")
+    args, _unknown = parser.parse_known_args()
+
     server = HRW4ULanguageServer()
+    if args.sandbox:
+        try:
+            server.document_manager.sandbox = SandboxConfig.load(args.sandbox)
+        except Exception as e:
+            print(f"hrw4u-lsp: warning: could not load sandbox config: {e}", 
file=sys.stderr)
     server.start()
 
 
diff --git a/tools/hrw4u/src/common.py b/tools/hrw4u/src/common.py
index 7ca9c92ed4..38027a502f 100644
--- a/tools/hrw4u/src/common.py
+++ b/tools/hrw4u/src/common.py
@@ -1,4 +1,5 @@
 #
+#
 #  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
@@ -203,7 +204,7 @@ def generate_output(
         filename: str,
         args: Any,
         error_collector: ErrorCollector | None = None,
-        visitor_kwargs: Callable[[Any], dict[str, Any]] | None = None) -> None:
+        extra_kwargs: dict[str, Any] | None = None) -> None:
     """Generate and print output based on mode with optional error 
collection."""
     if args.ast:
         if tree is not None:
@@ -213,13 +214,15 @@ def generate_output(
     else:
         if tree is not None:
             preserve_comments = not getattr(args, 'no_comments', False)
-            extra_kwargs = visitor_kwargs(args) if visitor_kwargs else {}
-            visitor = visitor_class(
-                filename=filename,
-                debug=args.debug,
-                error_collector=error_collector,
-                preserve_comments=preserve_comments,
-                **extra_kwargs)
+            kwargs: dict[str, Any] = {
+                "filename": filename,
+                "debug": args.debug,
+                "error_collector": error_collector,
+                "preserve_comments": preserve_comments
+            }
+            if extra_kwargs:
+                kwargs.update(extra_kwargs)
+            visitor = visitor_class(**kwargs)
             try:
                 if getattr(args, 'output', None) == 'hrw4u':
                     result = visitor.flatten(tree)
@@ -237,9 +240,9 @@ def generate_output(
                 else:
                     fatal(str(e))
 
-    if error_collector and error_collector.has_errors():
+    if error_collector and (error_collector.has_errors() or 
error_collector.has_warnings()):
         print(error_collector.get_error_summary(), file=sys.stderr)
-        if not args.ast and tree is None:
+        if error_collector.has_errors() and not args.ast and tree is None:
             sys.exit(1)
 
 
@@ -253,7 +256,7 @@ def run_main(
         output_flag_help: str,
         add_args: Callable[[argparse.ArgumentParser, 
argparse._MutuallyExclusiveGroup], None] | None = None,
         pre_process: Callable[[str, str, Any], str] | None = None,
-        visitor_kwargs: Callable[[Any], dict[str, Any]] | None = None) -> None:
+        visitor_kwargs: Callable[[argparse.Namespace], dict[str, Any]] | None 
= None) -> None:
     """
     Generic main function for hrw4u and u4wrh scripts with bulk compilation 
support.
 
@@ -267,6 +270,7 @@ def run_main(
         output_flag_help: Help text for output flag
         add_args: Optional callback to add extra arguments to the parser and 
output group
         pre_process: Optional callback(content, filename, args) -> content run 
before parsing
+        visitor_kwargs: Optional callback(args) -> dict of extra kwargs for 
the visitor
     """
     parser = argparse.ArgumentParser(
         description=description,
@@ -296,6 +300,14 @@ def run_main(
 
     args = parser.parse_args()
 
+    if not hasattr(args, output_flag_name):
+        setattr(args, output_flag_name, False)
+
+    if not (args.ast or getattr(args, output_flag_name)):
+        setattr(args, output_flag_name, True)
+
+    extra_kwargs = visitor_kwargs(args) if visitor_kwargs else None
+
     if not args.files:
         content, filename = process_input(sys.stdin)
         if pre_process is not None:
@@ -306,7 +318,7 @@ def run_main(
                 sys.exit(1)
         tree, parser_obj, error_collector = create_parse_tree(
             content, filename, lexer_class, parser_class, error_prefix, not 
args.stop_on_error, args.max_errors)
-        generate_output(tree, parser_obj, visitor_class, filename, args, 
error_collector, visitor_kwargs)
+        generate_output(tree, parser_obj, visitor_class, filename, args, 
error_collector, extra_kwargs)
         return
 
     if any(':' in f for f in args.files):
@@ -344,7 +356,7 @@ def run_main(
                     original_stdout = sys.stdout
                     try:
                         sys.stdout = output_file
-                        generate_output(tree, parser_obj, visitor_class, 
filename, args, error_collector, visitor_kwargs)
+                        generate_output(tree, parser_obj, visitor_class, 
filename, args, error_collector, extra_kwargs)
                     finally:
                         sys.stdout = original_stdout
             except Exception as e:
@@ -375,4 +387,4 @@ def run_main(
             tree, parser_obj, error_collector = create_parse_tree(
                 content, filename, lexer_class, parser_class, error_prefix, 
not args.stop_on_error, args.max_errors)
 
-            generate_output(tree, parser_obj, visitor_class, filename, args, 
error_collector, visitor_kwargs)
+            generate_output(tree, parser_obj, visitor_class, filename, args, 
error_collector, extra_kwargs)
diff --git a/tools/hrw4u/src/errors.py b/tools/hrw4u/src/errors.py
index 7b37a939bc..51275c928a 100644
--- a/tools/hrw4u/src/errors.py
+++ b/tools/hrw4u/src/errors.py
@@ -18,6 +18,7 @@
 from __future__ import annotations
 
 import re
+from dataclasses import dataclass
 from typing import Final
 
 from antlr4.error.ErrorListener import ErrorListener
@@ -61,6 +62,24 @@ def humanize_error_message(msg: str) -> str:
     return _TOKEN_PATTERN.sub(lambda m: _TOKEN_NAMES[m.group(1)], msg)
 
 
+def _format_diagnostic(filename: str, line: int, col: int, severity: str, 
message: str, source_line: str) -> str:
+    header = f"{filename}:{line}:{col}: {severity}: {message}"
+
+    lineno = f"{line:4d}"
+    code_line = f"{lineno} | {source_line}"
+    pointer_line = f"{' ' * 4} | {' ' * col}^"
+    return f"{header}\n{code_line}\n{pointer_line}"
+
+
+def _extract_source_context(ctx: object) -> tuple[int, int, str]:
+    try:
+        input_stream = ctx.start.getInputStream()
+        source_line = input_stream.strdata.splitlines()[ctx.start.line - 1]
+        return ctx.start.line, ctx.start.column, source_line
+    except Exception:
+        return 0, 0, ""
+
+
 class ThrowingErrorListener(ErrorListener):
 
     def __init__(self, filename: str = "<input>") -> None:
@@ -88,7 +107,7 @@ class ThrowingErrorListener(ErrorListener):
 class Hrw4uSyntaxError(Exception):
 
     def __init__(self, filename: str, line: int, column: int, message: str, 
source_line: str) -> None:
-        super().__init__(self._format_error(filename, line, column, message, 
source_line))
+        super().__init__(_format_diagnostic(filename, line, column, "error", 
message, source_line))
         self.filename = filename
         self.line = line
         self.column = column
@@ -100,14 +119,6 @@ class Hrw4uSyntaxError(Exception):
     def add_resolution_hint(self, hint: str) -> None:
         self.add_note(f"Hint: {hint}")
 
-    def _format_error(self, filename: str, line: int, col: int, message: str, 
source_line: str) -> str:
-        error = f"{filename}:{line}:{col}: error: {message}"
-
-        lineno = f"{line:4d}"
-        code_line = f"{lineno} | {source_line}"
-        pointer_line = f"{' ' * 4} | {' ' * col}^"
-        return f"{error}\n{code_line}\n{pointer_line}"
-
 
 class SymbolResolutionError(Exception):
 
@@ -124,16 +135,8 @@ def hrw4u_error(filename: str, ctx: object, exc: 
Exception) -> Hrw4uSyntaxError:
     if isinstance(exc, Hrw4uSyntaxError):
         return exc
 
-    if ctx is None:
-        error = Hrw4uSyntaxError(filename, 0, 0, str(exc), "")
-    else:
-        try:
-            input_stream = ctx.start.getInputStream()
-            source_line = input_stream.strdata.splitlines()[ctx.start.line - 1]
-        except Exception:
-            source_line = ""
-
-        error = Hrw4uSyntaxError(filename, ctx.start.line, ctx.start.column, 
str(exc), source_line)
+    line, col, source_line = _extract_source_context(ctx) if ctx else (0, 0, 
"")
+    error = Hrw4uSyntaxError(filename, line, col, str(exc), source_line)
 
     if hasattr(exc, '__notes__') and exc.__notes__:
         for note in exc.__notes__:
@@ -142,15 +145,50 @@ def hrw4u_error(filename: str, ctx: object, exc: 
Exception) -> Hrw4uSyntaxError:
     return error
 
 
+def format_diagnostic(filename: str, ctx: object, severity: str, message: str) 
-> str:
+    """Format a diagnostic message (error/warning) with source context from a 
parser ctx."""
+    line, col, source_line = _extract_source_context(ctx)
+    return _format_diagnostic(filename, line, col, severity, message, 
source_line)
+
+
+@dataclass(frozen=True, slots=True)
+class Warning:
+    """Structured warning with source location for use by both CLI and LSP."""
+    filename: str
+    line: int
+    column: int
+    message: str
+    source_line: str
+
+    def format(self) -> str:
+        return _format_diagnostic(self.filename, self.line, self.column, 
"warning", self.message, self.source_line)
+
+    @classmethod
+    def from_ctx(cls, filename: str, ctx: object, message: str) -> Warning:
+        line, col, source_line = _extract_source_context(ctx)
+        return cls(filename=filename, line=line, column=col, message=message, 
source_line=source_line)
+
+
 class ErrorCollector:
+    """Collects multiple syntax errors and warnings for comprehensive 
reporting."""
 
     def __init__(self, max_errors: int = 5) -> None:
         self.errors: list[Hrw4uSyntaxError] = []
         self.max_errors = max_errors
+        self.warnings: list[Warning] = []
+        self._sandbox_message: str | None = None
 
     def add_error(self, error: Hrw4uSyntaxError) -> None:
         self.errors.append(error)
 
+    def add_warning(self, warning: Warning) -> None:
+        self.warnings.append(warning)
+
+    def set_sandbox_message(self, message: str) -> None:
+        """Record the sandbox policy message to display once at the end."""
+        if message and self._sandbox_message is None:
+            self._sandbox_message = message
+
     def has_errors(self) -> bool:
         return bool(self.errors)
 
@@ -158,21 +196,38 @@ class ErrorCollector:
     def at_limit(self) -> bool:
         return len(self.errors) >= self.max_errors
 
+    def has_warnings(self) -> bool:
+        return bool(self.warnings)
+
     def get_error_summary(self) -> str:
-        if not self.errors:
+        if not self.errors and not self.warnings:
             return "No errors found."
 
-        count = len(self.errors)
-        lines = [f"Found {count} error{'s' if count > 1 else ''}:"]
+        lines: list[str] = []
+
+        if self.errors:
+            count = len(self.errors)
+            lines.append(f"Found {count} error{'s' if count > 1 else ''}:")
 
-        for error in self.errors:
-            lines.append(str(error))
-            if hasattr(error, '__notes__') and error.__notes__:
-                lines.extend(error.__notes__)
+            for error in self.errors:
+                lines.append(str(error))
+                if hasattr(error, '__notes__') and error.__notes__:
+                    lines.extend(error.__notes__)
+
+        if self.warnings:
+            if self.errors:
+                lines.append("")
+            count = len(self.warnings)
+            lines.append(f"{count} warning{'s' if count > 1 else ''}:")
+            lines.extend(w.format() for w in self.warnings)
 
         if self.at_limit:
             lines.append(f"(stopped after {self.max_errors} errors)")
 
+        if self._sandbox_message:
+            lines.append("")
+            lines.append(self._sandbox_message)
+
         return "\n".join(lines)
 
 
diff --git a/tools/hrw4u/src/sandbox.py b/tools/hrw4u/src/sandbox.py
new file mode 100644
index 0000000000..75ceb76593
--- /dev/null
+++ b/tools/hrw4u/src/sandbox.py
@@ -0,0 +1,180 @@
+#
+#  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.
+"""Sandbox configuration for restricting hrw4u language features."""
+
+from __future__ import annotations
+
+import yaml
+from dataclasses import dataclass, fields
+from pathlib import Path
+from typing import Any
+
+from hrw4u.errors import SymbolResolutionError
+
+
+class SandboxDenialError(SymbolResolutionError):
+    """Raised when a feature is denied by sandbox policy."""
+
+    def __init__(self, name: str, category: str, message: str) -> None:
+        super().__init__(name, f"'{name}' is denied by sandbox policy 
({category})")
+        self.sandbox_message = message
+
+
+_VALID_CATEGORY_KEYS = frozenset({"sections", "functions", "conditions", 
"operators", "language", "modifiers"})
+_VALID_LANGUAGE_CONSTRUCTS = frozenset({"break", "variables", "in", "else", 
"elif"})
+_VALID_MODIFIERS = frozenset({"AND", "OR", "NOT", "NOCASE", "PRE", "SUF", 
"EXT", "MID", "I", "L", "QSA"})
+
+
+def _load_set(data: dict[str, Any], key: str, prefix: str) -> frozenset[str]:
+    raw = data.get(key)
+
+    if raw is None:
+        return frozenset()
+    if not isinstance(raw, list):
+        raise ValueError(f"sandbox.{prefix}.{key} must be a list, got 
{type(raw).__name__}")
+    return frozenset(str(item).strip() for item in raw if item)
+
+
+def _is_matched(name: str, name_set: frozenset[str]) -> bool:
+    if name in name_set:
+        return True
+
+    for entry in name_set:
+        if entry.endswith(".") and name.startswith(entry):
+            return True
+
+    return False
+
+
+@dataclass(frozen=True)
+class PolicySets:
+    """A set of sandbox policy entries for one severity level (deny or 
warn)."""
+    sections: frozenset[str] = frozenset()
+    functions: frozenset[str] = frozenset()
+    conditions: frozenset[str] = frozenset()
+    operators: frozenset[str] = frozenset()
+    language: frozenset[str] = frozenset()
+    modifiers: frozenset[str] = frozenset()
+
+    @classmethod
+    def load(cls, data: dict[str, Any], prefix: str) -> PolicySets:
+        if not isinstance(data, dict):
+            raise ValueError(f"sandbox.{prefix} must be a mapping")
+
+        unknown_keys = set(data.keys()) - _VALID_CATEGORY_KEYS
+        if unknown_keys:
+            raise ValueError(f"Unknown keys in sandbox.{prefix}: {', 
'.join(sorted(unknown_keys))}")
+
+        language = _load_set(data, "language", prefix)
+        unknown_lang = language - _VALID_LANGUAGE_CONSTRUCTS
+        if unknown_lang:
+            raise ValueError(
+                f"Unknown language constructs in sandbox.{prefix}: {', 
'.join(sorted(unknown_lang))}. "
+                f"Valid: {', '.join(sorted(_VALID_LANGUAGE_CONSTRUCTS))}")
+
+        modifiers = frozenset(s.upper() for s in _load_set(data, "modifiers", 
prefix))
+        unknown_mods = modifiers - _VALID_MODIFIERS
+        if unknown_mods:
+            raise ValueError(
+                f"Unknown modifiers in sandbox.{prefix}: {', 
'.join(sorted(unknown_mods))}. "
+                f"Valid: {', '.join(sorted(_VALID_MODIFIERS))}")
+
+        return cls(
+            sections=_load_set(data, "sections", prefix),
+            functions=_load_set(data, "functions", prefix),
+            conditions=_load_set(data, "conditions", prefix),
+            operators=_load_set(data, "operators", prefix),
+            language=language,
+            modifiers=modifiers,
+        )
+
+    @property
+    def is_active(self) -> bool:
+        return any(getattr(self, f.name) for f in fields(self))
+
+
+@dataclass(frozen=True)
+class SandboxConfig:
+    message: str
+    deny: PolicySets
+    warn: PolicySets
+
+    @classmethod
+    def load(cls, path: Path) -> SandboxConfig:
+        with open(path, encoding="utf-8") as f:
+            raw = yaml.safe_load(f)
+
+        if not isinstance(raw, dict) or "sandbox" not in raw:
+            raise ValueError(f"Sandbox config must have a top-level 'sandbox' 
key: {path}")
+
+        sandbox = raw["sandbox"]
+        if not isinstance(sandbox, dict):
+            raise ValueError(f"sandbox must be a mapping: {path}")
+
+        message = str(sandbox.get("message", "")).strip()
+
+        deny_data = sandbox.get("deny", {})
+        if not isinstance(deny_data, dict):
+            raise ValueError(f"sandbox.deny must be a mapping: {path}")
+        deny = PolicySets.load(deny_data, "deny")
+
+        warn_data = sandbox.get("warn", {})
+        if not isinstance(warn_data, dict):
+            raise ValueError(f"sandbox.warn must be a mapping: {path}")
+        warn = PolicySets.load(warn_data, "warn")
+
+        for f in fields(PolicySets):
+            overlap = getattr(deny, f.name) & getattr(warn, f.name)
+            if overlap:
+                raise ValueError(f"sandbox.deny.{f.name} and 
sandbox.warn.{f.name} overlap: {', '.join(sorted(overlap))}")
+
+        return cls(message=message, deny=deny, warn=warn)
+
+    @classmethod
+    def empty(cls) -> SandboxConfig:
+        return cls(message="", deny=PolicySets(), warn=PolicySets())
+
+    @property
+    def is_active(self) -> bool:
+        return self.deny.is_active or self.warn.is_active
+
+    def _check(self, name: str, category: str) -> str | None:
+        display = category.rstrip("s")
+
+        if _is_matched(name, getattr(self.deny, category)):
+            raise SandboxDenialError(name, display, self.message)
+        if _is_matched(name, getattr(self.warn, category)):
+            return f"'{name}' is warned by sandbox policy ({display})"
+        return None
+
+    def check_section(self, section_name: str) -> str | None:
+        return self._check(section_name, "sections")
+
+    def check_function(self, func_name: str) -> str | None:
+        return self._check(func_name, "functions")
+
+    def check_condition(self, condition_key: str) -> str | None:
+        return self._check(condition_key, "conditions")
+
+    def check_operator(self, operator_key: str) -> str | None:
+        return self._check(operator_key, "operators")
+
+    def check_language(self, construct: str) -> str | None:
+        return self._check(construct, "language")
+
+    def check_modifier(self, modifier: str) -> str | None:
+        return self._check(modifier.upper(), "modifiers")
diff --git a/tools/hrw4u/src/symbols.py b/tools/hrw4u/src/symbols.py
index 979c9141ac..7ef52fcf59 100644
--- a/tools/hrw4u/src/symbols.py
+++ b/tools/hrw4u/src/symbols.py
@@ -25,12 +25,14 @@ from hrw4u.common import SystemDefaults
 from hrw4u.debugging import Dbg
 from hrw4u.symbols_base import SymbolResolverBase
 from hrw4u.suggestions import SuggestionEngine
+from hrw4u.sandbox import SandboxConfig
 
 
 class SymbolResolver(SymbolResolverBase):
 
-    def __init__(self, debug: bool = SystemDefaults.DEFAULT_DEBUG, dbg: Dbg | 
None = None) -> None:
-        super().__init__(debug, dbg=dbg)
+    def __init__(
+            self, debug: bool = SystemDefaults.DEFAULT_DEBUG, sandbox: 
SandboxConfig | None = None, dbg: Dbg | None = None) -> None:
+        super().__init__(debug, sandbox=sandbox, dbg=dbg)
         self._symbols: dict[str, types.Symbol] = {}
         self._var_counter = {vt: 0 for vt in types.VarType}
         self._suggestion_engine = SuggestionEngine()
@@ -75,6 +77,8 @@ class SymbolResolver(SymbolResolverBase):
 
     def resolve_assignment(self, name: str, value: str, section: SectionType | 
None = None) -> str:
         with self.debug_context("resolve_assignment", name, value, section):
+            self._collect_warning(self._sandbox.check_operator(name))
+
             for op_key, params in self._operator_map.items():
                 if op_key.endswith("."):
                     if name.startswith(op_key):
@@ -119,6 +123,8 @@ class SymbolResolver(SymbolResolverBase):
     def resolve_add_assignment(self, name: str, value: str, section: 
SectionType | None = None) -> str:
         """Resolve += assignment, if it is supported for the given operator."""
         with self.debug_context("resolve_add_assignment", name, value, 
section):
+            self._collect_warning(self._sandbox.check_operator(name))
+
             for op_key, params in self._operator_map.items():
                 if op_key.endswith(".") and name.startswith(op_key) and params 
and params.add:
                     self.validate_section_access(name, section, 
params.sections)
@@ -137,8 +143,11 @@ class SymbolResolver(SymbolResolverBase):
     def resolve_condition(self, name: str, section: SectionType | None = None) 
-> tuple[str, bool]:
         with self.debug_context("resolve_condition", name, section):
             if symbol := self.symbol_for(name):
+                
self._collect_warning(self._sandbox.check_language("variables"))
                 return symbol.as_cond(), False
 
+            self._collect_warning(self._sandbox.check_condition(name))
+
             if params := self._lookup_condition_cached(name):
                 tag = params.target if params else None
                 allowed_sections = params.sections if params else None
@@ -193,6 +202,8 @@ class SymbolResolver(SymbolResolverBase):
 
     def resolve_statement_func(self, func_name: str, args: list[str], section: 
SectionType | None = None) -> str:
         with self.debug_context("resolve_statement_func", func_name, args, 
section):
+            self._collect_warning(self._sandbox.check_function(func_name))
+
             if params := self._lookup_statement_function_cached(func_name):
                 allowed_sections = params.sections if params else None
                 self.validate_section_access(func_name, section, 
allowed_sections)
diff --git a/tools/hrw4u/src/symbols_base.py b/tools/hrw4u/src/symbols_base.py
index bce15006fb..0e245164e7 100644
--- a/tools/hrw4u/src/symbols_base.py
+++ b/tools/hrw4u/src/symbols_base.py
@@ -1,4 +1,5 @@
 #
+#
 #  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
@@ -22,14 +23,18 @@ from hrw4u.debugging import Dbg
 from hrw4u.states import SectionType
 from hrw4u.common import SystemDefaults
 from hrw4u.errors import SymbolResolutionError
+from hrw4u.sandbox import SandboxConfig
 import hrw4u.tables as tables
 import hrw4u.types as types
 
 
 class SymbolResolverBase:
 
-    def __init__(self, debug: bool = SystemDefaults.DEFAULT_DEBUG, dbg: Dbg | 
None = None) -> None:
+    def __init__(
+            self, debug: bool = SystemDefaults.DEFAULT_DEBUG, sandbox: 
SandboxConfig | None = None, dbg: Dbg | None = None) -> None:
         self._dbg = dbg if dbg is not None else Dbg(debug)
+        self._sandbox = sandbox or SandboxConfig.empty()
+        self._sandbox_warnings: list[str] = []
         # Clear caches when debug status changes to ensure consistency
         if hasattr(self, '_condition_cache'):
             self._condition_cache.cache_clear()
@@ -41,6 +46,15 @@ class SymbolResolverBase:
     def _condition_map(self) -> dict[str, types.MapParams]:
         return tables.CONDITION_MAP
 
+    def _collect_warning(self, warning: str | None) -> None:
+        if warning:
+            self._sandbox_warnings.append(warning)
+
+    def drain_warnings(self) -> list[str]:
+        warnings = self._sandbox_warnings[:]
+        self._sandbox_warnings.clear()
+        return warnings
+
     @cached_property
     def _operator_map(self) -> dict[str, types.MapParams]:
         return tables.OPERATOR_MAP
diff --git a/tools/hrw4u/src/visitor.py b/tools/hrw4u/src/visitor.py
index f6fdf65130..90ea932e68 100644
--- a/tools/hrw4u/src/visitor.py
+++ b/tools/hrw4u/src/visitor.py
@@ -37,6 +37,7 @@ from hrw4u.common import RegexPatterns, SystemDefaults
 from hrw4u.visitor_base import BaseHRWVisitor
 from hrw4u.validation import Validator
 from hrw4u.procedures import resolve_use_path
+from hrw4u.sandbox import SandboxConfig, SandboxDenialError
 
 _regex_validator = Validator.regex_pattern()
 
@@ -73,14 +74,16 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
             debug: bool = SystemDefaults.DEFAULT_DEBUG,
             error_collector=None,
             preserve_comments: bool = True,
-            proc_search_paths: list[Path] | None = None) -> None:
+            proc_search_paths: list[Path] | None = None,
+            sandbox: SandboxConfig | None = None) -> None:
         super().__init__(filename, debug, error_collector)
 
         self._cond_state = CondState()
         self._queued: QueuedItem | None = None
         self.preserve_comments = preserve_comments
+        self._sandbox = sandbox or SandboxConfig.empty()
 
-        self.symbol_resolver = SymbolResolver(debug, dbg=self._dbg)
+        self.symbol_resolver = SymbolResolver(debug, sandbox=self._sandbox, 
dbg=self._dbg)
 
         self._proc_registry: dict[str, ProcSig] = {}
         self._proc_loaded: set[str] = set()
@@ -89,6 +92,26 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
         self._proc_search_paths: list[Path] = list(proc_search_paths) if 
proc_search_paths else []
         self._source_text: str = ""
 
+    def _sandbox_check(self, ctx, check_fn) -> bool:
+        """Run a sandbox check, trapping any denial error into the error 
collector.
+
+        Returns True if the check passed (or warned), False if denied.
+        """
+        try:
+            warning = check_fn()
+            if warning:
+                self._add_sandbox_warning(ctx, warning)
+            return True
+        except SandboxDenialError:
+            with self.trap(ctx):
+                raise
+            return False
+
+    def _drain_resolver_warnings(self, ctx) -> None:
+        """Drain any warnings accumulated in the symbol resolver."""
+        for warning in self.symbol_resolver.drain_warnings():
+            self._add_sandbox_warning(ctx, warning)
+
     @lru_cache(maxsize=256)
     def _cached_symbol_resolution(self, symbol_text: str, section_name: str) 
-> tuple[str, bool]:
         try:
@@ -185,11 +208,13 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
                     arg_str = m.group("args").strip()
                     args = self._parse_function_args(arg_str) if arg_str else 
[]
                     replacement = 
self.symbol_resolver.resolve_function(func_name, args, strip_quotes=False)
+                    self._drain_resolver_warnings(ctx)
                     self.debug(f"substitute: {{{func_name}({arg_str})}} -> 
{replacement}")
                     return replacement
                 if m.group("var"):
                     var_name = m.group("var").strip()
                     replacement, _ = 
self.symbol_resolver.resolve_condition(var_name, self.current_section)
+                    self._drain_resolver_warnings(ctx)
                     self.debug(f"substitute: {{{var_name}}} -> {replacement}")
                     return replacement
                 raise SymbolResolutionError(m.group(0), "Unrecognized 
substitution format")
@@ -713,6 +738,10 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
             raise SymbolResolutionError("section", "Missing section name")
 
         section_name = ctx.name.text
+        warning = self._sandbox.check_section(section_name)
+        if warning:
+            self._add_sandbox_warning(ctx, warning)
+
         try:
             self.current_section = SectionType(section_name)
         except ValueError:
@@ -789,6 +818,10 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
             else:
                 raise error
         with self.debug_context("visitVarSection"):
+            if not self._sandbox_check(ctx, lambda: 
self._sandbox.check_section("VARS")):
+                return
+            if not self._sandbox_check(ctx, lambda: 
self._sandbox.check_language("variables")):
+                return
             self.visit(ctx.variables())
 
     def visitCommentLine(self, ctx) -> None:
@@ -803,6 +836,9 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
         with self.debug_context("visitStatement"), self.trap(ctx):
             match ctx:
                 case _ if ctx.BREAK():
+                    warning = self._sandbox.check_language("break")
+                    if warning:
+                        self._add_sandbox_warning(ctx, warning)
                     self._dbg("BREAK")
                     self.emit_statement("no-op [L]")
                     return
@@ -821,6 +857,7 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
                         self._substitute_strings(arg, ctx) if 
arg.startswith('"') and arg.endswith('"') else arg for arg in args
                     ]
                     symbol = self.symbol_resolver.resolve_statement_func(func, 
subst_args, self.current_section)
+                    self._drain_resolver_warnings(ctx)
                     self.emit_statement(symbol)
                     return
 
@@ -833,6 +870,7 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
                         rhs = self._substitute_strings(rhs, ctx)
                     self._dbg(f"assignment: {lhs} = {rhs}")
                     out = self.symbol_resolver.resolve_assignment(lhs, rhs, 
self.current_section)
+                    self._drain_resolver_warnings(ctx)
                     self.emit_statement(out)
                     return
 
@@ -845,6 +883,7 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
                         rhs = self._substitute_strings(rhs, ctx)
                     self._dbg(f"add assignment: {lhs} += {rhs}")
                     out = self.symbol_resolver.resolve_add_assignment(lhs, 
rhs, self.current_section)
+                    self._drain_resolver_warnings(ctx)
                     self.emit_statement(out)
                     return
 
@@ -913,11 +952,15 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
 
     def visitElseClause(self, ctx) -> None:
         with self.debug_context("visitElseClause"):
+            if not self._sandbox_check(ctx, lambda: 
self._sandbox.check_language("else")):
+                return
             self.emit_condition("else", final=True)
             self.visit(ctx.block())
 
     def visitElifClause(self, ctx) -> None:
         with self.debug_context("visitElifClause"):
+            if not self._sandbox_check(ctx, lambda: 
self._sandbox.check_language("elif")):
+                return
             self.emit_condition("elif", final=True)
             with self.stmt_indented(), self.cond_indented():
                 self.visit(ctx.condition())
@@ -941,6 +984,7 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
                 if comp.ident:
                     ident_name = comp.ident.text
                     lhs, _ = 
self._resolve_identifier_with_validation(ident_name)
+                    self._drain_resolver_warnings(ctx)
                 else:
                     lhs = self.visitFunctionCall(comp.functionCall())
             if not lhs:
@@ -979,6 +1023,8 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
                     cond_txt = f"{lhs} {ctx.iprange().getText()}"
 
                 case _ if ctx.set_():
+                    if not self._sandbox_check(ctx, lambda: 
self._sandbox.check_language("in")):
+                        return
                     inner = ctx.set_().getText()[1:-1]
                     cond_txt = f"{lhs} ({inner})"
 
@@ -995,6 +1041,9 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
         for token in ctx.modifierList().mods:
             try:
                 mod = token.text.upper()
+                warning = self._sandbox.check_modifier(mod)
+                if warning:
+                    self._add_sandbox_warning(ctx, warning)
                 self._cond_state.add_modifier(mod)
             except Exception as exc:
                 with self.trap(ctx):
@@ -1005,7 +1054,9 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
         with self.trap(ctx):
             func, raw_args = self._parse_function_call(ctx)
             self._dbg(f"function: {func}({', '.join(raw_args)})")
-            return self.symbol_resolver.resolve_function(func, raw_args, 
strip_quotes=True)
+            result = self.symbol_resolver.resolve_function(func, raw_args, 
strip_quotes=True)
+            self._drain_resolver_warnings(ctx)
+            return result
         return "ERROR"
 
     def emit_condition(self, text: str, *, final: bool = False) -> None:
@@ -1034,6 +1085,8 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
     def emit_expression(self, ctx, *, nested: bool = False, last: bool = 
False, grouped: bool = False) -> None:
         with self.debug_context("emit_expression"):
             if ctx.OR():
+                if not self._sandbox_check(ctx, lambda: 
self._sandbox.check_modifier("OR")):
+                    return
                 self.debug("`OR' detected")
                 if grouped:
                     self.debug("GROUP-START")
@@ -1051,6 +1104,8 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
     def emit_term(self, ctx, *, last: bool = False) -> None:
         with self.debug_context("emit_term"):
             if ctx.AND():
+                if not self._sandbox_check(ctx, lambda: 
self._sandbox.check_modifier("AND")):
+                    return
                 self.debug("`AND' detected")
                 self.emit_term(ctx.term(), last=False)
                 self._end_lhs_then_emit_rhs(False, lambda: 
self.emit_factor(ctx.factor(), last=last))
@@ -1104,6 +1159,7 @@ class HRW4UVisitor(hrw4uVisitor, BaseHRWVisitor):
                 case _ if ctx.ident:
                     name = ctx.ident.text
                     symbol, default_expr = 
self._resolve_identifier_with_validation(name)
+                    self._drain_resolver_warnings(ctx)
 
                     if default_expr:
                         cond_txt = f"{symbol} =\"\""
diff --git a/tools/hrw4u/src/visitor_base.py b/tools/hrw4u/src/visitor_base.py
index 531f98ab5f..3f6881d593 100644
--- a/tools/hrw4u/src/visitor_base.py
+++ b/tools/hrw4u/src/visitor_base.py
@@ -23,7 +23,8 @@ from typing import Any
 from hrw4u.debugging import Dbg
 from hrw4u.states import SectionType
 from hrw4u.common import SystemDefaults
-from hrw4u.errors import hrw4u_error
+from hrw4u.errors import hrw4u_error, Warning
+from hrw4u.sandbox import SandboxConfig, SandboxDenialError
 
 
 @dataclass(slots=True)
@@ -46,6 +47,7 @@ class BaseHRWVisitor:
         self.filename = filename
         self.error_collector = error_collector
         self.output: list[str] = []
+        self._sandbox = SandboxConfig.empty()
 
         self._state = VisitorState()
         self._dbg = Dbg(debug)
@@ -158,6 +160,13 @@ class BaseHRWVisitor:
 
         return DebugContext(self, method_name, args)
 
+    def _add_sandbox_warning(self, ctx, message: str) -> None:
+        """Format and collect a sandbox warning with source context."""
+        if self.error_collector:
+            self.error_collector.add_warning(Warning.from_ctx(self.filename, 
ctx, message))
+            if self._sandbox.message:
+                self.error_collector.set_sandbox_message(self._sandbox.message)
+
     def trap(self, ctx, *, note: str | None = None):
 
         class _Trap:
@@ -175,6 +184,8 @@ class BaseHRWVisitor:
 
                 if self.error_collector:
                     self.error_collector.add_error(error)
+                    if isinstance(exc, SandboxDenialError) and 
exc.sandbox_message:
+                        
self.error_collector.set_sandbox_message(exc.sandbox_message)
                     return True
                 else:
                     raise error from exc
@@ -256,6 +267,11 @@ class BaseHRWVisitor:
                                 raise Exception(f"Unknown modifier: 
{flag_text}")
                         else:
                             raise Exception(f"Unknown modifier: {flag_text}")
+                    sandbox = self._sandbox
+                    if sandbox is not None:
+                        warning = sandbox.check_modifier(flag_text)
+                        if warning and ctx:
+                            self._add_sandbox_warning(ctx, warning)
                 continue
 
             for kind in ("IDENT", "NUMBER", "STRING", "PERCENT_BLOCK", 
"COMPLEX_STRING"):
diff --git a/tools/hrw4u/tests/data/sandbox/allowed.ast.txt 
b/tools/hrw4u/tests/data/sandbox/allowed.ast.txt
new file mode 100644
index 0000000000..ff52d36c71
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/allowed.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section REMAP { (sectionBody (statement 
inbound.req.X-Foo = (value "allowed") ;)) })) <EOF>)
diff --git a/tools/hrw4u/tests/data/sandbox/allowed.input.txt 
b/tools/hrw4u/tests/data/sandbox/allowed.input.txt
new file mode 100644
index 0000000000..03d0ea6e52
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/allowed.input.txt
@@ -0,0 +1,3 @@
+REMAP {
+    inbound.req.X-Foo = "allowed";
+}
diff --git a/tools/hrw4u/tests/data/sandbox/allowed.output.txt 
b/tools/hrw4u/tests/data/sandbox/allowed.output.txt
new file mode 100644
index 0000000000..4c68298572
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/allowed.output.txt
@@ -0,0 +1,2 @@
+cond %{REMAP_PSEUDO_HOOK} [AND]
+    set-header X-Foo "allowed"
diff --git a/tools/hrw4u/tests/data/sandbox/denied-function.ast.txt 
b/tools/hrw4u/tests/data/sandbox/denied-function.ast.txt
new file mode 100644
index 0000000000..b1df274127
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-function.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section REMAP { (sectionBody (statement (functionCall 
set-debug ( )) ;)) })) <EOF>)
diff --git a/tools/hrw4u/tests/data/sandbox/denied-function.error.txt 
b/tools/hrw4u/tests/data/sandbox/denied-function.error.txt
new file mode 100644
index 0000000000..4054fd4a89
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-function.error.txt
@@ -0,0 +1,2 @@
+'set-debug' is denied by sandbox policy (function)
+Feature denied by sandbox policy. Contact platform team.
diff --git a/tools/hrw4u/tests/data/sandbox/denied-function.input.txt 
b/tools/hrw4u/tests/data/sandbox/denied-function.input.txt
new file mode 100644
index 0000000000..8954b69222
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-function.input.txt
@@ -0,0 +1,3 @@
+REMAP {
+    set-debug();
+}
diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-break.ast.txt 
b/tools/hrw4u/tests/data/sandbox/denied-language-break.ast.txt
new file mode 100644
index 0000000000..f6e1872e71
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-language-break.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section REMAP { (sectionBody (statement 
inbound.req.X-Foo = (value "test") ;)) (sectionBody (statement break ;)) })) 
<EOF>)
diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-break.error.txt 
b/tools/hrw4u/tests/data/sandbox/denied-language-break.error.txt
new file mode 100644
index 0000000000..9df5faeb5c
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-language-break.error.txt
@@ -0,0 +1,2 @@
+'break' is denied by sandbox policy (language)
+Feature denied by sandbox policy. Contact platform team.
diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-break.input.txt 
b/tools/hrw4u/tests/data/sandbox/denied-language-break.input.txt
new file mode 100644
index 0000000000..773d2e8079
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-language-break.input.txt
@@ -0,0 +1,4 @@
+REMAP {
+    inbound.req.X-Foo = "test";
+    break;
+}
diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-elif.ast.txt 
b/tools/hrw4u/tests/data/sandbox/denied-language-elif.ast.txt
new file mode 100644
index 0000000000..475169068b
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-language-elif.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section REMAP { (sectionBody (conditional (ifStatement 
if (condition (expression (term (factor (comparison (comparable 
inbound.req.X-Foo) == (value "a")))))) (block { (blockItem (statement 
inbound.req.X-Result = (value "a") ;)) })) (elifClause elif (condition 
(expression (term (factor (comparison (comparable inbound.req.X-Foo) == (value 
"b")))))) (block { (blockItem (statement inbound.req.X-Result = (value "b") ;)) 
})))) })) <EOF>)
diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-elif.error.txt 
b/tools/hrw4u/tests/data/sandbox/denied-language-elif.error.txt
new file mode 100644
index 0000000000..ea2490396a
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-language-elif.error.txt
@@ -0,0 +1 @@
+'elif' is denied by sandbox policy (language)
diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-elif.input.txt 
b/tools/hrw4u/tests/data/sandbox/denied-language-elif.input.txt
new file mode 100644
index 0000000000..96d094b675
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-language-elif.input.txt
@@ -0,0 +1,7 @@
+REMAP {
+    if inbound.req.X-Foo == "a" {
+        inbound.req.X-Result = "a";
+    } elif inbound.req.X-Foo == "b" {
+        inbound.req.X-Result = "b";
+    }
+}
diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-elif.sandbox.yaml 
b/tools/hrw4u/tests/data/sandbox/denied-language-elif.sandbox.yaml
new file mode 100644
index 0000000000..be279a696d
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-language-elif.sandbox.yaml
@@ -0,0 +1,6 @@
+sandbox:
+  message: "Feature denied by sandbox policy. Contact platform team."
+
+  deny:
+    language:
+      - elif
diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-else.ast.txt 
b/tools/hrw4u/tests/data/sandbox/denied-language-else.ast.txt
new file mode 100644
index 0000000000..4525607014
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-language-else.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section REMAP { (sectionBody (conditional (ifStatement 
if (condition (expression (term (factor (comparison (comparable 
inbound.req.X-Foo) == (value "a")))))) (block { (blockItem (statement 
inbound.req.X-Result = (value "yes") ;)) })) (elseClause else (block { 
(blockItem (statement inbound.req.X-Result = (value "no") ;)) })))) })) <EOF>)
diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-else.error.txt 
b/tools/hrw4u/tests/data/sandbox/denied-language-else.error.txt
new file mode 100644
index 0000000000..4aa2d20b1d
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-language-else.error.txt
@@ -0,0 +1 @@
+'else' is denied by sandbox policy (language)
diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-else.input.txt 
b/tools/hrw4u/tests/data/sandbox/denied-language-else.input.txt
new file mode 100644
index 0000000000..35261cb4f7
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-language-else.input.txt
@@ -0,0 +1,7 @@
+REMAP {
+    if inbound.req.X-Foo == "a" {
+        inbound.req.X-Result = "yes";
+    } else {
+        inbound.req.X-Result = "no";
+    }
+}
diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-else.sandbox.yaml 
b/tools/hrw4u/tests/data/sandbox/denied-language-else.sandbox.yaml
new file mode 100644
index 0000000000..58c2bde66b
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-language-else.sandbox.yaml
@@ -0,0 +1,6 @@
+sandbox:
+  message: "Feature denied by sandbox policy. Contact platform team."
+
+  deny:
+    language:
+      - else
diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-in.ast.txt 
b/tools/hrw4u/tests/data/sandbox/denied-language-in.ast.txt
new file mode 100644
index 0000000000..63ef38354c
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-language-in.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section REMAP { (sectionBody (conditional (ifStatement 
if (condition (expression (term (factor (comparison (comparable 
inbound.url.path) in (set [ (value "php") , (value "html") ])))))) (block { 
(blockItem (statement inbound.req.X-Result = (value "yes") ;)) })))) })) <EOF>)
diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-in.error.txt 
b/tools/hrw4u/tests/data/sandbox/denied-language-in.error.txt
new file mode 100644
index 0000000000..539bb7d50e
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-language-in.error.txt
@@ -0,0 +1 @@
+'in' is denied by sandbox policy (language)
diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-in.input.txt 
b/tools/hrw4u/tests/data/sandbox/denied-language-in.input.txt
new file mode 100644
index 0000000000..7deb855097
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-language-in.input.txt
@@ -0,0 +1,5 @@
+REMAP {
+    if inbound.url.path in ["php", "html"] {
+        inbound.req.X-Result = "yes";
+    }
+}
diff --git a/tools/hrw4u/tests/data/sandbox/denied-language-in.sandbox.yaml 
b/tools/hrw4u/tests/data/sandbox/denied-language-in.sandbox.yaml
new file mode 100644
index 0000000000..f10fef32e1
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-language-in.sandbox.yaml
@@ -0,0 +1,6 @@
+sandbox:
+  message: "Feature denied by sandbox policy. Contact platform team."
+
+  deny:
+    language:
+      - in
diff --git a/tools/hrw4u/tests/data/sandbox/denied-modifier-nocase.ast.txt 
b/tools/hrw4u/tests/data/sandbox/denied-modifier-nocase.ast.txt
new file mode 100644
index 0000000000..abdf80daea
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-modifier-nocase.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section REMAP { (sectionBody (conditional (ifStatement 
if (condition (expression (term (factor (comparison (comparable 
inbound.req.X-Foo) == (value "bar") (modifier with (modifierList NOCASE))))))) 
(block { (blockItem (statement inbound.req.X-Result = (value "yes") ;)) })))) 
})) <EOF>)
diff --git a/tools/hrw4u/tests/data/sandbox/denied-modifier-nocase.error.txt 
b/tools/hrw4u/tests/data/sandbox/denied-modifier-nocase.error.txt
new file mode 100644
index 0000000000..3177ef0613
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-modifier-nocase.error.txt
@@ -0,0 +1 @@
+'NOCASE' is denied by sandbox policy (modifier)
diff --git a/tools/hrw4u/tests/data/sandbox/denied-modifier-nocase.input.txt 
b/tools/hrw4u/tests/data/sandbox/denied-modifier-nocase.input.txt
new file mode 100644
index 0000000000..59d52c70ef
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-modifier-nocase.input.txt
@@ -0,0 +1,5 @@
+REMAP {
+    if inbound.req.X-Foo == "bar" with NOCASE {
+        inbound.req.X-Result = "yes";
+    }
+}
diff --git a/tools/hrw4u/tests/data/sandbox/denied-modifier-nocase.sandbox.yaml 
b/tools/hrw4u/tests/data/sandbox/denied-modifier-nocase.sandbox.yaml
new file mode 100644
index 0000000000..8f849222ad
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-modifier-nocase.sandbox.yaml
@@ -0,0 +1,6 @@
+sandbox:
+  message: "Modifier denied by sandbox policy. Contact platform team."
+
+  deny:
+    modifiers:
+      - NOCASE
diff --git a/tools/hrw4u/tests/data/sandbox/denied-modifier-or.ast.txt 
b/tools/hrw4u/tests/data/sandbox/denied-modifier-or.ast.txt
new file mode 100644
index 0000000000..e2741a07bf
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-modifier-or.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section REMAP { (sectionBody (conditional (ifStatement 
if (condition (expression (expression (term (factor ( (expression (term (factor 
(comparison (comparable inbound.req.X-A) == (value "a"))))) )))) || (term 
(factor ( (expression (term (factor (comparison (comparable inbound.req.X-B) == 
(value "b"))))) ))))) (block { (blockItem (statement inbound.req.X-Result = 
(value "yes") ;)) })))) })) <EOF>)
diff --git a/tools/hrw4u/tests/data/sandbox/denied-modifier-or.error.txt 
b/tools/hrw4u/tests/data/sandbox/denied-modifier-or.error.txt
new file mode 100644
index 0000000000..687b624430
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-modifier-or.error.txt
@@ -0,0 +1 @@
+'OR' is denied by sandbox policy (modifier)
diff --git a/tools/hrw4u/tests/data/sandbox/denied-modifier-or.input.txt 
b/tools/hrw4u/tests/data/sandbox/denied-modifier-or.input.txt
new file mode 100644
index 0000000000..3a2041d920
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-modifier-or.input.txt
@@ -0,0 +1,5 @@
+REMAP {
+    if (inbound.req.X-A == "a") || (inbound.req.X-B == "b") {
+        inbound.req.X-Result = "yes";
+    }
+}
diff --git a/tools/hrw4u/tests/data/sandbox/denied-modifier-or.sandbox.yaml 
b/tools/hrw4u/tests/data/sandbox/denied-modifier-or.sandbox.yaml
new file mode 100644
index 0000000000..11954a3062
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-modifier-or.sandbox.yaml
@@ -0,0 +1,6 @@
+sandbox:
+  message: "Modifier denied by sandbox policy. Contact platform team."
+
+  deny:
+    modifiers:
+      - OR
diff --git a/tools/hrw4u/tests/data/sandbox/denied-section.ast.txt 
b/tools/hrw4u/tests/data/sandbox/denied-section.ast.txt
new file mode 100644
index 0000000000..1e85d1675a
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-section.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section PRE_REMAP { (sectionBody (statement 
inbound.req.X-Foo = (value "test") ;)) })) <EOF>)
diff --git a/tools/hrw4u/tests/data/sandbox/denied-section.error.txt 
b/tools/hrw4u/tests/data/sandbox/denied-section.error.txt
new file mode 100644
index 0000000000..04d8c28c8f
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-section.error.txt
@@ -0,0 +1,2 @@
+'PRE_REMAP' is denied by sandbox policy (section)
+Feature denied by sandbox policy. Contact platform team.
diff --git a/tools/hrw4u/tests/data/sandbox/denied-section.input.txt 
b/tools/hrw4u/tests/data/sandbox/denied-section.input.txt
new file mode 100644
index 0000000000..637d71a216
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-section.input.txt
@@ -0,0 +1,3 @@
+PRE_REMAP {
+    inbound.req.X-Foo = "test";
+}
diff --git a/tools/hrw4u/tests/data/sandbox/exceptions.txt 
b/tools/hrw4u/tests/data/sandbox/exceptions.txt
new file mode 100644
index 0000000000..7d3a5c986c
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/exceptions.txt
@@ -0,0 +1,10 @@
+# Sandbox test exceptions
+# Format: test_name: direction
+#
+# Sandbox deny tests run WITH a sandbox config via the test suite;
+# testcase.py runs without one. Mark deny tests that would produce
+# symbol errors without a sandbox as u4wrh so testcase.py skips them.
+#
+# per-test-sandbox uses TXN_START, which rejects inbound.req.X-Foo
+# without a sandbox (section denial fires first in the real test).
+per-test-sandbox.input: u4wrh
diff --git a/tools/hrw4u/tests/data/sandbox/multiple-denials.ast.txt 
b/tools/hrw4u/tests/data/sandbox/multiple-denials.ast.txt
new file mode 100644
index 0000000000..f7a07055ca
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/multiple-denials.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section REMAP { (sectionBody (statement (functionCall 
set-debug ( )) ;)) (sectionBody (statement break ;)) })) <EOF>)
diff --git a/tools/hrw4u/tests/data/sandbox/multiple-denials.error.txt 
b/tools/hrw4u/tests/data/sandbox/multiple-denials.error.txt
new file mode 100644
index 0000000000..ab4a265cdd
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/multiple-denials.error.txt
@@ -0,0 +1,4 @@
+Found 2 errors:
+'set-debug' is denied by sandbox policy (function)
+'break' is denied by sandbox policy (language)
+Feature denied by sandbox policy. Contact platform team.
diff --git a/tools/hrw4u/tests/data/sandbox/multiple-denials.input.txt 
b/tools/hrw4u/tests/data/sandbox/multiple-denials.input.txt
new file mode 100644
index 0000000000..42dc9dc061
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/multiple-denials.input.txt
@@ -0,0 +1,4 @@
+REMAP {
+    set-debug();
+    break;
+}
diff --git a/tools/hrw4u/tests/data/sandbox/per-test-sandbox.error.txt 
b/tools/hrw4u/tests/data/sandbox/per-test-sandbox.error.txt
new file mode 100644
index 0000000000..9ac39a0dff
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/per-test-sandbox.error.txt
@@ -0,0 +1 @@
+'TXN_START' is denied by sandbox policy (section)
diff --git a/tools/hrw4u/tests/data/sandbox/per-test-sandbox.input.txt 
b/tools/hrw4u/tests/data/sandbox/per-test-sandbox.input.txt
new file mode 100644
index 0000000000..bdfdf647a7
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/per-test-sandbox.input.txt
@@ -0,0 +1,3 @@
+TXN_START {
+    inbound.req.X-Foo = "test";
+}
diff --git a/tools/hrw4u/tests/data/sandbox/per-test-sandbox.sandbox.yaml 
b/tools/hrw4u/tests/data/sandbox/per-test-sandbox.sandbox.yaml
new file mode 100644
index 0000000000..39dcd84eb5
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/per-test-sandbox.sandbox.yaml
@@ -0,0 +1,4 @@
+sandbox:
+  deny:
+    sections:
+      - TXN_START
diff --git a/tools/hrw4u/tests/data/sandbox/sandbox.yaml 
b/tools/hrw4u/tests/data/sandbox/sandbox.yaml
new file mode 100644
index 0000000000..f70a2fcae9
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/sandbox.yaml
@@ -0,0 +1,12 @@
+sandbox:
+  message: "Feature denied by sandbox policy. Contact platform team."
+
+  deny:
+    sections:
+      - TXN_START
+      - TXN_CLOSE
+      - PRE_REMAP
+    functions:
+      - set-debug
+    language:
+      - break
diff --git a/tools/hrw4u/tests/data/sandbox/warned-function.ast.txt 
b/tools/hrw4u/tests/data/sandbox/warned-function.ast.txt
new file mode 100644
index 0000000000..fda638a730
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/warned-function.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section REMAP { (sectionBody (statement (functionCall 
set-config ( (argumentList (value "proxy.config.http.insert_age_in_response") , 
(value "0")) )) ;)) })) <EOF>)
diff --git a/tools/hrw4u/tests/data/sandbox/warned-function.input.txt 
b/tools/hrw4u/tests/data/sandbox/warned-function.input.txt
new file mode 100644
index 0000000000..a4ec2ee976
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/warned-function.input.txt
@@ -0,0 +1,3 @@
+REMAP {
+    set-config("proxy.config.http.insert_age_in_response", "0");
+}
diff --git a/tools/hrw4u/tests/data/sandbox/warned-function.output.txt 
b/tools/hrw4u/tests/data/sandbox/warned-function.output.txt
new file mode 100644
index 0000000000..8e97ce7d99
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/warned-function.output.txt
@@ -0,0 +1,2 @@
+cond %{REMAP_PSEUDO_HOOK} [AND]
+    set-config "proxy.config.http.insert_age_in_response" "0"
diff --git a/tools/hrw4u/tests/data/sandbox/warned-function.sandbox.yaml 
b/tools/hrw4u/tests/data/sandbox/warned-function.sandbox.yaml
new file mode 100644
index 0000000000..fd4ce1fada
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/warned-function.sandbox.yaml
@@ -0,0 +1,6 @@
+sandbox:
+  message: "This feature will be denied in a future release. Contact platform 
team."
+
+  warn:
+    functions:
+      - set-config
diff --git a/tools/hrw4u/tests/data/sandbox/warned-function.warning.txt 
b/tools/hrw4u/tests/data/sandbox/warned-function.warning.txt
new file mode 100644
index 0000000000..12f6fdeb3a
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/warned-function.warning.txt
@@ -0,0 +1,2 @@
+'set-config' is warned by sandbox policy (function)
+This feature will be denied in a future release. Contact platform team.
diff --git a/tools/hrw4u/tests/test_lsp.py b/tools/hrw4u/tests/test_lsp.py
index 5f2896bd9e..a3881456f5 100644
--- a/tools/hrw4u/tests/test_lsp.py
+++ b/tools/hrw4u/tests/test_lsp.py
@@ -333,9 +333,33 @@ class LSPClient:
         if self.stderr_thread and self.stderr_thread.is_alive():
             self.stderr_thread.join(timeout=1.0)
 
+    def wait_for_diagnostics(self, uri: str, timeout: float = 3.0) -> 
list[dict[str, Any]]:
+        """Wait for a publishDiagnostics notification for the given URI."""
+        start_time = time.time()
+        stashed: list[dict[str, Any]] = []
+
+        while time.time() - start_time < timeout:
+            try:
+                msg = self.response_queue.get(timeout=0.1)
+                if msg.get("method") == "textDocument/publishDiagnostics":
+                    if msg.get("params", {}).get("uri") == uri:
+                        # Put stashed messages back
+                        for m in stashed:
+                            self.response_queue.put(m)
+                        return msg["params"].get("diagnostics", [])
+                    else:
+                        stashed.append(msg)
+                else:
+                    stashed.append(msg)
+            except queue.Empty:
+                continue
+
+        for m in stashed:
+            self.response_queue.put(m)
+        return []
+
 
 def _create_test_document(client, content: str, test_name: str) -> str:
-    """Helper to create and open a test document."""
     uri = f"file:///test_{test_name}.hrw4u"
     client.open_document(uri, content)
     return uri
@@ -638,3 +662,61 @@ def test_unknown_namespace_fallback(shared_lsp_client) -> 
None:
 
     content = response["result"]["contents"]["value"]
     assert "HRW4U symbol" in content
+
+
+# ---------------------------------------------------------------------------
+# Sandbox LSP tests
+# ---------------------------------------------------------------------------
+
+SANDBOX_YAML = Path(__file__).parent / "data" / "sandbox" / "sandbox.yaml"
+
+
[email protected](scope="module")
+def sandbox_lsp_client():
+    """LSP client started with --sandbox flag."""
+    lsp_script = Path(__file__).parent.parent / "scripts" / "hrw4u-lsp"
+    if not lsp_script.exists():
+        pytest.skip("hrw4u-lsp script not found - run 'make' first")
+    if not SANDBOX_YAML.exists():
+        pytest.skip("sandbox.yaml not found")
+
+    client = LSPClient([str(lsp_script), "--sandbox", str(SANDBOX_YAML)])
+    client.start_server()
+    yield client
+    client.stop_server()
+
+
[email protected]
+def test_sandbox_denied_function_produces_diagnostic(sandbox_lsp_client) -> 
None:
+    """set-debug denied by sandbox should appear as a diagnostic error."""
+    content = 'REMAP {\n    set-debug();\n}\n'
+    uri = "file:///test_sandbox_denied_function.hrw4u"
+    sandbox_lsp_client.open_document(uri, content)
+    diagnostics = sandbox_lsp_client.wait_for_diagnostics(uri)
+
+    assert any("denied by sandbox policy" in d.get("message", "") for d in 
diagnostics), \
+        f"Expected sandbox denial diagnostic, got: {diagnostics}"
+
+
[email protected]
+def test_sandbox_denied_section_produces_diagnostic(sandbox_lsp_client) -> 
None:
+    """PRE_REMAP denied by sandbox should appear as a diagnostic error."""
+    content = 'PRE_REMAP {\n    inbound.req.X-Foo = "test";\n}\n'
+    uri = "file:///test_sandbox_denied_section.hrw4u"
+    sandbox_lsp_client.open_document(uri, content)
+    diagnostics = sandbox_lsp_client.wait_for_diagnostics(uri)
+
+    assert any("denied by sandbox policy" in d.get("message", "") for d in 
diagnostics), \
+        f"Expected sandbox denial diagnostic, got: {diagnostics}"
+
+
[email protected]
+def test_sandbox_allowed_content_has_no_denial(sandbox_lsp_client) -> None:
+    """Content using no denied features should produce no sandbox 
diagnostics."""
+    content = 'REMAP {\n    inbound.req.X-Foo = "allowed";\n}\n'
+    uri = "file:///test_sandbox_allowed.hrw4u"
+    sandbox_lsp_client.open_document(uri, content)
+    diagnostics = sandbox_lsp_client.wait_for_diagnostics(uri)
+
+    sandbox_errors = [d for d in diagnostics if "denied by sandbox policy" in 
d.get("message", "")]
+    assert not sandbox_errors, f"Expected no sandbox denials, got: 
{sandbox_errors}"
diff --git a/tools/hrw4u/tests/test_sandbox.py 
b/tools/hrw4u/tests/test_sandbox.py
new file mode 100644
index 0000000000..fbc0e7a025
--- /dev/null
+++ b/tools/hrw4u/tests/test_sandbox.py
@@ -0,0 +1,43 @@
+#
+#  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.
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+import utils
+
+
[email protected]
[email protected]("input_file,error_file,sandbox_file", 
utils.collect_sandbox_deny_test_files("sandbox"))
+def test_sandbox_denials(input_file: Path, error_file: Path, sandbox_file: 
Path) -> None:
+    """Test that sandbox-denied features produce expected errors."""
+    utils.run_sandbox_deny_test(input_file, error_file, sandbox_file)
+
+
[email protected]
[email protected]("input_file,output_file,sandbox_file", 
utils.collect_sandbox_allow_test_files("sandbox"))
+def test_sandbox_allowed(input_file: Path, output_file: Path, sandbox_file: 
Path) -> None:
+    """Test that features not in the deny list compile normally under a 
sandbox."""
+    utils.run_sandbox_allow_test(input_file, output_file, sandbox_file)
+
+
[email protected]
[email protected]("input_file,warning_file,output_file,sandbox_file", 
utils.collect_sandbox_warn_test_files("sandbox"))
+def test_sandbox_warnings(input_file: Path, warning_file: Path, output_file: 
Path, sandbox_file: Path) -> None:
+    """Test that sandbox-warned features produce warnings but compile 
successfully."""
+    utils.run_sandbox_warn_test(input_file, warning_file, output_file, 
sandbox_file)
diff --git a/tools/hrw4u/tests/utils.py b/tools/hrw4u/tests/utils.py
index eefbd7249f..6b76ca1f6c 100644
--- a/tools/hrw4u/tests/utils.py
+++ b/tools/hrw4u/tests/utils.py
@@ -28,6 +28,8 @@ from antlr4 import InputStream, CommonTokenStream
 from hrw4u.hrw4uLexer import hrw4uLexer
 from hrw4u.hrw4uParser import hrw4uParser
 from hrw4u.visitor import HRW4UVisitor
+from hrw4u.sandbox import SandboxConfig
+from hrw4u.errors import ErrorCollector
 from u4wrh.u4wrhLexer import u4wrhLexer
 from u4wrh.u4wrhParser import u4wrhParser
 from u4wrh.hrw_visitor import HRWInverseVisitor
@@ -38,9 +40,15 @@ __all__: Final[list[str]] = [
     "collect_output_test_files",
     "collect_ast_test_files",
     "collect_failing_inputs",
+    "collect_sandbox_deny_test_files",
+    "collect_sandbox_allow_test_files",
+    "collect_sandbox_warn_test_files",
     "run_output_test",
     "run_ast_test",
     "run_failing_test",
+    "run_sandbox_deny_test",
+    "run_sandbox_allow_test",
+    "run_sandbox_warn_test",
     "run_reverse_test",
     "run_bulk_test",
     "run_procedure_output_test",
@@ -120,6 +128,73 @@ def collect_failing_inputs(group: str) -> 
Iterator[pytest.param]:
         yield pytest.param(input_file, id=test_id)
 
 
+def _collect_sandbox_test_files(group: str, result_suffix: str) -> 
Iterator[pytest.param]:
+    """Collect sandbox test files: (input, result, sandbox_config).
+
+    Uses a per-test `{name}.sandbox.yaml` if present, otherwise falls back
+    to a shared `sandbox.yaml` in the same directory.
+    """
+    base_dir = Path("tests/data") / group
+    shared_sandbox = base_dir / "sandbox.yaml"
+
+    for input_file in sorted(base_dir.glob("*.input.txt")):
+        base = input_file.with_suffix("").with_suffix("")
+        result_file = base.with_suffix(result_suffix)
+
+        if not result_file.exists():
+            continue
+
+        per_test_sandbox = base.with_suffix(".sandbox.yaml")
+        sandbox_file = per_test_sandbox if per_test_sandbox.exists() else 
shared_sandbox
+
+        if not sandbox_file.exists():
+            continue
+
+        yield pytest.param(input_file, result_file, sandbox_file, id=base.name)
+
+
+def collect_sandbox_deny_test_files(group: str) -> Iterator[pytest.param]:
+    """Collect sandbox denial test files: (input, error, sandbox_config)."""
+    yield from _collect_sandbox_test_files(group, ".error.txt")
+
+
+def collect_sandbox_allow_test_files(group: str) -> Iterator[pytest.param]:
+    """Collect sandbox allow test files: (input, output, sandbox_config).
+
+    Skips warned-* files which are handled by collect_sandbox_warn_test_files.
+    """
+    for param in _collect_sandbox_test_files(group, ".output.txt"):
+        input_file = param.values[0]
+        if not input_file.name.startswith("warned-"):
+            yield param
+
+
+def collect_sandbox_warn_test_files(group: str) -> Iterator[pytest.param]:
+    """Collect sandbox warning test files: (input, warning, output, 
sandbox_config).
+
+    Warning tests have both a .warning.txt (expected warning phrases) and a
+    .output.txt (expected compiled output), since compilation should succeed.
+    """
+    base_dir = Path("tests/data") / group
+    shared_sandbox = base_dir / "sandbox.yaml"
+
+    for input_file in sorted(base_dir.glob("warned-*.input.txt")):
+        base = input_file.with_suffix("").with_suffix("")
+        warning_file = base.with_suffix(".warning.txt")
+        output_file = base.with_suffix(".output.txt")
+
+        if not warning_file.exists() or not output_file.exists():
+            continue
+
+        per_test_sandbox = base.with_suffix(".sandbox.yaml")
+        sandbox_file = per_test_sandbox if per_test_sandbox.exists() else 
shared_sandbox
+
+        if not sandbox_file.exists():
+            continue
+
+        yield pytest.param(input_file, warning_file, output_file, 
sandbox_file, id=base.name)
+
+
 def run_output_test(input_file: Path, output_file: Path) -> None:
     input_text = input_file.read_text()
     parser, tree = parse_input_text(input_text)
@@ -213,6 +288,79 @@ def _assert_structured_error_fields(
         f"Actual full error:\n{actual_full_error}")
 
 
+def run_sandbox_deny_test(input_file: Path, error_file: Path, sandbox_file: 
Path) -> None:
+    """Run a sandbox denial test, verifying that denied features produce 
expected errors."""
+    text = input_file.read_text()
+    parser, tree = parse_input_text(text)
+
+    sandbox = SandboxConfig.load(sandbox_file)
+    error_collector = ErrorCollector()
+    visitor = HRW4UVisitor(filename=str(input_file), 
error_collector=error_collector, sandbox=sandbox)
+    visitor.visit(tree)
+
+    assert error_collector.has_errors(), f"Expected sandbox errors but none 
were raised for {input_file}"
+
+    actual_summary = error_collector.get_error_summary()
+    expected_content = error_file.read_text().strip()
+
+    for line in expected_content.splitlines():
+        line = line.strip()
+        if line:
+            assert line in actual_summary, (
+                f"Expected phrase not found in error summary for 
{input_file}:\n"
+                f"  Missing: {line!r}\n"
+                f"Actual summary:\n{actual_summary}")
+
+
+def run_sandbox_allow_test(input_file: Path, output_file: Path, sandbox_file: 
Path) -> None:
+    """Run a sandbox allow test, verifying that non-denied features compile 
normally."""
+    text = input_file.read_text()
+    parser, tree = parse_input_text(text)
+
+    sandbox = SandboxConfig.load(sandbox_file)
+    error_collector = ErrorCollector()
+    visitor = HRW4UVisitor(filename=str(input_file), 
error_collector=error_collector, sandbox=sandbox)
+    actual_output = "\n".join(visitor.visit(tree) or []).strip()
+
+    assert not error_collector.has_errors(), (
+        f"Expected no errors but sandbox denied something in {input_file}:\n"
+        f"{error_collector.get_error_summary()}")
+
+    expected_output = output_file.read_text().strip()
+    assert actual_output == expected_output, f"Output mismatch in {input_file}"
+
+
+def run_sandbox_warn_test(input_file: Path, warning_file: Path, output_file: 
Path, sandbox_file: Path) -> None:
+    """Run a sandbox warning test: compilation succeeds, warnings are emitted, 
output matches."""
+    text = input_file.read_text()
+    parser, tree = parse_input_text(text)
+
+    sandbox = SandboxConfig.load(sandbox_file)
+    error_collector = ErrorCollector()
+    visitor = HRW4UVisitor(filename=str(input_file), 
error_collector=error_collector, sandbox=sandbox)
+    actual_output = "\n".join(visitor.visit(tree) or []).strip()
+
+    assert not error_collector.has_errors(), (
+        f"Expected no errors but got errors in {input_file}:\n"
+        f"{error_collector.get_error_summary()}")
+
+    assert error_collector.has_warnings(), f"Expected warnings but none were 
emitted for {input_file}"
+
+    actual_summary = error_collector.get_error_summary()
+    expected_warnings = warning_file.read_text().strip()
+
+    for line in expected_warnings.splitlines():
+        line = line.strip()
+        if line:
+            assert line in actual_summary, (
+                f"Expected warning phrase not found for {input_file}:\n"
+                f"  Missing: {line!r}\n"
+                f"Actual summary:\n{actual_summary}")
+
+    expected_output = output_file.read_text().strip()
+    assert actual_output == expected_output, f"Output mismatch in {input_file}"
+
+
 def run_reverse_test(input_file: Path, output_file: Path) -> None:
     output_text = output_file.read_text()
     lexer = u4wrhLexer(InputStream(output_text))


Reply via email to