studio: resolve concatenated + f-string paths in sensitive-file gate (Patch A)

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.
This commit is contained in:
Daniel Han 2026-05-19 06:54:26 +00:00
commit 3e4704a856

View file

@ -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):