Skip to content

Commit 1953005

Browse files
Merge pull request #35 from NVIDIA-NeMo/security/codeact-constructor-string-coercion
2 parents ec4bd58 + 26f6d0a commit 1953005

3 files changed

Lines changed: 607 additions & 23 deletions

File tree

src/nooa/strategies/codeact.py

Lines changed: 180 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,23 @@
7777
logger = logging.getLogger(__name__)
7878

7979

80+
# Small, deterministic expression subset accepted inside constructor-string
81+
# arguments. The values supplied to these callables have already been reduced
82+
# to plain data by ``_safe_constructor_arg``; callbacks and object attributes
83+
# therefore cannot cross into this compatibility path.
84+
_SAFE_CONSTRUCTOR_CALLS = {
85+
"abs": abs,
86+
"all": all,
87+
"any": any,
88+
"len": len,
89+
"max": max,
90+
"min": min,
91+
"round": round,
92+
"sorted": sorted,
93+
"sum": sum,
94+
}
95+
96+
8097
class _ReturnResultSignal(ExecutionSignal):
8198
"""Signal raised when return_result() is called from within execute_python code.
8299
@@ -2009,13 +2026,135 @@ def _corrected_return_args(self, validated: Any, original_args: dict) -> dict:
20092026

20102027
return {"result": corrected_result}
20112028

2029+
@staticmethod
2030+
def _safe_constructor_arg(node: ast.AST, session_locals: dict[str, Any]) -> Any:
2031+
"""Decode one constructor argument without executing Python code.
2032+
2033+
Literal constants/containers are decoded directly. Bare names retain
2034+
pass-by-reference semantics by resolving directly from the REPL namespace.
2035+
Containers are rebuilt recursively so they can contain those references
2036+
without evaluating Python expressions. A short allowlist covers common
2037+
deterministic expressions emitted by models, such as ``min(...)``; those
2038+
helpers operate only on copied plain data. Everything else, including
2039+
attributes and arbitrary callables, is rejected.
2040+
"""
2041+
2042+
if isinstance(node, ast.Name):
2043+
if node.id not in session_locals:
2044+
raise ValueError(f"unknown constructor argument name: {node.id}")
2045+
# Dictionary lookup does not invoke any behavior on the referenced
2046+
# object. The trusted return type receives the same object identity.
2047+
return session_locals[node.id]
2048+
2049+
if isinstance(node, ast.Constant):
2050+
return CodeActStrategy._copy_constructor_data(node.value)
2051+
2052+
if isinstance(node, ast.List):
2053+
return [
2054+
CodeActStrategy._safe_constructor_arg(item, session_locals) for item in node.elts
2055+
]
2056+
2057+
if isinstance(node, ast.Tuple):
2058+
return tuple(
2059+
CodeActStrategy._safe_constructor_arg(item, session_locals) for item in node.elts
2060+
)
2061+
2062+
if isinstance(node, ast.Set):
2063+
return {
2064+
CodeActStrategy._copy_constructor_data(
2065+
CodeActStrategy._safe_constructor_arg(item, session_locals)
2066+
)
2067+
for item in node.elts
2068+
}
2069+
2070+
if isinstance(node, ast.Dict):
2071+
result: dict[Any, Any] = {}
2072+
for key_node, value_node in zip(node.keys, node.values, strict=True):
2073+
if key_node is None:
2074+
expanded = CodeActStrategy._safe_constructor_arg(value_node, session_locals)
2075+
if type(expanded) is not dict:
2076+
raise ValueError("literal ** expansion must be an exact dict")
2077+
for key, item in expanded.items():
2078+
result[CodeActStrategy._copy_constructor_data(key)] = item
2079+
else:
2080+
key = CodeActStrategy._copy_constructor_data(
2081+
CodeActStrategy._safe_constructor_arg(key_node, session_locals)
2082+
)
2083+
item = CodeActStrategy._safe_constructor_arg(value_node, session_locals)
2084+
result[key] = item
2085+
return result
2086+
2087+
# literal_eval supports numeric signs and complex-number literals without
2088+
# admitting general arithmetic or overloaded session objects.
2089+
if isinstance(node, (ast.UnaryOp, ast.BinOp)):
2090+
try:
2091+
return CodeActStrategy._copy_constructor_data(ast.literal_eval(node))
2092+
except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError) as exc:
2093+
raise ValueError("unsupported constructor numeric expression") from exc
2094+
2095+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
2096+
fn = _SAFE_CONSTRUCTOR_CALLS.get(node.func.id)
2097+
if fn is None:
2098+
raise ValueError(f"unsupported constructor argument call: {node.func.id}")
2099+
args = [
2100+
CodeActStrategy._copy_constructor_data(
2101+
CodeActStrategy._safe_constructor_arg(arg, session_locals)
2102+
)
2103+
for arg in node.args
2104+
]
2105+
kwargs: dict[str, Any] = {}
2106+
for keyword in node.keywords:
2107+
if keyword.arg is None:
2108+
expanded = CodeActStrategy._copy_constructor_data(
2109+
CodeActStrategy._safe_constructor_arg(keyword.value, session_locals)
2110+
)
2111+
if type(expanded) is not dict or not all(type(key) is str for key in expanded):
2112+
raise ValueError("safe call **kwargs must be a string-keyed dict")
2113+
kwargs.update(expanded)
2114+
else:
2115+
kwargs[keyword.arg] = CodeActStrategy._copy_constructor_data(
2116+
CodeActStrategy._safe_constructor_arg(keyword.value, session_locals)
2117+
)
2118+
result = fn(*args, **kwargs)
2119+
return CodeActStrategy._copy_constructor_data(result)
2120+
2121+
raise ValueError(f"unsupported constructor argument syntax: {type(node).__name__}")
2122+
2123+
@staticmethod
2124+
def _copy_constructor_data(value: Any) -> Any:
2125+
"""Copy an exact built-in scalar/container value or reject it.
2126+
2127+
Used only before invoking a fixed deterministic helper. Exact-type checks
2128+
prevent arbitrary references from reaching those calls through conversion
2129+
or iteration hooks.
2130+
"""
2131+
2132+
if value is None or type(value) in (bool, int, float, complex, str, bytes):
2133+
return value
2134+
if type(value) is list:
2135+
return [CodeActStrategy._copy_constructor_data(item) for item in value]
2136+
if type(value) is tuple:
2137+
return tuple(CodeActStrategy._copy_constructor_data(item) for item in value)
2138+
if type(value) is set:
2139+
return {CodeActStrategy._copy_constructor_data(item) for item in value}
2140+
if type(value) is frozenset:
2141+
return frozenset(CodeActStrategy._copy_constructor_data(item) for item in value)
2142+
if type(value) is dict:
2143+
return {
2144+
CodeActStrategy._copy_constructor_data(key): CodeActStrategy._copy_constructor_data(
2145+
item
2146+
)
2147+
for key, item in value.items()
2148+
}
2149+
raise ValueError(f"constructor argument {type(value).__name__} is not plain data")
2150+
20122151
def _maybe_eval_constructor_string(self, value: str, return_type: Any, session: Any) -> Any:
2013-
"""Eval a string that looks like a Python constructor call.
2152+
"""Parse a string that looks like a Python constructor call.
20142153
20152154
Detects patterns like 'ClassName(field=value, ...)' where ClassName
2016-
matches the expected return type. Evaluates in the session namespace
2017-
so the type is available. Returns the constructed object on success,
2018-
or the original string on failure.
2155+
matches the expected return type. Only plain literal/data arguments and
2156+
a small deterministic expression subset are accepted. Returns the
2157+
constructed object on success, or the original string on failure.
20192158
20202159
This handles a common LLM failure mode where the model calls
20212160
return_result as a tool with the constructor as a string argument
@@ -2034,21 +2173,46 @@ def _maybe_eval_constructor_string(self, value: str, return_type: Any, session:
20342173
if not candidate_name.isidentifier():
20352174
return value
20362175

2037-
# Check that the candidate name matches the expected return type
2038-
# or is available in session locals
2176+
# Only the trusted return type may be constructed. Preserve safe aliases
2177+
# without allowing an arbitrary session-local factory to become code.
20392178
type_name = getattr(return_type, "__name__", None)
2040-
if candidate_name != type_name and candidate_name not in session.session_locals:
2179+
if not isinstance(return_type, type):
2180+
return value
2181+
if (
2182+
candidate_name != type_name
2183+
and session.session_locals.get(candidate_name) is not return_type
2184+
):
20412185
return value
20422186

2043-
# Build eval namespace: session locals + the return type itself (which may
2044-
# live in module globals rather than session_locals).
2045-
eval_ns = dict(session.session_locals)
2046-
if type_name and type_name not in eval_ns and isinstance(return_type, type):
2047-
eval_ns[type_name] = return_type
2048-
2049-
# Try to eval in the combined namespace
20502187
try:
2051-
result = eval(stripped, {"__builtins__": {}}, eval_ns) # noqa: S307
2188+
parsed = ast.parse(stripped, mode="eval").body
2189+
if not isinstance(parsed, ast.Call) or not isinstance(parsed.func, ast.Name):
2190+
return value
2191+
if parsed.func.id != candidate_name:
2192+
return value
2193+
2194+
args: list[Any] = []
2195+
for arg in parsed.args:
2196+
if isinstance(arg, ast.Starred):
2197+
expanded = self._safe_constructor_arg(arg.value, session.session_locals)
2198+
if type(expanded) not in (list, tuple):
2199+
raise ValueError("constructor *args must be an exact list or tuple")
2200+
args.extend(expanded)
2201+
else:
2202+
args.append(self._safe_constructor_arg(arg, session.session_locals))
2203+
kwargs: dict[str, Any] = {}
2204+
for keyword in parsed.keywords:
2205+
if keyword.arg is None:
2206+
expanded = self._safe_constructor_arg(keyword.value, session.session_locals)
2207+
if type(expanded) is not dict or not all(type(key) is str for key in expanded):
2208+
raise ValueError("constructor **kwargs must be a string-keyed dict")
2209+
kwargs.update(expanded)
2210+
else:
2211+
kwargs[keyword.arg] = self._safe_constructor_arg(
2212+
keyword.value, session.session_locals
2213+
)
2214+
2215+
result = return_type(*args, **kwargs)
20522216
get_harness_metrics().constructor_string_coerced(candidate_name)
20532217
logger.debug(
20542218
"[CODEACT] Coerced constructor-call string %r into %s instance",
@@ -2058,7 +2222,7 @@ def _maybe_eval_constructor_string(self, value: str, return_type: Any, session:
20582222
return result
20592223
except Exception:
20602224
logger.debug(
2061-
"[CODEACT] Failed to eval constructor string %r, returning as-is",
2225+
"[CODEACT] Failed to parse constructor string %r, returning as-is",
20622226
stripped[:80],
20632227
)
20642228
return value

0 commit comments

Comments
 (0)