From 3e4704a856bf2d6ab95b92f70fe2f4d61fb9fbe9 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 19 May 2026 06:54:26 +0000 Subject: [PATCH] studio: resolve concatenated + f-string paths in sensitive-file gate (Patch A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static-string resolution in _extract_string_from_node was limited to bare ast.Constant. Concatenated string literals and f-strings with constant parts evaluated to ast.BinOp / ast.JoinedStr and slipped past the open() sensitive-file check, so: open('/etc/' + 'shadow') # ALLOWED before open(f'/etc/{"shadow"}') # ALLOWED before open('/etc/passwd') # BLOCKED before (literal) The helper now resolves ast.BinOp(Add) of two resolvable strings and ast.JoinedStr whose parts are themselves resolvable. The open() sensitive-file check uses the helper instead of an inline ast.Constant isinstance check, so the same widening covers concatenated/f-string paths without changing what was already blocked. Resolution is depth-capped at 6 to keep adversarial deep nesting from blowing the stack. All other call sites of the helper (_check_args_for_blocked, dynamic-arg shell-escape detection, HF upload path-shape inspection) automatically inherit the broader resolution. Closes open() gaps #4 and #6 from the documented 13-gap audit. Does not attempt to model variable flow (gap #5 stays open by design — the OS sandbox is the right layer for runtime flow). Regression: 131/131 studio/backend/tests/test_sandbox_tools.py pass. --- studio/backend/core/inference/tools.py | 47 ++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 0e9cce7c3e..32747ec3bf 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -862,10 +862,44 @@ def _check_signal_escape_patterns(code: str): } ) - def _extract_string_from_node(node): - """Extract a plain string value from an AST node, if it is a constant.""" + def _extract_string_from_node(node, _depth = 0): + """Extract a plain string value from an AST node when it can be + resolved statically. + + Handles: + * ``ast.Constant`` strings (unchanged from prior behaviour). + * ``ast.BinOp(ast.Add)`` joining two resolvable string operands. + Closes ``open('/etc/' + 'shadow')`` style dynamic paths. + * ``ast.JoinedStr`` (f-strings) whose ``FormattedValue`` parts + are themselves resolvable. Closes ``open(f'/etc/{"shadow"}')``. + + Resolution is depth-capped so adversarial deeply-nested + ``'a' + ('b' + ('c' + ...))`` cannot blow the stack. + Returns ``None`` whenever any subpart fails to resolve. + """ + if _depth > 6: + return None if isinstance(node, ast.Constant) and isinstance(node.value, str): return node.value + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + left = _extract_string_from_node(node.left, _depth + 1) + right = _extract_string_from_node(node.right, _depth + 1) + if left is not None and right is not None: + return left + right + return None + if isinstance(node, ast.JoinedStr): + parts: list[str] = [] + for v in node.values: + if isinstance(v, ast.Constant) and isinstance(v.value, str): + parts.append(v.value) + elif isinstance(v, ast.FormattedValue): + inner = _extract_string_from_node(v.value, _depth + 1) + if inner is None: + return None + parts.append(inner) + else: + return None + return "".join(parts) return None def _extract_strings_from_list(node): @@ -1736,10 +1770,11 @@ def _check_signal_escape_patterns(code: str): or fq.endswith(".open") ) if is_open_call and node.args: - a0 = node.args[0] - path_lit = None - if isinstance(a0, ast.Constant) and isinstance(a0.value, str): - path_lit = a0.value + # Use the static-string resolver so BinOp.Add of constants + # and JoinedStr (f-string) of constants are caught — not just + # bare ast.Constant. Closes ``open('/etc/' + 'shadow')`` + # and ``open(f'/etc/{"shadow"}')``. + path_lit = _extract_string_from_node(node.args[0]) if path_lit: flagged = False if any(path_lit.startswith(p) for p in _SENSITIVE_FILE_PREFIXES):