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
2 changes: 2 additions & 0 deletions docs/system-specs/modules/governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -3155,6 +3155,8 @@ is served from this origin — a bundled asset or a downloaded, verified one —
policy needs no off-origin media host, and a header that admitted one would be
admitting a fetch the server never offers.

**Which hosts — the `hosts` list.** An inner `hosts` ruleset (`host` matcher) names the hosts the manifest and media may come from, a mirror named by `KIROCREW_FEATURE_VIDEOS_MANIFEST_URL` included (RFC [crew-enterprise-edition](../../request-for-change/rfc-crew-enterprise-edition.md), appendix row "Feature content hosts"). `feature_videos_manifest.host_denied(url)` asks `hosts:<host>` for every reading of the url's host (`governance_parts.matching.url_policy_hosts`: the host as the standard library, `urllib3` and `yarl` each read it, plain and with the host part percent-decoded, lower-cased, without a root dot or an IPv6 zone; an IP literal in canonical form; a non-ASCII name only in the forms the clients send (Python's `idna` codec for `urllib`, UTS #46 `xn--` for `urllib3` and `yarl`). A URL with no such reading is asked as `""`, which matches no entry: as for `network.egress`, an allow list is the hard bound, entries name ASCII hosts without a root dot and IPs in canonical form (an exact entry in any other form, or a glob with a character no reading holds, fails the policy load, `archetypes._refuse_unmatchable_host_entries`; an IP glob in a non-canonical numeric spelling is a known gap, #23734), and a deny list refuses the spellings it can name) on the same pinned surface, audited and fail-closed, permitting only when every reading is permitted, before each manifest request in the walk and before each poster and each clip request in the download pass (`feature_videos_cache.HOST_DENIED` fails that entry only). The manifest fetch also holds every redirect hop to the list (`asset_downloader.build_opener(redirect_allowed=...)`), since an operator mirror may redirect across hosts; clip and poster transfers already refuse any cross-host hop. It does not touch the `download_denied` memo: one refused host does not report the feature off. A policy without the ruleset permits every host.

### The Jev decision seam — `capabilities.decisions`

An enabled, sampled session sends message excerpts and skill descriptions to an
Expand Down
43 changes: 39 additions & 4 deletions src/kiro_crew/asset_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,30 @@ def __init__(self, origin: str, target: str, policy: str) -> None:
" redirects across hosts is honoured only when set through the environment override)"
)
_HTTPS_ONLY_POLICY = "the transfer stays on https"
_CALLER_POLICY = "the caller's policy does not permit the redirect target"


class _SameHostRedirectHandler(urllib.request.HTTPRedirectHandler):
class _GuardedRedirect(urllib.request.HTTPRedirectHandler):
"""A redirect handler that also asks the caller's own predicate about each hop.

``redirect_allowed`` is ``None`` (no extra rule) unless :func:`build_opener`
was given one; a hop it refuses raises :class:`RedirectRefused`.
"""

redirect_allowed: "Callable[[str], bool] | None" = None

def _check_caller_guard(self, req, newurl: str) -> None: # noqa: ANN001
guard = self.redirect_allowed
if guard is not None and not guard(newurl):
logger.warning(
"refusing a redirect the caller's policy does not permit from %s to %s",
redact_url(req.full_url),
redact_url(newurl),
)
raise RedirectRefused(req.full_url, newurl, _CALLER_POLICY)


class _SameHostRedirectHandler(_GuardedRedirect):
"""Refuse any redirect that leaves https or changes the host.

The authorization to make a request is granted per HOST — a manifest's signed
Expand Down Expand Up @@ -229,10 +250,11 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
redact_url(newurl),
)
raise RedirectRefused(req.full_url, newurl, _SAME_HOST_POLICY)
self._check_caller_guard(req, newurl)
return super().redirect_request(req, fp, code, msg, headers, newurl)


class _HttpsOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
class _HttpsOnlyRedirectHandler(_GuardedRedirect):
"""Refuse a redirect that leaves https; allow one that changes the host.

The relaxed policy, reachable only through ``allow_cross_host_redirects``,
Expand Down Expand Up @@ -262,11 +284,15 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
redact_url(newurl),
)
raise RedirectRefused(req.full_url, newurl, _HTTPS_ONLY_POLICY)
self._check_caller_guard(req, newurl)
return super().redirect_request(req, fp, code, msg, headers, newurl)


