From 35d7b728322e97e706010114bae2209a86ffc47e Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 24 May 2026 14:39:11 +0000 Subject: [PATCH] studio/sandbox: use _find_sensitive_paths for binding-bias instead of substring Refines the round-5 ``_looks_sensitive`` heuristic that biases ``_record_string_binding`` toward the dangerous value in a chained reassignment. The substring hint set conflated ``/etc/shadow`` with ``/etc/hosts`` -- both contain ``/etc/`` -- so a payload like ``p = '/etc/hosts'; p = '/etc/shadow'; open(p)`` had ``cur`` already flagged sensitive, the guard refused to update, and the resolved value stayed at ``/etc/hosts`` (allow-listed). The chained shadow binding then slipped through. Now ``_looks_sensitive`` delegates to ``_find_sensitive_paths``, the authoritative bash / file gate matcher, so the distinction is exact: ``/etc/hosts`` is allow-listed and ``/etc/shadow`` is sensitive. ``_record_string_binding`` also adopts a clean three-way rule mirroring Python's last-wins semantics for sensitive values: * New sensitive value: always wins (covers the chained shadow case). * New benign value, current sensitive: keep current (static gate cannot prove the new value executes; err on blocking). * Both benign: latest seen wins. Test suite still 528 passing. --- studio/backend/core/inference/tools.py | 61 +++++++++++--------------- 1 file changed, 26 insertions(+), 35 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index b07893df40..ef5c799cd0 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -1364,46 +1364,33 @@ def _check_signal_escape_patterns(code: str): if alias.name in _PATHLIB_PATH_CLASSES_PREPASS: path_class_aliases_prepass.add(alias.asname or alias.name) - # Cheap hint set used to bias ``string_bindings`` toward the most - # sensitive value of a name when multiple literals are assigned. - # Substring match against the full set of credential / process-state - # root tokens (kept intentionally loose; the real per-spec match - # still runs downstream in the file-read / shutil / bash gates). - _SENSITIVE_HINTS = ( - "/etc/", - "/proc/", - "/var/spool/", - "/root/", - ".ssh/", - ".aws/", - ".gnupg", - ".kube", - ".docker", - ".config/gcloud", - ".pypirc", - ".npmrc", - ".netrc", - ".cargo/credentials", - ".password-store", - "credentials", - "id_rsa", - "id_dsa", - "id_ecdsa", - "id_ed25519", - "shadow", - "sudoers", - ) - def _looks_sensitive(value: str) -> bool: + """True if *value* matches any host-credential / process-state + path that the bash / file gates already flag. Uses the + authoritative ``_find_sensitive_paths`` so the bias distinguishes + ``/etc/shadow`` (sensitive) from ``/etc/hosts`` (allow-listed) -- + a substring hint set conflates them and admits a chained- + reassignment bypass ``p='/etc/hosts'; p='/etc/shadow'``.""" if not value: return False - low = value.lower() - return any(hint in low for hint in _SENSITIVE_HINTS) + return bool(_find_sensitive_paths(value)) def _record_string_binding(name: str, value: str) -> None: """Append ``value`` to ``string_bindings_all[name]`` and update - ``string_bindings[name]`` to favour a sensitive-shaped value - when one exists. Resists the second-assignment bypass.""" + ``string_bindings[name]`` so the gate sees the most sensitive + value the variable could carry at runtime. The selection rule + mirrors Python's last-wins semantics for sensitive values: + + * If the new value is sensitive, it always wins (even if the + current is also sensitive) -- a later sensitive assignment + is at least as concerning as an earlier one, and the chain + ``p='/etc/hosts'; p='/etc/shadow'`` must surface the shadow. + * If the new value is benign and the current sensitive, keep + the sensitive value (Python would last-wins to benign, but + statically we cannot prove the new value executes and we + err on the side of blocking the path the attacker reached + for). + * If both are benign, latest seen wins.""" bucket = string_bindings_all.setdefault(name, []) if value not in bucket: bucket.append(value) @@ -1411,8 +1398,12 @@ def _check_signal_escape_patterns(code: str): if cur is None: string_bindings[name] = value return - if _looks_sensitive(value) and not _looks_sensitive(cur): + if _looks_sensitive(value): string_bindings[name] = value + return + if _looks_sensitive(cur): + return + string_bindings[name] = value def _extract_string_literal(node, _depth = 0): """Strict literal-string extraction: no name binding lookup,