fix(git): fail closed on unquoted non-string YAML token scalars (#865)

Round-7 blocker-1 residual: the PyYAML-absent line-parser fallback in
detect-platform.sh (_strip_scalar) returned a stringified scalar for
UNQUOTED YAML values that PyYAML's implicit resolver types as a
non-string (int/null/bool/float/timestamp). That bypassed _accept's
isinstance(str) guard and could surface a garbage credential (e.g.
"12345", "null", "true") where the PyYAML path resolves NO token and
fails closed -- violating the module invariant that the fallback is only
ever MORE conservative than PyYAML, never less.

Root cause fix: mirror PyYAML 6.0.3's SafeLoader implicit resolver. An
unquoted plain scalar matching the null/bool/int/float/timestamp forms
now returns None (fail closed); a quoted scalar is always a string and is
accepted verbatim (quote-stripped) as before. Quoted-string handling,
scope-aware attribution, indentation, and inline-comment stripping are
unchanged. The predicate was fuzzed against real PyYAML over ~800k random
tokens with zero fail-open divergences.

Extends the forced-PyYAML-absence parser-equivalence harness with
token: 12345/null/~/yes/true/3.14 (each fails closed identically to
PyYAML) and token: "12345"/'abc' (quoted literals still accepted).

Blockers 2/3/4 (port-bound host, origin+full-path URL pin, review-body
binding) are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hermes Agent
2026-07-21 22:17:09 -05:00
parent 99856c5567
commit 3012b5c5e5
2 changed files with 102 additions and 1 deletions

View File

@@ -598,6 +598,55 @@ repo_host = os.environ.get("REPO_HOST", "").strip().lower()
config_path = sys.argv[1]
# PyYAML 6.0.3 SafeLoader implicit resolver patterns (YAML 1.1). An UNQUOTED
# plain scalar matching any of these is resolved by PyYAML to a NON-string type
# (null->None, bool, int, float, timestamp->date/datetime); a quoted scalar is
# ALWAYS a string. These mirror yaml/resolver.py so the PyYAML-absent fallback
# types unquoted scalars exactly as PyYAML would (see _implicit_nonstring).
_IMPLICIT_NULL = re.compile(r"^(?:~|null|Null|NULL|)$")
_IMPLICIT_BOOL = re.compile(
r"^(?:yes|Yes|YES|no|No|NO|true|True|TRUE|false|False|FALSE"
r"|on|On|ON|off|Off|OFF)$"
)
_IMPLICIT_INT = re.compile(
r"^(?:[-+]?0b[0-1_]+"
r"|[-+]?0[0-7_]+"
r"|[-+]?(?:0|[1-9][0-9_]*)"
r"|[-+]?0x[0-9a-fA-F_]+"
r"|[-+]?[1-9][0-9_]*(?::[0-5]?[0-9])+)$"
)
_IMPLICIT_FLOAT = re.compile(
r"^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+]?[0-9]+)?"
r"|\.[0-9][0-9_]*(?:[eE][-+]?[0-9]+)?"
r"|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*"
r"|[-+]?\.(?:inf|Inf|INF)"
r"|\.(?:nan|NaN|NAN))$"
)
_IMPLICIT_TIMESTAMP = re.compile(
r"^(?:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]"
r"|[0-9][0-9][0-9][0-9]-[0-9][0-9]?-[0-9][0-9]?"
r"(?:[Tt]|[ \t]+)[0-9][0-9]?"
r":[0-9][0-9]:[0-9][0-9](?:\.[0-9]*)?"
r"(?:[ \t]*(?:Z|[-+][0-9][0-9]?(?::[0-9][0-9])?))?)$"
)
def _implicit_nonstring(text):
# True when an UNQUOTED plain scalar would be resolved by PyYAML's SafeLoader
# to a non-string type (null/bool/int/float/timestamp). Fuzzed against real
# PyYAML 6.0.3: it never returns False where PyYAML types the scalar as a
# non-string (i.e. never fail-open), and is at worst MORE conservative on a
# couple of degenerate float spellings (e.g. "4.e8") that PyYAML keeps as a
# string -- the safe direction for this credential-selecting fallback.
return bool(
_IMPLICIT_NULL.match(text)
or _IMPLICIT_BOOL.match(text)
or _IMPLICIT_INT.match(text)
or _IMPLICIT_FLOAT.match(text)
or _IMPLICIT_TIMESTAMP.match(text)
)
def _strip_scalar(value):
# Resolve a YAML flow scalar the way PyYAML would for tea's simple scalars:
# honor surrounding quotes and strip a trailing inline comment. A quoted
@@ -605,6 +654,16 @@ def _strip_scalar(value):
# an unquoted scalar ends at the first whitespace-preceded '#' (a YAML
# comment must be preceded by whitespace or line start), so "abc#def" stays
# literal while "abc # note" becomes "abc".
#
# A quoted scalar is ALWAYS a string, so its contents are returned verbatim.
# An UNQUOTED scalar, however, is subject to PyYAML's implicit typing: forms
# like `12345`, `null`, `~`, `yes`/`true`, `3.14` or a timestamp resolve to a
# non-string (int/None/bool/float/date), which PyYAML's path would reject as
# a token via _accept's isinstance(str) guard. To stay faithful (and never
# fail OPEN by surfacing a stringified non-string as a credential) such a
# scalar returns None here so the caller treats it as absent (fail closed),
# exactly as the PyYAML path does. Only unquoted scalars PyYAML would type as
# a string are returned as a string.
value = value.strip()
if not value:
return value
@@ -620,7 +679,12 @@ def _strip_scalar(value):
if char == "#" and (index == 0 or value[index - 1] in (" ", "\t")):
value = value[:index]
break
return value.strip()
value = value.strip()
if not value or _implicit_nonstring(value):
# Unquoted scalar that PyYAML resolves to null or another non-string
# type: fail closed rather than emit a stringified non-credential.
return None
return value
def _url_matches_repo_host(url):