def build_opener(
context: "ssl.SSLContext | None" = None, *, allow_cross_host_redirects: bool = False
context: "ssl.SSLContext | None" = None,
*,
allow_cross_host_redirects: bool = False,
redirect_allowed: "Callable[[str], bool] | None" = None,
) -> urllib.request.OpenerDirector:
"""An opener that verifies TLS and pins redirects.

Expand All @@ -280,8 +306,17 @@ def build_opener(
place of :class:`_SameHostRedirectHandler`. It is for a url the operator set
in the process environment, or one written into the source as a constant,
and nothing else — see the module docstring.

*redirect_allowed*, when given, is asked about every redirect target the
handler would otherwise follow; ``False`` refuses that hop. It only narrows.
"""
handler = _HttpsOnlyRedirectHandler if allow_cross_host_redirects else _SameHostRedirectHandler
handler_cls = (
_HttpsOnlyRedirectHandler if allow_cross_host_redirects else _SameHostRedirectHandler
)
handler: "type[_GuardedRedirect] | _GuardedRedirect" = handler_cls
if redirect_allowed is not None:
handler = handler_cls()
handler.redirect_allowed = redirect_allowed
return urllib.request.build_opener(
urllib.request.HTTPSHandler(context=context or make_ssl_context()), handler
)
Expand Down
3 changes: 3 additions & 0 deletions src/kiro_crew/docs/feature-videos.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ Two independent switches:
no clip is downloaded, and nothing streams from the CDN — clips already on disk
still play. The settings panel reads the answer from `GET /api/feature-videos/status`
(`download_enabled`), so the controls reflect it rather than failing when pressed.
- The same scope can instead name the hosts the catalog and clips may come from
(`"scopes": {"hosts": {"mode": "allow", "allow": ["mirror.example.com"]}}`).
A request to any other host is not made.

To fetch the catalog from somewhere else, set `KIROCREW_FEATURE_VIDEOS_MANIFEST_URL`
in the gateway's environment to point at your own copy of the manifest. It must be
Expand Down
17 changes: 15 additions & 2 deletions src/kiro_crew/feature_videos_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,10 @@ def cache_ceiling_bytes(max_mb: float) -> int:
#: ``_download_entry`` keeps its ``(ok, error)`` shape.
DENIED_MID_ENTRY = "download denied by the governance ceiling"

#: The error for an entry whose poster or clip host the policy's ``hosts`` list
#: does not permit. The pass goes on to the next entry, which may name another host.
HOST_DENIED = "host not permitted by the governance ceiling"

