From 24ccc21df9f3b551fd6c270f28ad6b7a24ed7edc Mon Sep 17 00:00:00 2001 From: Joe Guo Date: Sat, 24 Oct 2026 07:19:48 +0000 Subject: [PATCH] feat: answer unnamed governance scopes from an enterprise baseline In an enterprise build a scope the remote policy does not name was permitted, exactly as in oss. RFC D3 (rfc-crew-enterprise-edition.md) locks such a scope to its minimal set, or off when it has none. Add governance_parts/baseline.py, a data table of each catalog scope's minimal set from the RFC appendix, parsed by the same per-control parser a remote policy uses. resolve fills the POLICY level from it when the policy names nothing and the build is enterprise; the profile level is untouched, so a profile can still only narrow and cannot block a remote widening. may_skip_gate reads the same lookup, so an auto-approve grant cannot skip the gate for an unnamed scope. oss is unchanged. --- .github/black-baseline.txt | 1 - docs/system-specs/modules/governance.md | 17 +- .../dashboard/handlers/member_dashboard.py | 9 +- src/kiro_crew/dashboard/handlers/security.py | 1 + .../handlers/security_governance_policy.py | 5 +- src/kiro_crew/platform/governance.py | 31 +- .../platform/governance_parts/__init__.py | 6 +- .../platform/governance_parts/baseline.py | 89 ++++++ .../platform/governance_parts/catalog.py | 4 + .../platform/governance_parts/evaluator.py | 81 ++++- .../platform/governance_parts/parsing.py | 15 + .../platform/governance_parts/tiers.py | 41 ++- test/test_dashboard_page_scripts_ceiling.py | 9 + test/test_governance_enterprise_baseline.py | 278 ++++++++++++++++++ test/test_governance_policy_viewer.py | 24 +- 15 files changed, 578 insertions(+), 33 deletions(-) create mode 100644 src/kiro_crew/platform/governance_parts/baseline.py create mode 100644 test/test_governance_enterprise_baseline.py diff --git a/.github/black-baseline.txt b/.github/black-baseline.txt index af6f960b300..43d9c15602f 100644 --- a/.github/black-baseline.txt +++ b/.github/black-baseline.txt @@ -506,7 +506,6 @@ test/test_frontend_edition_build.py test/test_frontmatter.py test/test_gateway_appkit_endpoints.py test/test_gitlab_client_coverage.py -test/test_governance_policy_viewer.py test/test_handler_cron_list.py test/test_handlers_channel_clear_context.py test/test_handlers_channel_presets.py diff --git a/docs/system-specs/modules/governance.md b/docs/system-specs/modules/governance.md index cf330690855..4e9f451d6b5 100644 --- a/docs/system-specs/modules/governance.md +++ b/docs/system-specs/modules/governance.md @@ -125,6 +125,17 @@ evaluator edits. > mirror), **not** this re-triage. See `platform-context.md` for the design > record. +## Enterprise built-in baseline (unnamed scopes) + +In an `oss` build a scope the policy does not name is ungoverned and permitted (`_PERMIT_NOT_GOVERNED`). In an enterprise build (`edition.is_enterprise()`) it is answered by the built-in baseline policy instead: each scope's minimal set, or off when it has none ([RFC D3](../../request-for-change/rfc-crew-enterprise-edition.md), appendix "Minimal set"). The table is data in `platform/governance_parts/baseline.py` (`BASELINE_CONTROLS`), parsed by the same per-control parser a remote policy uses, so it obeys the catalog's `always_permitted` floor. + +- **Where it applies.** `evaluator._policy_level` fills the POLICY level only, in `resolve`. `_query_level` still permits a `None` control, because it also answers the profile level: a profile that does not name a scope must not impose the baseline again beneath a remote widening. For the same reason no baseline is added when a `Profile` sits in the ceiling slot (`governance_profiles` asks `may_skip_gate` about one profile alone) or when `gate_decision`'s alias-group pass asks the profile layer alone (`resolve(..., baseline=False)`). `policy_level_control` exposes the same lookup to readers that inspect a ceiling directly: `may_skip_gate` and `_ceiling_mentions_mcp_server` use it, so an auto-approve grant cannot skip the gate for an unnamed scope, the policy viewer (`GET /api/governance/policy`) reports the baseline instead of "ungoverned", and the member-dashboard script grant (`member_dashboard._egress_decision`) reads an unnamed `network.egress` as the baseline's off. A `folders.*` query finds the policy's `filesystem.*` rule before the baseline answers, because the parser stores it under the canonical scope. +- **Who may widen.** The remote policy, by naming the scope. A profile only narrows. +- **Not gated.** `BASELINE_UNGATED` lists the two ordinals (`approval_mode`, `sandbox.min_level`), which keep Crew's own default. A scope in neither table (one registered at run time) is denied. +- **Minimal sets that differ from the appendix wording.** `approval_modes` is `normal`, `trust`, `trust_reads` (the catalog floor; only `yolo` is off). `filesystem.read` is Crew's fixed default workspace root (`config.paths._default_workspace_base()` + `_WORKSPACE_DIR_NAME`), never the configured one, because the configured root is a local source. `tools` is off, not read-only tools: `classify_tool_title` also asks `commands` about every bare tool name, so a read-only set could never pass while `commands` is off. Read-only tools wait on the tools/commands classifier fix, a separate item. `updates.source` is not a catalog scope and has no baseline. +- **Local policy tiers.** `compose_tier_ladder` first fills the authority tier's unnamed scopes from the baseline (`tiers._with_enterprise_baseline`), so the tighten-only `_intersect_ceilings` composes a local tier (`env`, `home`, `bundled`) WITH the baseline: a local tier can narrow it and never supply a scope's whole answer. With no central document the authority is itself local, so it is intersected with the baseline scope by scope rather than filled; only a central authority's named scopes replace the baseline. The installed ceiling therefore names every gated scope in an enterprise build, and `kirocrew policy show` lists the baseline rows beside the document's own. +- **Not covered here.** The `ceiling is None and profile is None` early returns in `hook_runtime/governance_gate.py`, `mcp_gateway/app_call.py` and `governance_profiles.governance_permits` still permit before `resolve` runs; an enterprise host with no policy at all is the enterprise gate's to refuse (RFC Migration plan phase 2). + ## Loading + precedence `load_security_policy()` precedence, highest first. The centrally distributed @@ -3702,8 +3713,10 @@ carve-out stay as code. It expects `CONTRACT_VERSION == 1` (pinned pre-launch). `compose_tier_ladder` with its refresh entry point `compose_installed_ceiling` (+ `_TierProcessState`, the per-process tier state: the remembered bundled tier and the two once-per-process latches), and `load_security_policy`. + - `platform/governance_parts/baseline.py` — the enterprise built-in baseline + (`BASELINE_CONTROLS`, `BASELINE_UNGATED`); data only. - `platform/governance_parts/evaluator.py` — `resolve`, `resolve_ordinal`, `gate_decision`, - `resolve_pinned_commands` + `COMMANDS_SCOPE` force-pins. + `resolve_pinned_commands` + `COMMANDS_SCOPE` force-pins, `policy_level_control`. - `platform/admission.py` — `canonical_signing_bytes` / `hmac_signature` (shared by both trust roots), `ed25519_verify` (policy issuers only), `require_policy_signature` / `trust_keys` / `trust_public_keys`, and @@ -3769,6 +3782,8 @@ extensibility), `test_governance_policy_signatures.py` (the `identity.signature` states, the opt-in fail-closed gate, and the `policy show` provenance reporting), `test_governance_policy_decisions.py` (an unnamed capability is ungoverned, `policy validate` names the ungoverned rows, and an alias group is one gate question), +`test_governance_enterprise_baseline.py` (an unnamed scope answers the enterprise +baseline, every catalog scope has a baseline row, and `oss` is unchanged), `test_governance_tier_ladder.py` (the tier ladder: that the central document outranks every local one, that a lower tier can only tighten it, that the reserved sandbox boot flags compose across tiers, that an env document beneath an authority diff --git a/src/kiro_crew/dashboard/handlers/member_dashboard.py b/src/kiro_crew/dashboard/handlers/member_dashboard.py index aaea85ca2b8..aa3a1a1a056 100644 --- a/src/kiro_crew/dashboard/handlers/member_dashboard.py +++ b/src/kiro_crew/dashboard/handlers/member_dashboard.py @@ -1358,16 +1358,17 @@ def _egress_decision(owner_agent: str = "", session_key: str = "") -> _EgressDec from kiro_crew.platform.governance import MODE_DENY, ScopedRuleset try: - from kiro_crew.platform.governance import last_composed_ceiling + from kiro_crew.platform.governance import last_composed_ceiling, policy_level_control ceiling = last_composed_ceiling() - controls = (ceiling.controls or {}) if ceiling is not None else {} + # The policy level as ``resolve`` enforces it: in an enterprise build an + # unnamed ``network.egress`` is the built-in baseline (off), not absent. + control = policy_level_control(ceiling, "network.egress") # NO CEILING, OR NONE GOVERNING EGRESS, IS NOT AN ANSWER -- it only means the # outer bound restricts nothing, and the profile below still decides. Returning # True here was the hole: the standalone default is every install that # configures no policy, and a profile can restrict egress in all of them. - if "network.egress" in controls: - control = controls["network.egress"] + if control is not None: if not isinstance(control, ScopedRuleset): return _EgressDecision(False, "policy", "ceiling_egress_control_unreadable") # Only one shape leaves the agent unrestricted: deny-mode denying nothing. diff --git a/src/kiro_crew/dashboard/handlers/security.py b/src/kiro_crew/dashboard/handlers/security.py index e1ec7ac8ac1..971dbdf7c28 100644 --- a/src/kiro_crew/dashboard/handlers/security.py +++ b/src/kiro_crew/dashboard/handlers/security.py @@ -857,6 +857,7 @@ def _mutate(denied: dict) -> None: ScopedRuleset, _AndRuleset, _compose_controls, + policy_level_control, ) from kiro_crew.platform.governance_profiles import ( # noqa: F401 HOST_SESSION_KEY, diff --git a/src/kiro_crew/dashboard/handlers/security_governance_policy.py b/src/kiro_crew/dashboard/handlers/security_governance_policy.py index 03e2f44e65c..67a886411c2 100644 --- a/src/kiro_crew/dashboard/handlers/security_governance_policy.py +++ b/src/kiro_crew/dashboard/handlers/security_governance_policy.py @@ -30,6 +30,7 @@ ScopedRuleset, _AndRuleset, _compose_controls, + policy_level_control, ) from kiro_crew.platform.governance_profiles import ( HOST_SESSION_KEY, @@ -262,7 +263,9 @@ def build_governance_policy_snapshot() -> dict: # would be a permanently-ungoverned duplicate row. if scope in _SCOPE_ALIASES: continue - policy_control = ceiling.get(scope) if ceiling is not None else None + # The policy level as ``resolve`` enforces it: in an enterprise build an + # unnamed scope reports the built-in baseline, not "ungoverned". + policy_control = policy_level_control(ceiling, scope) profile_control = profile.get(scope) if profile is not None else None if policy_control is not None and profile_control is not None: diff --git a/src/kiro_crew/platform/governance.py b/src/kiro_crew/platform/governance.py index 58fb391577d..68a4f71371c 100644 --- a/src/kiro_crew/platform/governance.py +++ b/src/kiro_crew/platform/governance.py @@ -50,6 +50,7 @@ Tuple, ) +from kiro_crew import edition from kiro_crew.config.paths import config_dir # noqa: F401 - re-export from kiro_crew.mcp_provenance import is_marked from kiro_crew.platform.admission import ( # noqa: F401 - re-export @@ -87,7 +88,7 @@ ) from kiro_crew.platform.governance_parts.carriers import GovernanceCeiling, Profile from kiro_crew.platform.governance_parts.catalog import ORDINAL, SCOPE_CATALOG -from kiro_crew.platform.governance_parts.evaluator import gate_decision +from kiro_crew.platform.governance_parts.evaluator import gate_decision, policy_level_control from kiro_crew.platform.governance_parts.tiers import _POLICY_CACHE_LEAF logger = logging.getLogger("kiro_crew.platform.governance") @@ -186,9 +187,7 @@ def _ceiling_mentions_mcp_server(ceiling: Optional[GovernanceCeiling], server: s test for the scope-level question. Do not "fix" this function to return True on an empty ruleset: that would tax every server for a rule about one. """ - if ceiling is None: - return False - ruleset = ceiling.get("mcp") + ruleset = policy_level_control(ceiling, "mcp") if not isinstance(ruleset, RulesetLike): return False prefix = f"@{server}".casefold() @@ -285,7 +284,9 @@ def may_skip_gate(ref: str, ceiling: Optional[GovernanceCeiling]) -> bool: BUILTIN_TOOL_SCOPES.get(ref, ()) ): return False - if ceiling is None: + # An enterprise build has no ungoverned host: an unnamed scope is answered by + # the built-in baseline, read through ``policy_level_control`` below. + if ceiling is None and not edition.is_enterprise(): return True try: if ref.startswith("@"): @@ -308,7 +309,7 @@ def may_skip_gate(ref: str, ceiling: Optional[GovernanceCeiling]) -> bool: # gate, so if the ceiling has an opinion on ANY argument-derived scope, # withhold and let the gate apply it against the real path/host. arg_scopes = ("filesystem.read", "filesystem.write", "network.egress") - if any(_ruleset_has_an_opinion(ceiling.get(_s)) for _s in arg_scopes): + if any(_ruleset_has_an_opinion(policy_level_control(ceiling, _s)) for _s in arg_scopes): return False return True @@ -319,10 +320,10 @@ def may_skip_gate(ref: str, ceiling: Optional[GovernanceCeiling]) -> bool: # bypassed a `tools`-scope ceiling (e.g. tools.deny=["report"]). The # capability scopes it DOES map to add path/host granularity on top. scopes = ("tools",) + tuple(BUILTIN_TOOL_SCOPES.get(ref, ())) - if any(_ruleset_has_an_opinion(ceiling.get(scope)) for scope in scopes): + if any(_ruleset_has_an_opinion(policy_level_control(ceiling, scope)) for scope in scopes): return False capability = BUILTIN_TOOL_CAPABILITIES.get(ref) - return not (capability and _capability_restricts(ceiling.get(capability))) + return not (capability and _capability_restricts(policy_level_control(ceiling, capability))) except Exception: # noqa: BLE001 — see the fail-closed note above logger.warning("ceiling probe failed for %r; not auto-approving", ref, exc_info=True) return False @@ -1062,6 +1063,7 @@ def assert_governance_paths_protected() -> None: # ``tool_calls`` -- the governed (scope, item) pairs one tool call touches # ``archetypes`` -- the decision record, the four archetypes and their composition # ``catalog`` -- which scopes exist, and which archetype each one is +# ``baseline`` -- the enterprise built-in baseline: each scope's minimal set # ``carriers`` -- ``GovernanceCeiling``, ``Profile`` and their policy-only blocks # ``parsing`` -- a policy or profile document into its carrier # ``signature`` -- proving a policy's ``identity.signature`` @@ -1073,6 +1075,7 @@ def assert_governance_paths_protected() -> None: "kiro_crew.platform.governance_parts.tool_calls", "kiro_crew.platform.governance_parts.archetypes", "kiro_crew.platform.governance_parts.catalog", + "kiro_crew.platform.governance_parts.baseline", "kiro_crew.platform.governance_parts.carriers", "kiro_crew.platform.governance_parts.parsing", "kiro_crew.platform.governance_parts.signature", @@ -1099,6 +1102,13 @@ def assert_governance_paths_protected() -> None: _reject_unknown_keys, _str_tuple, ) + from kiro_crew.platform.governance_parts.baseline import ( # noqa: F401 + _CAPABILITY_OFF, + _DEFAULT_WORKSPACE_ROOTS, + _OFF, + BASELINE_CONTROLS, + BASELINE_UNGATED, + ) from kiro_crew.platform.governance_parts.carriers import ( # noqa: F401 _AGENTCORE_POLICY_ONLY, _AGENTCORE_POSTURES, @@ -1144,7 +1154,10 @@ def assert_governance_paths_protected() -> None: register_scope, ) from kiro_crew.platform.governance_parts.evaluator import ( # noqa: F401 + _BASELINE_REASON, + _DENY_UNLISTED, _PERMIT_NOT_GOVERNED, + _policy_level, _query_level, resolve, resolve_ordinal, @@ -1177,6 +1190,7 @@ def assert_governance_paths_protected() -> None: _parse_control, _parse_controls, _read_json_file, + baseline_controls, parse_policy, parse_profile, ) @@ -1211,6 +1225,7 @@ def assert_governance_paths_protected() -> None: _TierProcessState, _warn_env_beneath_central_once, _warn_home_unreadable_beneath_authority_once, + _with_enterprise_baseline, compose_installed_ceiling, compose_tier_ladder, last_composed_ceiling, diff --git a/src/kiro_crew/platform/governance_parts/__init__.py b/src/kiro_crew/platform/governance_parts/__init__.py index f4f31d38adf..8fe0240ec6f 100644 --- a/src/kiro_crew/platform/governance_parts/__init__.py +++ b/src/kiro_crew/platform/governance_parts/__init__.py @@ -13,6 +13,8 @@ that composes two controls of one archetype; * ``catalog`` -- which scopes exist: ``SCOPE_CATALOG`` with its archetype ids, ``register_scope`` and the scope aliases; +* ``baseline`` -- the enterprise built-in baseline: the minimal set an unnamed scope + answers in an enterprise build (data only); * ``carriers`` -- what a parsed policy and profile hold: ``GovernanceCeiling`` and ``Profile`` with their boot, update, distribution and agentcore blocks, and profile inheritance; @@ -21,8 +23,8 @@ refuses an unproven one; * ``tiers`` -- the tier ladder: where each local policy is read, the per-process tier state, and the fold into the installed ceiling; -* ``evaluator`` -- answering a query: ``resolve``, ``resolve_ordinal``, ``gate_decision`` - and the command force-pins. +* ``evaluator`` -- answering a query: ``resolve``, ``resolve_ordinal``, ``gate_decision``, + the command force-pins, and the policy level's control with the baseline filled in. The facade keeps the auto-approve surface it decides itself -- ``may_skip_gate`` with the builtin-tool scope maps, the auto-approve strip, the confirm-tools gate, diff --git a/src/kiro_crew/platform/governance_parts/baseline.py b/src/kiro_crew/platform/governance_parts/baseline.py new file mode 100644 index 00000000000..61e77c8a7c1 --- /dev/null +++ b/src/kiro_crew/platform/governance_parts/baseline.py @@ -0,0 +1,89 @@ +"""The enterprise built-in baseline policy: each governed scope's minimal set. + +Data only. In an enterprise build a scope the remote policy does not name is +answered from this table instead of being permitted (RFC +``docs/request-for-change/rfc-crew-enterprise-edition.md``, decision D3 and the +appendix "Minimal set" column). The remote policy may widen past it; nothing +local can, because a profile only ever narrows the policy level it sits under. + +``BASELINE_CONTROLS`` maps a ``SCOPE_CATALOG`` scope to the raw control a policy +document would carry for it, parsed by the same per-control parser a remote +policy goes through. ``BASELINE_UNGATED`` lists the scopes that keep Crew's own +default, with the reason. Every catalog scope (aliases aside) is in exactly one +of the two; ``test_governance_enterprise_baseline.py`` fails on a new scope that +is in neither. + +Two rows differ from the appendix. ``tools`` is off, not read-only tools: read-only +tools wait on the tools/commands classifier fix (a separate item), because the gate +also asks ``commands`` about every bare tool name and ``commands`` is off. +``approval_modes`` carries the catalog's undeniable floor (``normal``, ``trust``, +``trust_reads``), so only ``yolo`` is off. +""" + +from __future__ import annotations + +import os +from typing import Dict, Mapping + +_OFF: Mapping[str, object] = {"mode": "allow", "allow": []} +_CAPABILITY_OFF: Mapping[str, object] = {"enabled": False} + +# Crew's default workspace root: ``config.paths._default_workspace_base()`` joined +# with ``_WORKSPACE_DIR_NAME``. The base is ``~/workplace`` everywhere, or +# ``/Volumes/workplace`` on macOS when that volume exists. Fixed here on purpose: +# the configured root comes from ``KIROCREW_WORKSPACE``, the saved +# ``workspace_dir`` file or ``workspaces[].dir`` in config, all local sources +# D3 says may not widen a control. ``~`` is expanded at match time. +_DEFAULT_WORKSPACE_ROOTS = ( + os.path.join("~", "workplace", "kirocrew-workspace"), + os.path.join("/Volumes", "workplace", "kirocrew-workspace"), +) + +BASELINE_CONTROLS: Dict[str, Mapping[str, object]] = { + # Off, although the appendix's minimal set is read-only tools. The gate asks + # ``commands`` about every bare tool name too (``classify_tool_title``), so + # with ``commands`` off a read-only set here could never pass. Read-only tools + # wait on the tools/commands classifier fix, a separate item. + "tools": _OFF, + # The catalog's ``always_permitted`` floor: only ``yolo`` is off. + "approval_modes": {"mode": "allow", "allow": ["normal", "trust", "trust_reads"]}, + "mcp": {"mode": "allow", "allow": ["@kirocrew-core"]}, + "apps": _OFF, + "commands": _OFF, + "filesystem.read": { + "mode": "allow", + "allow": [p for root in _DEFAULT_WORKSPACE_ROOTS for p in (root, os.path.join(root, "**"))], + }, + "filesystem.write": _OFF, + "network.egress": _OFF, + "channels": {"members": _OFF}, + "yolo_duration": _OFF, + # ``acp_backends.POLICY_ID_KIRO``. + "agent_backend": {"mode": "allow", "allow": ["kiro"]}, + "capabilities.spawn": _CAPABILITY_OFF, + "capabilities.memory_writes": _CAPABILITY_OFF, + "capabilities.browse": _CAPABILITY_OFF, + "capabilities.script_hooks": _CAPABILITY_OFF, + "capabilities.cron": _CAPABILITY_OFF, + "capabilities.messaging": _CAPABILITY_OFF, + "capabilities.agentcore": _CAPABILITY_OFF, + # ``publish_provider.DEFAULT_PROVIDER``. + "capabilities.publish": { + "enabled": True, + "scopes": {"destinations": {"mode": "allow", "allow": ["default"]}}, + }, + "capabilities.theme_persona": _CAPABILITY_OFF, + "capabilities.theme_install": _CAPABILITY_OFF, + "capabilities.telemetry": _CAPABILITY_OFF, + "capabilities.tailnet_origin": _CAPABILITY_OFF, + "capabilities.mobile_connect": _CAPABILITY_OFF, + "capabilities.social_share": _CAPABILITY_OFF, + "capabilities.feature_videos_download": _CAPABILITY_OFF, + "capabilities.decisions": _CAPABILITY_OFF, + "capabilities.decisions_local": _CAPABILITY_OFF, +} + +BASELINE_UNGATED: Dict[str, str] = { + "approval_mode": "ordinal; never item-queried, so Crew's own approval default applies", + "sandbox.min_level": "ordinal; the RFC appendix keeps Crew's current sandbox default", +} diff --git a/src/kiro_crew/platform/governance_parts/catalog.py b/src/kiro_crew/platform/governance_parts/catalog.py index a4067d91f6f..fad5ed42d72 100644 --- a/src/kiro_crew/platform/governance_parts/catalog.py +++ b/src/kiro_crew/platform/governance_parts/catalog.py @@ -79,6 +79,10 @@ class ScopeSpec: # validate`` reports a partially-governed ``capabilities`` block so the gap is # visible instead of implied. # +# An ENTERPRISE build is the exception: there an unnamed scope is answered by the +# built-in baseline (``baseline.py``, RFC D3), so a new row here also needs a row +# in ``BASELINE_CONTROLS`` or ``BASELINE_UNGATED``; a test fails until it has one. +# # The absent-key semantics below are verified by executing parse_policy + resolve, # not inferred from the comments. diff --git a/src/kiro_crew/platform/governance_parts/evaluator.py b/src/kiro_crew/platform/governance_parts/evaluator.py index 917655ce1b3..ac9d3e5d121 100644 --- a/src/kiro_crew/platform/governance_parts/evaluator.py +++ b/src/kiro_crew/platform/governance_parts/evaluator.py @@ -14,6 +14,7 @@ from dataclasses import replace from typing import Mapping, Optional, Tuple +from kiro_crew import edition from kiro_crew.platform.context import PlatformCompositionError from kiro_crew.platform.governance_parts.archetypes import ( CapabilityGate, @@ -24,7 +25,10 @@ _AndRuleset, _dedup, ) +from kiro_crew.platform.governance_parts.baseline import BASELINE_UNGATED from kiro_crew.platform.governance_parts.carriers import GovernanceCeiling, Profile +from kiro_crew.platform.governance_parts.catalog import _SCOPE_ALIASES +from kiro_crew.platform.governance_parts.parsing import baseline_controls from kiro_crew.platform.governance_parts.tool_calls import ( _governed_targets, classify_tool_args, @@ -41,12 +45,64 @@ _PERMIT_NOT_GOVERNED = Decision(True, "not governed", rule="default", layer="default") +_BASELINE_REASON = "enterprise baseline" + +# A scope with neither a baseline row nor an ungated reason (one registered at run +# time) is refused in an enterprise build: an empty allow-set denies every item. +_DENY_UNLISTED = ScopedRuleset(mode="allow", allow=()) + + +def _policy_level( + ceiling: Optional[GovernanceCeiling], scope: str, *, baseline: bool = True +) -> Tuple[object, bool]: + """The policy level's control for *scope*, and whether the baseline supplied it. + + The remote policy answers a scope it names. In an enterprise build a scope it + does not name is answered by the built-in baseline (RFC D3), never left + ungoverned. Only the POLICY level is filled in this way: a profile narrows the + policy beneath it, so an unnamed profile scope stays ``None`` and cannot + impose the baseline again under a remote widening. That is why a ``Profile`` + passed in the ceiling's place (``governance_profiles`` asks ``may_skip_gate`` + about one profile alone) and a caller asking about the profile layer alone + (``baseline=False``) get no baseline. + """ + control = ceiling.get(scope) if ceiling is not None else None + if ( + control is not None + or not baseline + or isinstance(ceiling, Profile) + or not edition.is_enterprise() + ): + return control, False + # A policy's ``folders.*`` rule is stored under its ``filesystem.*`` scope at + # parse time, so an alias query must find it there before the baseline answers. + canonical = _SCOPE_ALIASES.get(scope, scope) + named = ceiling.get(canonical) if ceiling is not None else None + if named is not None: + return named, False + if canonical in BASELINE_UNGATED: + return None, False + return baseline_controls().get(canonical, _DENY_UNLISTED), True + + +def policy_level_control(ceiling: Optional[GovernanceCeiling], scope: str) -> Optional[object]: + """The control the policy level enforces for *scope* (see :func:`_policy_level`). + + For readers that inspect a ceiling's control directly instead of calling + :func:`resolve`, such as ``governance.may_skip_gate``; reading + ``ceiling.get`` there would treat an unnamed scope as ungoverned in an + enterprise build and skip the gate the baseline is enforced at. + """ + return _policy_level(ceiling, scope)[0] + def _query_level(control: object, scope: str, item: str) -> Decision: """Query ONE level's control for *item* in *scope* (Rule 1). A ``None`` control means the level does not govern this scope → permit - (truth-table "not-governed" rows). Dispatch is by archetype, not scope + (truth-table "not-governed" rows). In an enterprise build the POLICY level + is never ``None`` here for a gated scope: :func:`_policy_level` supplies the + built-in baseline first, so only a profile reaches this permit. Dispatch is by archetype, not scope name. ``scope`` is used only to locate the sub-leaf for the structured archetypes (capabilities.., channels member). """ @@ -83,6 +139,8 @@ def resolve( profile: Optional[Profile], scope: str, item: str, + *, + baseline: bool = True, ) -> Decision: """Resolve whether *item* is permitted in *scope* — ``policy ∩ profile``. @@ -90,10 +148,15 @@ def resolve( Policy is the hard ceiling; the profile can only further restrict. Either level being ``None`` (or not governing the scope) contributes a permit, so ``resolve(None, None, …)`` permits everything (the ungoverned standalone - default) and a policy-deny is final regardless of the profile. + default) and a policy-deny is final regardless of the profile. In an + enterprise build a scope the policy does not name is answered by the built-in + baseline instead (:func:`_policy_level`); ``baseline=False`` asks the profile + layer alone, for a caller that judges each layer separately. """ - policy_control = ceiling.get(scope) if ceiling is not None else None + policy_control, from_baseline = _policy_level(ceiling, scope, baseline=baseline) policy_dec = _query_level(policy_control, scope, item) + if from_baseline: + policy_dec = replace(policy_dec, reason=f"{_BASELINE_REASON}: {policy_dec.reason}") profile_control = profile.get(scope) if profile is not None else None if not policy_dec.permitted: # Same provenance rule as ``_AndRuleset.permits``: a policy allow-mode @@ -274,15 +337,21 @@ def gate_decision( if not spellings: continue refusal: Optional[Decision] = None - for layer_ceiling, layer_profile in ((ceiling, None), (None, profile)): - if layer_ceiling is None and layer_profile is None: + # The profile pass asks the profile alone: filling its empty policy slot + # with the baseline would refuse beneath a remote grant what the ceiling + # pass already admitted. + layers = ((ceiling, None, True), (None, profile, False)) + for layer_ceiling, layer_profile, layer_baseline in layers: + if layer_ceiling is None and layer_profile is None and not layer_baseline: continue admitted = False last_refusal: Optional[Decision] = None for spelling in spellings: spelling_ok = True for scope, item in dict.fromkeys(classify_tool_title(spelling)): - decision = resolve(layer_ceiling, layer_profile, scope, item) + decision = resolve( + layer_ceiling, layer_profile, scope, item, baseline=layer_baseline + ) if decision.permitted: continue if decision.explicit_deny: diff --git a/src/kiro_crew/platform/governance_parts/parsing.py b/src/kiro_crew/platform/governance_parts/parsing.py index 000ddfd29d0..0dd10600ad0 100644 --- a/src/kiro_crew/platform/governance_parts/parsing.py +++ b/src/kiro_crew/platform/governance_parts/parsing.py @@ -14,6 +14,7 @@ import json import logging import re +from functools import lru_cache from pathlib import Path from typing import Dict, List, Mapping, Optional @@ -25,6 +26,7 @@ ScopedRuleset, _compose_controls, ) +from kiro_crew.platform.governance_parts.baseline import BASELINE_CONTROLS from kiro_crew.platform.governance_parts.carriers import ( SIGNATURE_UNCHECKED, Bind, @@ -357,6 +359,19 @@ def _coerce_boot_flag( return closed +@lru_cache(maxsize=1) +def baseline_controls() -> Mapping[str, object]: + """The enterprise built-in baseline (``baseline.BASELINE_CONTROLS``), parsed. + + Parsed by :func:`_parse_control`, the per-control parser a remote policy goes + through, so the baseline obeys the same catalog floors (``always_permitted``). + """ + return { + scope: _parse_control(scope, SCOPE_CATALOG[scope], raw, is_policy=True) + for scope, raw in BASELINE_CONTROLS.items() + } + + def parse_policy( data: Mapping[str, object], *, signature_state: str = SIGNATURE_UNCHECKED ) -> GovernanceCeiling: diff --git a/src/kiro_crew/platform/governance_parts/tiers.py b/src/kiro_crew/platform/governance_parts/tiers.py index 5041192a056..e51163b176c 100644 --- a/src/kiro_crew/platform/governance_parts/tiers.py +++ b/src/kiro_crew/platform/governance_parts/tiers.py @@ -19,6 +19,7 @@ from pathlib import Path from typing import Callable, Dict, Mapping, Optional, Tuple +from kiro_crew import edition from kiro_crew.config.paths import config_dir from kiro_crew.platform.context import PlatformCompositionError from kiro_crew.platform.governance_parts.archetypes import _compose_controls @@ -33,6 +34,7 @@ from kiro_crew.platform.governance_parts.parsing import ( _SANDBOX_FLAGS_SCOPE, _read_json_file, + baseline_controls, parse_policy, ) from kiro_crew.platform.governance_parts.signature import _verify_policy_signature @@ -389,11 +391,15 @@ def compose_tier_ladder( Extracted so that boot and a live central refresh cannot drift: a refresh that installed the fetched document alone would silently drop every local restriction below it. + + In an enterprise build the authority's unnamed scopes are first filled from the + built-in baseline (:func:`_with_enterprise_baseline`), so a lower tier that names + such a scope narrows the baseline instead of supplying the scope's whole answer. """ present = [tier for tier in tiers if tier is not None] if not present: return None - ceiling = present[0] + ceiling = _with_enterprise_baseline(present[0]) # The distribution pins ride OUTSIDE ``controls`` (they are values the core # consumes, not a governed scope), so they are not carried by the per-scope # compose the rest of this fold uses -- every path that REBUILDS a ceiling has @@ -426,6 +432,39 @@ def compose_tier_ladder( return ceiling +def _with_enterprise_baseline(authority: GovernanceCeiling) -> GovernanceCeiling: + """*authority* with every scope it does not name filled from the baseline. + + RFC D3 (``docs/request-for-change/rfc-crew-enterprise-edition.md``): in an + enterprise build only the remote policy may turn a control on, and a scope it + does not name holds its minimal set. :func:`_intersect_ceilings` lets a lower + tier supply a scope its authority left out, which is a tightening only while an + unnamed scope means "unrestricted". Filling the authority first keeps that fold + tighten-only in an enterprise build too: a local tier composes WITH the baseline + and can narrow it, never replace it. Only a central authority's named scopes + replace the baseline; a local authority (no central document present) is + intersected with it scope by scope. The ``oss`` edition returns *authority* + unchanged (the same object). + """ + if not edition.is_enterprise(): + return authority + baseline = baseline_controls() + if authority.tier != TIER_CENTRAL: + # No central document above it: the authority is itself a LOCAL tier, so + # its named scopes may only narrow the baseline, never replace it. + merged: Dict[str, object] = dict(authority.controls) + for scope, control in baseline.items(): + named = merged.get(scope) + merged[scope] = control if named is None else _compose_controls(control, named) + return replace(authority, controls=merged) + missing = { + scope: control for scope, control in baseline.items() if scope not in authority.controls + } + if not missing: + return authority + return replace(authority, controls={**authority.controls, **missing}) + + def _subordinate_ceiling( bundled: Optional[Mapping[str, object]], home_data: Optional[Dict[str, object]], diff --git a/test/test_dashboard_page_scripts_ceiling.py b/test/test_dashboard_page_scripts_ceiling.py index f033870b2f2..c573e6e195c 100644 --- a/test/test_dashboard_page_scripts_ceiling.py +++ b/test/test_dashboard_page_scripts_ceiling.py @@ -271,6 +271,15 @@ def test_the_grant_is_resolved_off_the_event_loop(self) -> None: class TestTheUngovernedDefaults: """Every install that configures no policy is unchanged.""" + def test_enterprise_unnamed_egress_is_the_baseline_and_gets_NO_scripts( + self, ceiling, monkeypatch + ) -> None: + """In an enterprise build an unnamed ``network.egress`` is the built-in + baseline (off), so the page stays static (RFC D3).""" + monkeypatch.setattr("kiro_crew.edition.EDITION", "enterprise") + ceiling(None, governed=False) + assert md.owner_agent_egress_unrestricted() is False + def test_no_ceiling_gets_scripts(self, monkeypatch) -> None: monkeypatch.setattr(gov, "last_composed_ceiling", lambda: None) assert md.owner_agent_egress_unrestricted() is True diff --git a/test/test_governance_enterprise_baseline.py b/test/test_governance_enterprise_baseline.py new file mode 100644 index 00000000000..e6d9174088f --- /dev/null +++ b/test/test_governance_enterprise_baseline.py @@ -0,0 +1,278 @@ +"""The enterprise built-in baseline: an unnamed scope answers its minimal set. + +RFC ``docs/request-for-change/rfc-crew-enterprise-edition.md`` decision D3: in an +enterprise build a scope the remote policy does not name is locked to its +minimal set, or off when it has none. The remote policy may widen past it; a +profile only narrows. The ``oss`` edition is unchanged. +""" + +from __future__ import annotations + +import dataclasses +import os + +import pytest + +from kiro_crew.platform import governance +from kiro_crew.platform.governance import ( + _PERMIT_NOT_GOVERNED, + _SCOPE_ALIASES, + SCOPE_CATALOG, + _query_level, + gate_decision, + parse_policy, + parse_profile, + resolve, +) +from kiro_crew.platform.governance_parts.baseline import BASELINE_CONTROLS, BASELINE_UNGATED + +_WORKSPACE_FILE = os.path.join("~", "workplace", "kirocrew-workspace", "notes.txt") + +# scope -> (items the baseline permits, items it denies). One row per catalog scope. +_BASELINE_TABLE: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = { + "tools": ((), ("fs_read", "glob", "grep", "fs_write", "execute_bash", "web_fetch")), + "approval_modes": (("normal", "trust", "trust_reads"), ("yolo",)), + "mcp": (("@kirocrew-core/local_knowledge_search",), ("@other/tool", "@other")), + "apps": ((), ("any-app",)), + "commands": ((), ("ls", "git status")), + "filesystem.read": ((_WORKSPACE_FILE,), ("/etc/passwd", os.path.join("~", "notes.txt"))), + "filesystem.write": ((), (_WORKSPACE_FILE,)), + "folders.read": ((_WORKSPACE_FILE,), ("/etc/passwd",)), + "folders.write": ((), (_WORKSPACE_FILE,)), + "network.egress": ((), ("example.com",)), + "channels": ((), ("slack",)), + "approval_mode": (("interactive",), ()), + "sandbox.min_level": (("strict",), ()), + "yolo_duration": ((), ("permanent", "until_shutdown", "1h")), + "agent_backend": (("kiro",), ("claude", "kas")), + "capabilities.publish": (("", "destinations:default"), ("destinations:s3",)), +} + + +def _row(scope: str) -> tuple[tuple[str, ...], tuple[str, ...]]: + if scope in _BASELINE_TABLE: + return _BASELINE_TABLE[scope] + assert scope.startswith("capabilities."), scope + return (), ("", "agents:researcher") + + +def _ceiling(**controls: object) -> governance.GovernanceCeiling: + return parse_policy({"version": 1, "boot": {}, **controls}) + + +@pytest.fixture +def enterprise(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("kiro_crew.edition.EDITION", "enterprise") + + +def test_every_catalog_scope_has_a_baseline_entry() -> None: + """A new catalog scope fails here until it gets a baseline row or an ungated reason.""" + canonical = set(SCOPE_CATALOG) - set(_SCOPE_ALIASES) + assert set(BASELINE_CONTROLS).isdisjoint(BASELINE_UNGATED) + assert set(BASELINE_CONTROLS) | set(BASELINE_UNGATED) == canonical + assert all(BASELINE_UNGATED.values()) + + +def test_every_catalog_scope_has_a_table_row() -> None: + for scope in SCOPE_CATALOG: + if not scope.startswith("capabilities.") or scope == "capabilities.publish": + assert scope in _BASELINE_TABLE, scope + + +@pytest.mark.parametrize("ceiling_kind", ["none", "empty"]) +@pytest.mark.parametrize("scope", sorted(SCOPE_CATALOG)) +def test_enterprise_unnamed_scope_answers_the_baseline( + enterprise: None, scope: str, ceiling_kind: str +) -> None: + ceiling = None if ceiling_kind == "none" else _ceiling() + permitted, denied = _row(scope) + for item in permitted: + assert resolve(ceiling, None, scope, item).permitted, (scope, item) + for item in denied: + decision = resolve(ceiling, None, scope, item) + assert not decision.permitted, (scope, item) + assert decision.layer == "policy" + assert "enterprise baseline" in decision.reason + + +def test_enterprise_scope_with_no_baseline_row_is_denied(enterprise: None) -> None: + assert not resolve(_ceiling(), None, "made.up", "x").permitted + + +def test_enterprise_policy_naming_a_scope_answers_and_may_widen(enterprise: None) -> None: + ceiling = _ceiling( + tools={"mode": "allow", "allow": ["fs_write"]}, + capabilities={"browse": {"enabled": True}}, + commands={"mode": "deny", "deny": ["rm *"]}, + ) + assert resolve(ceiling, None, "tools", "fs_write").permitted + assert not resolve(ceiling, None, "tools", "fs_read").permitted + assert resolve(ceiling, None, "capabilities.browse", "").permitted + assert resolve(ceiling, None, "commands", "ls").permitted + assert not resolve(ceiling, None, "commands", "rm -rf x").permitted + # A scope the policy leaves out still answers the baseline. + assert not resolve(ceiling, None, "network.egress", "example.com").permitted + + +@pytest.mark.parametrize("scope", ["filesystem.read", "folders.read"]) +def test_enterprise_alias_query_finds_the_policy_rule(enterprise: None, scope: str) -> None: + """A ``folders.*`` query answers from the ``filesystem.*`` rule it was stored under.""" + # Built with the host's own separators: the path matcher absolutizes the item. + root = os.path.abspath(os.path.join(os.sep, "project")) + ceiling = _ceiling(filesystem={"read": {"mode": "allow", "allow": [os.path.join(root, "**")]}}) + assert resolve(ceiling, None, scope, os.path.join(root, "notes.txt")).permitted + assert not resolve(ceiling, None, scope, "/etc/passwd").permitted + + +def test_enterprise_profile_not_naming_a_scope_keeps_the_remote_widening( + enterprise: None, +) -> None: + ceiling = _ceiling(tools={"mode": "allow", "allow": ["fs_write"]}) + profile = parse_profile({"name": "narrow", "commands": {"mode": "deny", "deny": ["rm *"]}}) + assert resolve(ceiling, profile, "tools", "fs_write").permitted + + +def test_enterprise_alias_group_keeps_the_remote_grant_under_a_profile(enterprise: None) -> None: + """The per-layer alias pass asks the profile alone, so no baseline is added there.""" + ceiling = _ceiling( + tools={"mode": "deny", "deny": []}, + commands={"mode": "deny", "deny": []}, + ) + profile = parse_profile({"name": "narrow", "mcp": {"mode": "deny", "deny": ["@x"]}}) + groups = (("shell", "execute_bash"),) + assert gate_decision(ceiling, profile, "shell", alias_groups=groups).permitted + assert not gate_decision(_ceiling(), profile, "shell", alias_groups=groups).permitted + + +def test_enterprise_may_skip_gate_asked_about_a_profile_adds_no_baseline( + enterprise: None, +) -> None: + """``governance_profiles`` asks ``may_skip_gate`` about one profile in the ceiling slot.""" + profile = parse_profile({"name": "narrow", "mcp": {"mode": "deny", "deny": ["@x"]}}) + assert governance.may_skip_gate("web_fetch", profile) is True # type: ignore[arg-type] + assert governance.may_skip_gate("@x", profile) is False # type: ignore[arg-type] + + +def test_enterprise_profile_narrows_the_baseline(enterprise: None) -> None: + ceiling = _ceiling(tools={"mode": "allow", "allow": ["fs_read", "grep"]}) + profile = parse_profile({"name": "narrow", "tools": {"mode": "deny", "deny": ["grep"]}}) + assert not resolve(ceiling, profile, "tools", "grep").permitted + assert resolve(ceiling, profile, "tools", "fs_read").permitted + profile = parse_profile({"name": "narrow", "agent_backend": {"mode": "deny", "deny": ["kiro"]}}) + assert not resolve(_ceiling(), profile, "agent_backend", "kiro").permitted + + +def test_enterprise_gate_refuses_a_shell_command_with_an_empty_policy(enterprise: None) -> None: + assert not gate_decision(_ceiling(), None, "Running: ls").permitted + + +@pytest.mark.parametrize("ceiling_kind", ["none", "empty"]) +@pytest.mark.parametrize("ref", ["web_fetch", "web_search", "use_subagent", "@other", "@other/x"]) +def test_enterprise_auto_approve_cannot_skip_the_gate_for_an_unnamed_scope( + enterprise: None, ref: str, ceiling_kind: str +) -> None: + ceiling = None if ceiling_kind == "none" else _ceiling() + assert governance.may_skip_gate(ref, ceiling) is False + + +@pytest.mark.parametrize("ref", ["web_fetch", "web_search", "use_subagent", "@other"]) +def test_oss_auto_approve_is_unchanged(ref: str) -> None: + assert governance.may_skip_gate(ref, None) is True + assert governance.may_skip_gate(ref, _ceiling()) is True + + +@pytest.mark.parametrize("scope", sorted(SCOPE_CATALOG)) +def test_oss_unnamed_scope_is_not_governed(scope: str) -> None: + assert _query_level(None, scope, "x") is _PERMIT_NOT_GOVERNED + for ceiling in (None, _ceiling()): + decision = resolve(ceiling, None, scope, "x") + assert decision.permitted + assert decision.layer == "default" + + +# ────────────────────────────────────────────────────────────────────────── +# The tier fold: a local tier narrows the baseline, never widens it +# ────────────────────────────────────────────────────────────────────────── +def _tier(tier: str, **controls: object) -> governance.GovernanceCeiling: + return dataclasses.replace(_ceiling(**controls), tier=tier) + + +_WS = os.path.abspath(os.path.join(os.sep, "ws")) + + +def test_enterprise_local_curl_deny_stays_denied(enterprise: None) -> None: + central = _tier(governance.TIER_CENTRAL, commands={"mode": "deny", "deny": []}) + home = _tier(governance.TIER_HOME, commands={"mode": "deny", "deny": ["curl *"]}) + composed = governance.compose_tier_ladder(central, home) + assert not resolve(composed, None, "commands", "curl https://example.com").permitted + assert resolve(composed, None, "commands", "ls").permitted + + +def test_enterprise_local_write_allowlist_still_narrows_writes(enterprise: None) -> None: + central = _tier( + governance.TIER_CENTRAL, + filesystem={"write": {"mode": "allow", "allow": [os.path.join(_WS, "**")]}}, + ) + home = _tier( + governance.TIER_HOME, + filesystem={"write": {"mode": "allow", "allow": [os.path.join(_WS, "sub", "**")]}}, + ) + composed = governance.compose_tier_ladder(central, home) + assert resolve(composed, None, "filesystem.write", os.path.join(_WS, "sub", "a")).permitted + assert not resolve(composed, None, "filesystem.write", os.path.join(_WS, "b")).permitted + + +@pytest.mark.parametrize("local_tier", ["env", "home"]) +def test_enterprise_local_tier_cannot_permit_what_the_baseline_denies( + enterprise: None, local_tier: str +) -> None: + central = _tier(governance.TIER_CENTRAL) + local = _tier( + local_tier, + capabilities={"browse": {"enabled": True}}, + network={"egress": {"mode": "deny", "deny": []}}, + tools={"mode": "allow", "allow": ["fs_read", "fs_write"]}, + ) + composed = governance.compose_tier_ladder(central, local) + assert not resolve(composed, None, "capabilities.browse", "").permitted + assert not resolve(composed, None, "network.egress", "example.com").permitted + assert not resolve(composed, None, "tools", "fs_write").permitted + assert not resolve(composed, None, "tools", "fs_read").permitted + # Named by the authority, the scope answers from the remote policy. + central = _tier(governance.TIER_CENTRAL, capabilities={"browse": {"enabled": True}}) + assert resolve( + governance.compose_tier_ladder(central, local), None, "capabilities.browse", "" + ).permitted + + +def test_oss_tier_fold_is_unchanged() -> None: + central = _tier(governance.TIER_CENTRAL) + home = _tier(governance.TIER_HOME, capabilities={"browse": {"enabled": False}}) + assert governance.compose_tier_ladder(central) is central + composed = governance.compose_tier_ladder(central, home) + assert set(composed.controls) == {"capabilities.browse"} + assert resolve(composed, None, "network.egress", "example.com").permitted + + +@pytest.mark.parametrize("local_tier", ["env", "bundled", "home"]) +def test_enterprise_local_authority_with_no_central_only_narrows_the_baseline( + enterprise: None, local_tier: str +) -> None: + """With no central document the top tier is local, so it may not replace the baseline.""" + local = _tier( + local_tier, + network={"egress": {"mode": "deny", "deny": []}}, + commands={"mode": "deny", "deny": []}, + capabilities={"browse": {"enabled": True}}, + agent_backend={"mode": "deny", "deny": ["kiro"]}, + ) + for composed in ( + governance.compose_tier_ladder(None, local), + governance.compose_tier_ladder(local, _tier(governance.TIER_HOME)), + ): + assert not resolve(composed, None, "network.egress", "example.com").permitted + assert not resolve(composed, None, "commands", "ls").permitted + assert not resolve(composed, None, "capabilities.browse", "").permitted + # Narrowing still holds: the baseline's ``kiro`` is denied by the local tier. + assert not resolve(composed, None, "agent_backend", "kiro").permitted + assert resolve(composed, None, "mcp", "@kirocrew-core/x").permitted diff --git a/test/test_governance_policy_viewer.py b/test/test_governance_policy_viewer.py index bd749196745..6b3bd7654ec 100644 --- a/test/test_governance_policy_viewer.py +++ b/test/test_governance_policy_viewer.py @@ -80,6 +80,18 @@ def test_no_policy_no_profile_all_ungoverned(self, profiles_dir): assert all(row["source"] == "ungoverned" for row in snap["scopes"]) assert all(row["governed"] is False for row in snap["scopes"]) + def test_enterprise_unnamed_scope_reports_the_baseline(self, profiles_dir, monkeypatch): + # An enterprise build answers an unnamed scope from the built-in baseline, + # so the viewer must not call it "ungoverned" (RFC D3). + monkeypatch.setattr("kiro_crew.edition.EDITION", "enterprise") + _install_ceiling({"version": 1, "boot": {}}) + rows = _by_scope(build_governance_policy_snapshot()) + + assert rows["capabilities.browse"]["governed"] is True + assert rows["capabilities.browse"]["source"] == "policy" + assert rows["capabilities.browse"]["detail"]["enabled"] is False + assert rows["sandbox.min_level"]["source"] == "ungoverned" + def test_covers_every_catalog_scope_minus_aliases(self, profiles_dir): # The viewer iterates SCOPE_CATALOG (never a hardcoded list) so it stays # complete + auto-extends; the folders.* aliases are folded into @@ -314,9 +326,7 @@ def test_policy_pin_is_marked_install_wide(self, profiles_dir): assert row["source"] == "policy" assert row["scope_note"] == "policy_wide" - def test_composed_row_is_policy_wide_when_policy_is_the_half_that_disables( - self, profiles_dir - ): + def test_composed_row_is_policy_wide_when_policy_is_the_half_that_disables(self, profiles_dir): """The inverse mislabel: a Level-1 ceiling must not read as host-only. ``CapabilityGate.enabled`` composes by AND, so a POLICY gate that is already @@ -489,9 +499,7 @@ def _boom(*_a, **_k): assert payload["scopes"] == [] assert payload["has_policy"] is False - def test_fallback_profiles_lists_the_broken_host_profile( - self, profiles_dir, monkeypatch - ): + def test_fallback_profiles_lists_the_broken_host_profile(self, profiles_dir, monkeypatch): """A resolved profile that is a deny-all fallback is named in the list.""" # Write a profile with a valid bind but invalid controls — the bind is # salvaged so the surface resolves to the deny-all fallback. @@ -580,9 +588,7 @@ def test_fallback_profiles_empty_when_no_profile(self, profiles_dir): assert snap["fallback_profiles"] == [] assert snap["profile"] is None - def test_fallback_profiles_empty_in_unavailable_response( - self, profiles_dir, monkeypatch - ): + def test_fallback_profiles_empty_in_unavailable_response(self, profiles_dir, monkeypatch): """The unavailable response always reports an empty list.""" import kiro_crew.dashboard.handlers.security as sec