diff --git a/doc/admin-guide/configuration/hrw4u.en.rst b/doc/admin-guide/configuration/hrw4u.en.rst
index 1b7d2d2fcd6..af294dfefa7 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 cdn-sre@example.com 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 cdn-sre@example.com 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 c61b1049d77..7bd07cb17d6 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 826025451cb..0f9c1f636c4 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 4398e35c7df..498ca6b7bbd 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 00000000000..516064ceee1
--- /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 2940a4c970a..91a586a181b 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 ce78da8e4c0..e17c1886a43 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 7ca9c92ed46..38027a502f3 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 7b37a939bc2..51275c928a6 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 = "") -> None:
@@ -88,7 +107,7 @@ def syntaxError(self, recognizer: object, _: object, line: int, column: int, msg
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 @@ def add_context_note(self, context: str) -> None:
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 @@ def has_errors(self) -> bool:
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 00000000000..75ceb76593e
--- /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 979c9141ac0..7ef52fcf59e 100644
--- a/tools/hrw4u/src/symbols.py
+++ b/tools/hrw4u/src/symbols.py
@@ -25,12 +25,14 @@
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 @@ def declare_variable(self, name: str, type_name: str, explicit_slot: int | None
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 @@ def resolve_assignment(self, name: str, value: str, section: SectionType | None
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 @@ def resolve_add_assignment(self, name: str, value: str, section: SectionType | N
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 @@ def resolve_function(self, func_name: str, args: list[str], strip_quotes: bool =
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 bce15006fba..0e245164e73 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.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 @@ def __init__(self, debug: bool = SystemDefaults.DEFAULT_DEBUG, dbg: Dbg | None =
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 f6fdf651304..90ea932e68b 100644
--- a/tools/hrw4u/src/visitor.py
+++ b/tools/hrw4u/src/visitor.py
@@ -37,6 +37,7 @@
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 @@ def __init__(
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 @@ def __init__(
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 @@ def repl(m: re.Match) -> str:
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 @@ def _prepare_section(self, ctx):
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 @@ def visitVarSection(self, ctx) -> None:
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 @@ def visitStatement(self, ctx) -> None:
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 @@ def visitStatement(self, ctx) -> None:
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 @@ def visitStatement(self, ctx) -> None:
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 @@ def visitStatement(self, ctx) -> None:
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 @@ def visitIfStatement(self, ctx) -> None:
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 @@ def visitComparison(self, ctx, *, last: bool = False) -> None:
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 @@ def visitComparison(self, ctx, *, last: bool = False) -> None:
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 @@ def visitModifier(self, ctx) -> None:
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 @@ def visitFunctionCall(self, ctx) -> str:
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 @@ def _end_lhs_then_emit_rhs(self, set_and_or: bool, rhs_emitter) -> None:
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 @@ def emit_expression(self, ctx, *, nested: bool = False, last: bool = False, grou
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 @@ def emit_factor(self, ctx, *, last: bool = False) -> None:
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 531f98ab5f8..3f6881d5936 100644
--- a/tools/hrw4u/src/visitor_base.py
+++ b/tools/hrw4u/src/visitor_base.py
@@ -23,7 +23,8 @@
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 @@ def __init__(
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 @@ def __exit__(self, exc_type, exc_val, exc_tb):
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 @@ def __exit__(_, exc_type, exc, tb):
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 @@ def _parse_op_tails(self, node, ctx=None) -> tuple[list[str], object, object]:
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 00000000000..ff52d36c713
--- /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") ;)) })) )
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 00000000000..03d0ea6e525
--- /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 00000000000..4c68298572b
--- /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 00000000000..b1df2741276
--- /dev/null
+++ b/tools/hrw4u/tests/data/sandbox/denied-function.ast.txt
@@ -0,0 +1 @@
+(program (programItem (section REMAP { (sectionBody (statement (functionCall set-debug ( )) ;)) })) )
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 00000000000..4054fd4a899
--- /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 00000000000..8954b692226
--- /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 00000000000..f6e1872e715
--- /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 ;)) })) )
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 00000000000..9df5faeb5c9
--- /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 00000000000..773d2e80798
--- /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 00000000000..475169068b8
--- /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") ;)) })))) })) )
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 00000000000..ea2490396ad
--- /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 00000000000..96d094b6750
--- /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 00000000000..be279a696d8
--- /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 00000000000..45256070144
--- /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") ;)) })))) })) )
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 00000000000..4aa2d20b1d5
--- /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 00000000000..35261cb4f7a
--- /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 00000000000..58c2bde66b0
--- /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 00000000000..63ef38354c8
--- /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") ;)) })))) })) )
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 00000000000..539bb7d50eb
--- /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 00000000000..7deb8550973
--- /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 00000000000..f10fef32e15
--- /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 00000000000..abdf80daea9
--- /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") ;)) })))) })) )
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 00000000000..3177ef0613f
--- /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 00000000000..59d52c70ef9
--- /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 00000000000..8f849222ad4
--- /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 00000000000..e2741a07bf3
--- /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") ;)) })))) })) )
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 00000000000..687b6244302
--- /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 00000000000..3a2041d9208
--- /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 00000000000..11954a30621
--- /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 00000000000..1e85d1675a7
--- /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") ;)) })) )
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 00000000000..04d8c28c8f2
--- /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 00000000000..637d71a216e
--- /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 00000000000..7d3a5c986c1
--- /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 00000000000..f7a07055cae
--- /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 ;)) })) )
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 00000000000..ab4a265cdd1
--- /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 00000000000..42dc9dc0611
--- /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 00000000000..9ac39a0dffb
--- /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 00000000000..bdfdf647a71
--- /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 00000000000..39dcd84eb5e
--- /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 00000000000..f70a2fcae9e
--- /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 00000000000..fda638a730c
--- /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")) )) ;)) })) )
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 00000000000..a4ec2ee976f
--- /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 00000000000..8e97ce7d990
--- /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 00000000000..fd4ce1fadac
--- /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 00000000000..12f6fdeb3ad
--- /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 5f2896bd9e4..a3881456f51 100644
--- a/tools/hrw4u/tests/test_lsp.py
+++ b/tools/hrw4u/tests/test_lsp.py
@@ -333,9 +333,33 @@ def stop_server(self) -> None:
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"
+
+
+@pytest.fixture(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()
+
+
+@pytest.mark.sandbox
+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}"
+
+
+@pytest.mark.sandbox
+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}"
+
+
+@pytest.mark.sandbox
+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 00000000000..fbc0e7a025d
--- /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
+
+
+@pytest.mark.sandbox
+@pytest.mark.parametrize("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)
+
+
+@pytest.mark.sandbox
+@pytest.mark.parametrize("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)
+
+
+@pytest.mark.sandbox
+@pytest.mark.parametrize("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 eefbd7249f5..6b76ca1f6c7 100644
--- a/tools/hrw4u/tests/utils.py
+++ b/tools/hrw4u/tests/utils.py
@@ -28,6 +28,8 @@
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 @@
"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))