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
6 changes: 6 additions & 0 deletions docs/system-specs/modules/governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -3251,6 +3251,12 @@ seam. A policy pins the seam off MACHINE-WIDE with one object -- every surface,
{"version": 1, "capabilities": {"decisions": {"enabled": false}}}
```

**Which models, points and endpoints — three lists.** The row carries inner `models` (`identifier`), `points` (`identifier`) and `endpoints` (`host`) rulesets (RFC [crew-enterprise-edition](../../request-for-change/rfc-crew-enterprise-edition.md), appendix rows "Decision models", "Decision kinds" and "Decision endpoints"). `decide` asks `capability.targets_denied` once the lane and model are known and after the scrub (`scrub_reason`), so a model id the scrub refuses never reaches the SEL row: `points:<point>` always, and for the Jev lane, the one that sends to a decision provider, `models:<model id>` and `endpoints:<host>` for every reading of the provider endpoint'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). The judge's LLM lane sends to the session's own model provider through the session runner, which picks the served model itself, so it is asked about its point only; `models` and `endpoints` do not bind it. Each item is evaluated and audited, never short-circuited; a refusal returns `None` and writes no decision row, like the capability refusal. The lists bind local presets too, as `capabilities.decisions` already does. A policy without these rulesets permits as before.

```json
{"version": 1, "capabilities": {"decisions": {"scopes": {"models": {"mode": "allow", "allow": ["jev-latest"]}, "points": {"mode": "deny", "deny": ["tool.risk"]}}}}}
```

### "Share as image" — `capabilities.social_share`

The chat's *More actions → Share as image* turns an assistant reply into a branded
Expand Down
48 changes: 44 additions & 4 deletions src/kiro_crew/decisions/capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,13 @@ def _answer_generation() -> int | None:
return None


def _row_denied(surface_key: str, scope: str) -> bool:
"""Evaluate and audit ONE capability row. Fail-closed; see :func:`is_decisions_denied`."""
def _row_denied(surface_key: str, scope: str, item: str = "") -> bool:
"""Evaluate and audit ONE capability row or list item. Fail-closed; see
:func:`is_decisions_denied`."""
from kiro_crew.edition_locks import UNNAMED_REASON, follows_policy, locked_off

try:
if scope == DECISIONS_SCOPE and locked_off():
if scope == DECISIONS_SCOPE and not item and locked_off():
# Enterprise: content sent to a decision provider follows this hosted
# row and is off by default (RFC enterprise edition, Red "Content sent
# to external decisions"). Evaluated without the seam's own audit, so
Expand All @@ -240,7 +241,7 @@ def _row_denied(surface_key: str, scope: str) -> bool:
return denied
decision = vet_and_audit(
scope,
"",
item,
session_key=surface_key,
tool_name=AUDIT_TOOL,
log_warning=False,
Expand All @@ -257,6 +258,45 @@ def _row_denied(surface_key: str, scope: str) -> bool:
return not getattr(decision, "permitted", False)


def targets_denied(
surface_key: str = DASHBOARD_SURFACE_KEY,
*,
point: str,
model: str | None,
endpoint: str | None,
) -> bool:
"""Whether the ceiling's lists refuse this request's point, model or endpoint.

Asks :data:`DECISIONS_SCOPE` for ``points:<point>`` and, when the lane sends a
*model* to a decision provider at an *endpoint*, ``models:<model>`` and
``endpoints:<host>`` for every reading of its host. A ruleset the
policy does not name permits, so a ceiling without these lists answers exactly
as before. Every item is evaluated and audited, never short-circuited, so which
rows appear does not depend on another item's answer. Fail-closed, like
:func:`is_decisions_denied`. Blocking: call off the event loop.
"""
items = [f"points:{point}"]
if model is not None:
items.append(f"models:{model}")
if endpoint is not None:
# Every host a client could read the endpoint as, so a parser
# disagreement or a root-dot spelling cannot reach a refused host.
from kiro_crew.platform.governance_parts.matching import url_policy_hosts

items.extend(f"endpoints:{host}" for host in url_policy_hosts(endpoint))
# One request's items must be answered by ONE policy: a swap between two of
# them could pair one policy's permit for the model with another's permit for
# the endpoint. Bracketed as in :func:`denied_sides`, failing closed if it moved.
_prime_profiles()
before = _answer_generation()
denied = [_row_denied(surface_key, DECISIONS_SCOPE, item) for item in items]
if not any(denied) and (before is None or _answer_generation() != before):
logger.debug("decisions governance answer moved mid-evaluation; denying the request")
_audit_unevaluable(surface_key, DECISIONS_SCOPE)
return True
return any(denied)


def _audit_unevaluable(surface_key: str, scope: str = DECISIONS_SCOPE) -> None:
"""Best-effort SEL record for the path ``vet_and_audit`` never reached. Never raises."""
_audit_row(surface_key, scope, "denied", _UNEVALUABLE_REASON)
Expand Down
34 changes: 34 additions & 0 deletions src/kiro_crew/decisions/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,32 @@ def _capability_denied(session_key: str | None, *, local: bool = False) -> bool:
return True


def _targets_denied(
config: Any | None, session_key: str | None, point: str, model: str, lane: str
) -> bool:
"""Whether the ceiling's ``points`` / ``models`` / ``endpoints`` lists refuse this
request. Filesystem IO; never raises (an error denies).

The ``models`` and ``endpoints`` lists bind the decision PROVIDER, which only
the Jev lane uses. The judge's LLM lane sends to the session's own model
provider through the session runner, which picks the served model itself, so
it is asked about its point only.
"""
from kiro_crew.decisions.capability import DASHBOARD_SURFACE_KEY, targets_denied

try:
jev = lane == LANE_JEV
return targets_denied(
session_key or DASHBOARD_SURFACE_KEY,
point=point,
model=model if jev else None,
endpoint=configured_endpoint(config) if jev else None,
)
except Exception as exc:
logger.debug("decisions: %s target check failed (%s)", point, type(exc).__name__)
return True


def _consented_for(
config: Any | None,
session_key: str | None = None,
Expand Down Expand Up @@ -1137,10 +1163,18 @@ async def _write(
# For every lane but the judge's LLM one this is ``provider.model`` with the
# same fallback ``JevOracle`` applies, unchanged.
model = lane_model(cfg, lane=lane)
# The scrub runs first: the list check below writes the model id to the SEL,
# so an id the scrub refuses (a pasted credential) never reaches that row.
refusal = scrub_reason(state, questions, model=model)
if refusal is not None:
await _write(latency_ms=0, answers=None, error=refusal)
return None
# The fleet's lists: which points, models and endpoints the seam may use. Like
# the capability row in ``_consented_for``, a refusal here writes no decision
# row (its audit is the SEL ``governance_decision`` row) and costs nothing on
# an unconsented install, which never reaches this line.
if await asyncio.to_thread(_targets_denied, cfg, session_key, point, model, lane):
return None

started = time.monotonic()
oracle: Any = None
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
16 changes: 15 additions & 1 deletion src/kiro_crew/platform/governance_parts/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,21 @@ class ScopeSpec:
# ``decisions_enabled`` so the Feature Previews card is not drawn at all.
# Data row only -- CONTRACT_VERSION and the evaluator are untouched (mirrors
# social_share).
"capabilities.decisions": ScopeSpec(CAPABILITY, capability_default=True),
#
# Three inner rulesets bound WHAT the seam may use once it is on, each queried
# by ``decide`` before a request is sent (``capability.targets_denied``):
# ``models`` (``identifier``: the model id the request names), ``points``
# (``identifier``: the decision point, e.g. ``skills.select``) and
# ``endpoints`` (``host``: the provider endpoint's host). ``models`` and
# ``endpoints`` bind the decision provider, so the judge's LLM lane, which
# runs on the session's own model provider, is asked about its point only.
# They bind local presets too, because a ``capabilities.decisions`` answer
# already does.
"capabilities.decisions": ScopeSpec(
CAPABILITY,
capability_default=True,
scope_matchers={"models": "identifier", "points": "identifier", "endpoints": "host"},
),
# The same seam answered by a LOCAL PRESET model on this machine
# (``decisions/local_models.py``) instead of hosted Jev. Nothing leaves the
# machine and nothing is spent, so a fleet that permits hosted Jev can still
Expand Down
Loading
Loading