Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions loopx/capabilities/decision_context/packets.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ...control_plane.runtime.public_safety import (
REMOTE_LOCATION_SURFACE_PATTERN,
SECRET_LIKE_SURFACE_PATTERN,
find_public_safe_local_path,
)

DECISION_EVIDENCE_PACKET_SCHEMA_VERSION = "decision_evidence_packet_v0"
Expand Down Expand Up @@ -40,7 +41,10 @@
}

_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
_LOCAL_PATH_RE = re.compile(r"(^|[\s:=])(?:/Users/|/private/|/tmp/|~/)")
# Refs #5136, direction 3: "does this text carry a local path?" is decided once
# by find_public_safe_local_path; this site keeps its own rejection message and
# length limit for whatever the owner recognizes.
#
# Local threshold policy only: the credential *shapes* are decided once by
# SECRET_LIKE_SURFACE_PATTERN, which this site consults in addition to this list.
_CREDENTIAL_RE = re.compile(
Expand Down Expand Up @@ -77,7 +81,7 @@ def _compact_text(value: Any, *, field: str, max_len: int = 320) -> str:
raise ValueError(f"{field} must be non-empty")
if len(text) > max_len:
raise ValueError(f"{field} must be at most {max_len} characters")
if _LOCAL_PATH_RE.search(text):
if find_public_safe_local_path(text) is not None:
raise ValueError(f"{field} must not contain a local path")
if REMOTE_LOCATION_SURFACE_PATTERN.search(text):
raise ValueError(f"{field} must use an opaque source reference, not a raw URL")
Expand Down
8 changes: 6 additions & 2 deletions loopx/capabilities/material_lifecycle/_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@
from ...control_plane.runtime.public_safety import (
REMOTE_LOCATION_SURFACE_PATTERN,
SECRET_LIKE_SURFACE_PATTERN,
find_public_safe_local_path,
)

_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
_LOCAL_PATH_RE = re.compile(r"(^|[\s:=])(?:/Users/|/private/|/tmp/|~/)")
# Refs #5136, direction 3: "does this text carry a local path?" is decided once
# by find_public_safe_local_path; this site keeps its own rejection message and
# length limit for whatever the owner recognizes.
#
# Local threshold policy only: the credential *shapes* are decided once by
# SECRET_LIKE_SURFACE_PATTERN, which this site consults in addition to this list.
_CREDENTIAL_RE = re.compile(
Expand Down Expand Up @@ -53,7 +57,7 @@ def compact_text(value: Any, *, field: str, max_len: int = 320) -> str:
raise ValueError(f"{field} must be non-empty")
if len(text) > max_len:
raise ValueError(f"{field} must be at most {max_len} characters")
if _LOCAL_PATH_RE.search(text):
if find_public_safe_local_path(text) is not None:
raise ValueError(f"{field} must not contain a local path")
if REMOTE_LOCATION_SURFACE_PATTERN.search(text):
raise ValueError(f"{field} must use an opaque reference, not a raw URL")
Expand Down
2 changes: 2 additions & 0 deletions loopx/control_plane/runtime/public_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
# module are unchanged.
from ...public_safe_text import (
LOCAL_PATH_SURFACE_PATTERN as LOCAL_PATH_SURFACE_PATTERN,
PUBLIC_SAFE_LOCAL_PATH_PATTERNS as PUBLIC_SAFE_LOCAL_PATH_PATTERNS,
REMOTE_LOCATION_SURFACE_PATTERN as REMOTE_LOCATION_SURFACE_PATTERN,
SECRET_LIKE_SURFACE_PATTERN as SECRET_LIKE_SURFACE_PATTERN,
find_public_safe_local_path as find_public_safe_local_path,
)

NormalizeText = Callable[..., str]
Expand Down
56 changes: 46 additions & 10 deletions loopx/public_safe_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,16 @@
r")",
re.IGNORECASE,
)
# Refs #5136, direction 3: the shared classifier can *recognize* the local-path
# shapes the legacy surface pattern misses -- a home-relative `~/...` path and a
# local path behind an explicit `path:` prefix. Recognition is opt-in
# (`include_path_gaps`) so this consolidation does not silently tighten the 30+
# consumers of LOCAL_PATH_SURFACE_PATTERN; wiring these into a surface's
# enforcement policy is the disclosed behavior change tracked separately.
# `file://` is not added to the gap set here: direction 3 does classify it as a
# local path, but acting on that means a public projection stops carrying a
# location it accepts today, which is a disclosed tightening rather than part of
# this relocation. It is applied with the enforcement policy in the follow-up.
# Refs #5136, direction 3: the local-path shapes this owner recognizes.
# `LOCAL_PATH_SURFACE_PATTERN` carries the absolute roots, Windows drive letters
# and UNC shares; the gap pair adds the home-relative `~/...` form and a local
# path behind an explicit `path:` prefix. Whether a surface *rejects* what this
# owner recognizes stays the caller's named policy, so a surface can still opt
# into the narrower legacy set by asking for `LOCAL_PATH_SURFACE_PATTERN` alone.
# `file://` is deliberately not in this set: direction 3 does classify it as a
# local path, but every surface that has to stop carrying one already rejects it
# here as a raw remote location, and the surfaces that keep ordinary URLs would
# need a per-surface decision rather than a shared-pattern change.
HOME_RELATIVE_PATH_PATTERN = re.compile(r"(?<![\w~])~[\\/][^\s`'\"<>]+")
PATH_PREFIX_LOCAL_PATTERN = re.compile(
r"(?<![\w:])path:[\\/][^\s`'\"<>]+", re.IGNORECASE
Expand All @@ -108,6 +108,21 @@
HOME_RELATIVE_PATH_PATTERN,
PATH_PREFIX_LOCAL_PATTERN,
)
# The boundary form one pair of migrating surfaces already enforced: a local
# reference introduced by `:` or `=`. `LOCAL_PATH_SURFACE_PATTERN`'s lookbehind
# deliberately skips a preceding colon, so without this arm a surface moving
# onto the shared decision would start accepting `:/Users/...` -- a loosening
# no migration is allowed to introduce.
LOCAL_PATH_BOUNDARY_REFERENCE_PATTERN = re.compile(
r"(?:^|[\s:=])(?:/Users/|/private/|/tmp/|~[/\\])",
re.IGNORECASE,
)
PUBLIC_SAFE_LOCAL_PATH_PATTERNS: tuple[re.Pattern[str], ...] = (
LOCAL_PATH_SURFACE_PATTERN,
HOME_RELATIVE_PATH_PATTERN,
PATH_PREFIX_LOCAL_PATTERN,
LOCAL_PATH_BOUNDARY_REFERENCE_PATTERN,
)
# Refs #5136: one definition for "this string carries a raw remote location".
# Three validators each restated the same scheme list, and the canonical
# public-safety owner had no counterpart, so a fourth caller had to invent one.
Expand Down Expand Up @@ -262,6 +277,27 @@ def find_private_text_match(value: str | None) -> re.Pattern[str] | None:
)


def find_public_safe_local_path(value: str | None) -> re.Pattern[str] | None:
"""Return the local-path shape ``value`` carries, or None when it carries none.

This is the single answer to "is there a local path in this text" for
surfaces that publish outside the runtime (Refs #5136, direction 3): the
absolute roots, the two gap shapes `classify_private_text` reaches only when
a caller opts into `include_path_gaps`, and the colon/equals boundary form
the migrated surfaces already enforced. Recognition is still not permission:
a caller that must keep a narrower historical verdict asks for
`LOCAL_PATH_SURFACE_PATTERN` directly, and each surface keeps its own
rejection message and length limit.
"""

if not value:
return None
for pattern in PUBLIC_SAFE_LOCAL_PATH_PATTERNS:
if pattern.search(value):
return pattern
return None


def classify_private_text(
value: str | None,
*,
Expand Down
Loading
Loading