#: Failure reason for an entry whose media landed but whose receipt could not be
#: written. The media stays on disk; without the receipt it reads as not cached,
#: so the next pass re-fetches it and tries the receipt again.
Expand Down Expand Up @@ -602,10 +606,16 @@ def _download_entry_into(
*folder* is held open for the whole entry, so the directory the poster
landed in is the directory the clip lands in and the receipts are written
to — no step between them re-resolves the folder's name.

Each request is first held to the policy's ``hosts`` list
(:func:`manifest_mod.host_denied`); a refused host fails this entry only.
"""
poster_url = current.asset_url(entry.poster)
if manifest_mod.host_denied(poster_url):
return False, HOST_DENIED
poster_ok, poster_err = asset_downloader.download_to(
folder.describe(entry.poster),
current.asset_url(entry.poster),
poster_url,
sha256=entry.poster_sha256,
# A bound, not a declared length: the manifest carries a poster sha but
# no poster byte count, and max_bytes caps the transfer without
Expand All @@ -628,9 +638,12 @@ def _download_entry_into(
# the poster's permit.
if manifest_mod.download_denied():
return False, DENIED_MID_ENTRY
clip_url = current.asset_url(entry.file)
if manifest_mod.host_denied(clip_url):
return False, HOST_DENIED
clip_ok, clip_err = asset_downloader.download_to(
folder.describe(entry.file),
current.asset_url(entry.file),
clip_url,
sha256=entry.sha256,
size=entry.bytes,
resume=True,
Expand Down
48 changes: 47 additions & 1 deletion src/kiro_crew/feature_videos_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,42 @@ def download_permitted_cached() -> bool:
return not download_denied()


def host_denied(url: str) -> bool:
"""Whether the ceiling forbids fetching *url*'s host. Blocking, audited, fail-closed.

Asks :data:`DOWNLOAD_SCOPE` for ``hosts:<host>``, so a policy can name the exact
CDN or mirror hosts. A gate pinned off denies every host; a policy with no
``hosts`` ruleset permits every one, as before the list existed. Called next to
:func:`download_denied` before each manifest, poster and clip request, and does
not touch its memo: one refused host does not turn the whole feature off.

Every host a client could read *url* as is asked (``url_policy_hosts``), and
the request is permitted only when all are, so a parser disagreement cannot
reach a host the list refused.
"""
from kiro_crew.platform.governance_parts.matching import url_policy_hosts

try:
decisions = [
_governance_profiles.vet_and_audit(
DOWNLOAD_SCOPE,
f"hosts:{host}",
session_key=DASHBOARD_SURFACE_KEY,
tool_name=AUDIT_TOOL,
log_warning=False,
fail_closed=True,
)
for host in url_policy_hosts(url)
]
except Exception as exc:
# The type only: the url can carry a credential, and a traceback would
# render the exception's own text, which may quote it.
logger.debug("feature-video host governance probe failed (%s); denying", type(exc).__name__)
_audit_unevaluable()
return True
return not all(getattr(d, "permitted", False) for d in decisions)


def _audit_unevaluable() -> None:
"""Best-effort SEL record for the path ``vet_and_audit`` never reached."""
try:
Expand Down Expand Up @@ -1028,7 +1064,12 @@ def _fetch_json(url: str, *, operator_url: bool = False) -> "dict | None":
# build_opener, never urlopen: the url was authorized against ONE host, and
# urlopen's default handler would follow a cross-host redirect and spend
# that authorization on a destination the CDN chose.
opener = asset_downloader.build_opener(allow_cross_host_redirects=operator_url)
# Every hop is held to the policy's ``hosts`` list as the first url was, so
# an operator mirror that redirects cannot reach a host the list omits.
opener = asset_downloader.build_opener(
allow_cross_host_redirects=operator_url,
redirect_allowed=lambda target: not host_denied(target),
)
# nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- manifest_url enforces https://, redirects are pinned (host-pinned unless the operator's own env url), and the document is signature-verified before use
with opener.open(request, timeout=_MANIFEST_TIMEOUT_SECS) as resp:
body = resp.read(_MANIFEST_MAX_BYTES + 1)
Expand Down Expand Up @@ -1164,6 +1205,10 @@ def fetch_manifest(version: str) -> "tuple[VideoManifest | None, dict]":
if download_denied():
logger.info("feature-video manifest fetch stopped: downloading withdrawn mid-walk")
return None, {}
if host_denied(url):
# Every candidate shares one host, so the walk ends here too.
logger.info("feature-video manifest fetch stopped: the policy does not permit its host")
return None, {}
raw = _fetch_json(url, operator_url=overridden)
if raw is None:
continue
Expand Down Expand Up @@ -1199,6 +1244,7 @@ def fetch_manifest(version: str) -> "tuple[VideoManifest | None, dict]":
"checked_cache_root",
"download_denied",
"download_permitted_cached",
"host_denied",
"ensure_cache_dir",
"fetch_manifest",
"is_safe_media_name",
Expand Down
5 changes: 5 additions & 0 deletions src/kiro_crew/platform/governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -1099,8 +1099,10 @@ def assert_governance_paths_protected() -> None:
_compose_controls,
_compose_posture,
_dedup,
_refuse_unmatchable_host_entries,
_reject_unknown_keys,
_str_tuple,
_unmatchable_glob,
)
from kiro_crew.platform.governance_parts.baseline import ( # noqa: F401
_CAPABILITY_OFF,
Expand Down Expand Up @@ -1169,6 +1171,8 @@ def assert_governance_paths_protected() -> None:
_MATCHERS,
_NETWORK_SCHEMES,
_ORDINAL_SCALES,
_PLAIN_HOST_RE,
_canonical_ip,
_expand,
_looks_like_host,
_match_command,
Expand All @@ -1179,6 +1183,7 @@ def assert_governance_paths_protected() -> None:
_norm_item,
_url_host,
register_matcher,
url_policy_hosts,
)
from kiro_crew.platform.governance_parts.parsing import ( # noqa: F401
_KEY_OPEN_NAMESPACES,
Expand Down
81 changes: 81 additions & 0 deletions src/kiro_crew/platform/governance_parts/archetypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,75 @@ def is_at_least_as_strict_as(self, ceiling: "OrdinalControl") -> bool:
return self.rank() >= ceiling.rank()


def _unmatchable_glob(glob: str) -> str:
"""Why a host glob can match no reading, or ``""`` when it might.

A character check only: a reading holds only a URL host's characters
(``matching._PLAIN_HOST_RE``), so a glob holding a ``[`` class, ``@``, ``/``,
``\\``, whitespace, a zone, or a ``:`` outside a hex IPv6 glob can never match. How an IP glob spells its
numbers is not checked: it matches canonical readings as written (an
alternate numeric spelling is tracked in the deferral issue for these lists).
"""
import re

from kiro_crew.platform.governance_parts.matching import _PLAIN_HOST_RE

if "[" in glob:
return "uses a [ ] character class; host lists take * and ? only"
body = glob.replace("*", "").replace("?", "")
if ":" in body and not re.fullmatch(r"[0-9a-f:]*", body):
return "is not an IPv6 glob in canonical form (hex groups only, no zone or port)"
if body and not _PLAIN_HOST_RE.fullmatch(body):
return "has a character no host reading holds ('@', '/', a backslash or a space)"
return ""


def _refuse_unmatchable_host_entries(scope_name: str, ruleset: "ScopedRuleset") -> None:
"""Refuse a ``host`` entry in a capability's list that no host reading can match.

A capability's ``host`` lists are asked about hosts as clients send them
(``matching.url_policy_hosts``): ASCII, no root dot, an IP literal in canonical
form. An exact entry in another spelling would match nothing, so a deny would
refuse nothing and an allow would admit nothing, silently. It fails to load
instead, and the message says which form to write. A glob gets a character
check only (``_unmatchable_glob``); how it spells IP numbers is not checked.
Top-level ``network.egress`` keeps its
own parsing; this applies only to lists inside a capability.
"""
from kiro_crew.platform.governance_parts.matching import _PLAIN_HOST_RE, _canonical_ip

for listed, entries in (("allow", ruleset.allow), ("deny", ruleset.deny)):
for index, raw in enumerate(entries):
entry = raw.strip()
spelled = entry.lower()
why = ""
if not entry.isascii():
why = "is not ASCII; write the name in its xn-- (IDNA) form"
elif entry.endswith("."):
why = "ends in a root dot; write it without the dot"
elif entry.startswith("[") and ":" in entry:
why = "is a bracketed IPv6 literal; write the address without brackets"
elif any(c in entry for c in "*?["):
why = _unmatchable_glob(spelled)
else:
# Exactly the form a host is asked in, or the entry can never match.
ip = _canonical_ip(spelled)
if ip:
asked = ip.split("%", 1)[0]
if asked != spelled:
why = f"is an IP literal not in the form hosts are asked in; write {asked}"
elif ":" in spelled or not _PLAIN_HOST_RE.fullmatch(spelled):
why = (
"is not a host as clients send it "
"(brackets, a zone, a port, '/', '@', a backslash or a space)"
)
if why:
# The position, never the value: an entry can be a pasted URL.
raise PlatformCompositionError(
f"capability scope {scope_name!r} {listed}[{index}] {why}"
)


# ──────────────────────────────────────────────────────────────────────────
# Archetype 3 — CapabilityGate
# ──────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -418,11 +487,23 @@ def from_dict(
if not isinstance(raw_scopes, dict):
raise PlatformCompositionError("CapabilityGate.scopes must be an object")
matchers = scope_matchers or {}
for k, v in raw_scopes.items():
# A declared list in the wrong shape would otherwise be dropped below,
# leaving the capability with no bound on that list at all.
if str(k) in matchers and not isinstance(v, dict):
raise PlatformCompositionError(
f"CapabilityGate.scopes.{k} must be an object such as "
'{"mode": "allow", "allow": [...]}, '
f"got {type(v).__name__}"
)
scopes: Dict[str, RulesetLike] = {
str(k): ScopedRuleset.from_dict(v, matcher=matchers.get(str(k), _DEFAULT_MATCHER))
for k, v in raw_scopes.items()
if isinstance(v, dict)
}
for name, ruleset in scopes.items():
if isinstance(ruleset, ScopedRuleset) and ruleset.matcher == "host":
_refuse_unmatchable_host_entries(name, ruleset)
if "enabled" not in d:
enabled_flag = default_enabled
else:
Expand Down
9 changes: 8 additions & 1 deletion src/kiro_crew/platform/governance_parts/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,14 @@ class ScopeSpec:
# on disk is a separate decision this row does not make.
# Data row only — CONTRACT_VERSION and the evaluator are untouched (mirrors
# social_share).
"capabilities.feature_videos_download": ScopeSpec(CAPABILITY, capability_default=True),
#
# Inner ``hosts`` ruleset (``host`` matcher): which hosts the manifest and
# its media may be fetched from (a mirror named by the URL override included).
# Queried as ``hosts:<host>`` before each manifest request and each clip or
# poster request (``feature_videos_manifest.host_denied``).
"capabilities.feature_videos_download": ScopeSpec(
CAPABILITY, capability_default=True, scope_matchers={"hosts": "host"}
),
# The Jev decision seam: an enabled, sampled session sends message excerpts and
# skill descriptions to an external, PAID provider endpoint and spends the
# operator's account to do it. The keystone ``decisions_consent.json`` is the
Expand Down
Loading
Loading