diff --git a/.github/black-baseline.txt b/.github/black-baseline.txt index 6e2372892ac..d2f669f02c4 100644 --- a/.github/black-baseline.txt +++ b/.github/black-baseline.txt @@ -119,7 +119,6 @@ src/kiro_crew/apps/builtins/workflows/server.py src/kiro_crew/apps/cron_sdk.py src/kiro_crew/apps/dependencies.py src/kiro_crew/apps/dependency_ledger.py -src/kiro_crew/apps/dev_mode.py src/kiro_crew/apps/manifest.py src/kiro_crew/apps/module_loader.py src/kiro_crew/apps/permissions.py @@ -226,7 +225,6 @@ src/kiro_crew/metrics/http_metrics.py src/kiro_crew/metrics/recorder.py src/kiro_crew/notifications/resource_pressure.py src/kiro_crew/perf_sampler.py -src/kiro_crew/platform/update_layout.py src/kiro_crew/pod/launchd.py src/kiro_crew/publish_governance.py src/kiro_crew/publish_sync.py diff --git a/docs/system-specs/modules/computer-use.md b/docs/system-specs/modules/computer-use.md index d5614c33dcd..772636726c6 100644 --- a/docs/system-specs/modules/computer-use.md +++ b/docs/system-specs/modules/computer-use.md @@ -498,6 +498,12 @@ Mechanics: - `is_enabled()` is strict identity against `True`: a hand-edited `"enabled": "false"` (a truthy string) or `"enabled": 1` does not enable desktop control. +- An enterprise build ignores the opt-in: `is_enabled()` answers `False`, the + config GET carries `locked_by_policy: true`, and a PUT with `enabled: true` + answers 409 `locked_by_policy` + ([rfc-crew-enterprise-edition](../../request-for-change/rfc-crew-enterprise-edition.md) + D3, `kiro_crew.edition_locks`). This is an edition lock, not a governance scope; + computer use stays ungoverned, and an `oss` build is unchanged. - The only writer is the dashboard PUT handler, which does not route through the agent tool gate. - `allowed_apps` is an optional narrowing (empty = everything not denied); diff --git a/docs/system-specs/modules/platform-context.md b/docs/system-specs/modules/platform-context.md index a914bdd672f..336fba9f6f6 100644 --- a/docs/system-specs/modules/platform-context.md +++ b/docs/system-specs/modules/platform-context.md @@ -534,7 +534,21 @@ those to differ must bump its own project version as well. `kiro_crew/edition.py` holds `EDITION`, one of `EDITIONS = {"oss", "enterprise"}`, and `is_enterprise()`. The checked-in value is always `"oss"`. A packaging build picks the value: `make wheel|backend-bin|desktop EDITION=enterprise` (only a command-line `EDITION=` counts, an exported shell `EDITION` is ignored, even under `make -e`, because the edition variables are `override`; without it, Make takes `KIROCREW_BUILD_EDITION` from the environment, else `oss`; both set and different is refused) passes it as `KIROCREW_BUILD_EDITION` to `setup.py`'s `build_py`, which rewrites the `EDITION` line in the BUILT copy only, through `kiro_crew/_edition_stamp.py`. The same variable works for a direct `python -m build` or `packaging/build-desktop.sh` run. An unknown value fails the build (Make checks first with an exact match, so `%` wildcards are refused; `build_py` checks again); an editable install (which `make.ps1 wheel` runs as a prerequisite) stamps nothing, since it runs the `oss` source tree, but still refuses an unknown value. The stamp is rewritten on every build, `oss` included, so a reused `build/` cannot carry an old enterprise stamp into a default build. -Runtime code reads only the module constant, never the build variable or a config file, for the same reason as `BUILD_VERSION`: an installed build's edition is part of its bytes, not something the local operator sets. Read it at call time (`edition.is_enterprise()`), not as a `from kiro_crew.edition import EDITION` copy, so tests can flip it with `monkeypatch.setattr("kiro_crew.edition.EDITION", "enterprise")`. Its one reader is `resolve_profile`: an enterprise build always resolves the `enterprise` profile, so `PlatformContext.is_enterprise` stays the single property callers read and the edition only feeds it. An enterprise build with no companion installed therefore fails closed at boot (`discover_companion_context` raises); that is intended, not a gap. An `oss` build resolves exactly as before. Tests: `test/test_edition.py`, `test/test_platform_context.py::TestEnterpriseEditionProfile`. +Runtime code reads only the module constant, never the build variable or a config file, for the same reason as `BUILD_VERSION`: an installed build's edition is part of its bytes, not something the local operator sets. Read it at call time (`edition.is_enterprise()`), not as a `from kiro_crew.edition import EDITION` copy, so tests can flip it with `monkeypatch.setattr("kiro_crew.edition.EDITION", "enterprise")`. Its readers are `resolve_profile` and `kiro_crew.edition_locks` (below). In `resolve_profile`, an enterprise build always resolves the `enterprise` profile, so `PlatformContext.is_enterprise` stays the single property callers read and the edition only feeds it. An enterprise build with no companion installed therefore fails closed at boot (`discover_companion_context` raises); that is intended, not a gap. An `oss` build resolves exactly as before. Tests: `test/test_edition.py`, `test/test_platform_context.py::TestEnterpriseEditionProfile`. + +#### Local switches with no governance key (`edition_locks.py`) + +[rfc-crew-enterprise-edition](../../request-for-change/rfc-crew-enterprise-edition.md) D3: in an enterprise build only the remote policy can turn a control on, and local config, environment variables, profiles and dashboard settings may only narrow it. A control with a governance key gets that from the evaluator. A control with no key cannot be turned on by any remote rule, so its local switch is ignored: `kiro_crew/edition_locks.py` lists each one in `UNKEYED_LOCKS`, and the feature calls `local_enable_locked()` where it reads its own switch. The call returns `False` on `oss`, so nothing changes there; on enterprise it returns `True` and logs one warning per control. A later change that adds a real key for a control replaces its row with a governance check. + +| Control id | Local source ignored | Read site | Setting API | +|---|---|---|---| +| `app_dev_mode` | `installed.json` `dev`, the dev sentinel, `POST /api/apps/{name}/dev`, `kirocrew app dev` | `apps/dev_mode.py` (`set_dev_mode`, `is_dev_mode`, `is_dev_mode_cached`, `_load_dev_apps`) | the toggle and the CLI answer `locked_by_policy` (409 over HTTP), audited as a denied `app_dev_mode` SEL row | +| `updates.channel` | `$KIROCREW_HOME/channel` | `platform/update_layout.py::release_channel` (answers `stable`) | `POST /api/update/channel` to a non-stable channel answers 409 `locked_by_policy` after the layout refusals, audited as `update.channel` | +| `computer_use` | `computer_use.json` `enabled` | `computer_use/enable_state.py::is_enabled` | `GET /api/computer-use/config` carries `locked_by_policy`; an enable save answers 409 | +| `tunnel` | `tunnel.enabled` | `dashboard/server_runtime/tunnel.py::_start_aea_tunnel` (the edition's own `TunnelProvider` is not a local source) | `GET /api/config/schema` marks the path `lockedByPolicy` | +| `telemetry.otlp_hosts` | `telemetry.otlp_endpoint` | `metrics/provider.py::_edition_locked_view`, read by `_otlp_destinations` and `otlp_egress_active` before any provider sees the config (an edition provider's own destinations stay) | `GET /api/config/schema` marks the path `lockedByPolicy` | + +Each locked config path is in `CONFIG_PATH_LOCKS`, which the schema endpoint reads. A write of a locked value is still stored and is ignored on read, so a saved setting never loops on an error. Tests: `test/test_enterprise_unkeyed_locks.py`. ## Gateway restart launcher diff --git a/src/kiro_crew/apps/dev_mode.py b/src/kiro_crew/apps/dev_mode.py index 197cdf2aebb..a927363882c 100644 --- a/src/kiro_crew/apps/dev_mode.py +++ b/src/kiro_crew/apps/dev_mode.py @@ -31,6 +31,7 @@ (:data:`_dev_apps_cache`) so ``handle_app_ui_file`` can decide the cache header without any per-request disk IO on the event loop. """ + from __future__ import annotations import asyncio @@ -53,6 +54,7 @@ holds_app_record_locks, ) from kiro_crew.atomic_write import atomic_write +from kiro_crew.edition_locks import LOCKED_BY_POLICY, local_enable_locked from kiro_crew.security import is_sensitive_path, path_contains_sensitive from kiro_crew.sel import sel @@ -151,6 +153,8 @@ def _load_dev_apps() -> set[str]: sentinel changes and at seeding, never per UI request. """ keep: set[str] = set() + if local_enable_locked("app_dev_mode"): + return keep for name in _read_dev_sentinel(): try: meta = _read_installed(name) @@ -185,9 +189,7 @@ def _read_dev_grants() -> dict[str, str]: return {} if isinstance(data, dict): return { - str(k): str(v) - for k, v in data.items() - if isinstance(k, str) and isinstance(v, str) + str(k): str(v) for k, v in data.items() if isinstance(k, str) and isinstance(v, str) } return {} @@ -376,6 +378,17 @@ def set_dev_mode( """ if not _check_path_safety(name): return {"error": f"invalid app name {name!r}"} + if enabled and local_enable_locked("app_dev_mode"): + # Audited here, not by the caller, so the CLI and the HTTP toggle both record it. + sel().log_api_access( + caller=f"app:{name}", + operation="app_dev_mode", + outcome="denied", + source="apps", + resources=f"{name} enabled=True", + error="locked_by_policy", + ) + return {"error": LOCKED_BY_POLICY, "code": "locked_by_policy"} hold_app_record_lock(name) # Cheap validation read outside the sentinel lock (the record lock above is # already held) — builtin/not-installed status does not change under us. The authoritative read → mutate → write happens again @@ -450,9 +463,7 @@ def set_dev_mode( # Anchor = resolved apps ROOT + literal name (same rule as the # route): re-resolving through the app's own entry would race # a concurrent swap of that entry. - Path(granted_root).relative_to( - Path(os.path.realpath(apps_dir())) / name - ) + Path(granted_root).relative_to(Path(os.path.realpath(apps_dir())) / name) except ValueError: # BOTH directions of the sensitivity test: a root that IS # sensitive (inside `~/.ssh`) and a root that CONTAINS @@ -460,9 +471,7 @@ def set_dev_mode( # only its `config.json` is — and `~` contains everything). # Either shape would let the unauthenticated UI route serve # credential material out of an allowlisted extension. - if is_sensitive_path(granted_root) or path_contains_sensitive( - granted_root - ): + if is_sensitive_path(granted_root) or path_contains_sensitive(granted_root): return { "error": ( f"app {name!r} has a ui root resolving to a " @@ -639,7 +648,7 @@ def is_dev_mode(name: str) -> bool: authorization decision use :func:`dev_mode_granted_root`, which also requires the gateway-owned sentinel. """ - if not _check_path_safety(name): + if not _check_path_safety(name) or local_enable_locked("app_dev_mode"): return False meta = _read_installed(name) return bool(meta and meta.dev) @@ -691,7 +700,7 @@ def is_dev_mode_cached(name: str) -> bool: Safe to call on the event loop per-request. The cache is seeded at watcher init and refreshed each tick, so it lags disk by at most one poll interval. """ - return name in _dev_apps_cache + return name in _dev_apps_cache and not local_enable_locked("app_dev_mode") def _scan_ui_mtimes(ui_dir: Path) -> tuple[int, int]: diff --git a/src/kiro_crew/apps/routes_static.py b/src/kiro_crew/apps/routes_static.py index 439537fbc5e..5f0a72ef239 100644 --- a/src/kiro_crew/apps/routes_static.py +++ b/src/kiro_crew/apps/routes_static.py @@ -505,6 +505,10 @@ async def handle_app_dev_mode(request: web.Request) -> web.Response: # the dev sentinel) — offload it so the gateway event loop never stalls. result = await asyncio.to_thread(set_dev_mode, name, enabled) if "error" in result: + if result.get("code") == "locked_by_policy": + return web.json_response( + {"error": result["error"], "code": "locked_by_policy"}, status=409 + ) return web.json_response(result, status=404 if "not installed" in result["error"] else 400) sel().log_api_access( caller=request.get("user", "dashboard"), diff --git a/src/kiro_crew/computer_use/enable_state.py b/src/kiro_crew/computer_use/enable_state.py index fc8daf6a440..94d1db45a36 100644 --- a/src/kiro_crew/computer_use/enable_state.py +++ b/src/kiro_crew/computer_use/enable_state.py @@ -90,7 +90,14 @@ def is_enabled(state: "dict | None" = None) -> bool: *state* lets a caller that already loaded the file avoid a second read inside one dispatch. + + An enterprise build ignores the opt-in and answers ``False`` + (:mod:`kiro_crew.edition_locks`, RFC D3). """ + from kiro_crew.edition_locks import local_enable_locked + + if local_enable_locked("computer_use"): + return False data = load_state() if state is None else state return data.get(STATE_KEY_ENABLED) is True diff --git a/src/kiro_crew/dashboard/handlers/agents.py b/src/kiro_crew/dashboard/handlers/agents.py index 830148548b3..0c66e8274c2 100644 --- a/src/kiro_crew/dashboard/handlers/agents.py +++ b/src/kiro_crew/dashboard/handlers/agents.py @@ -459,6 +459,8 @@ def _supply_live_enum(entry: dict) -> None: async def api_config_schema(request: web.Request) -> web.Response: """GET /api/config/schema — return config schema entries.""" + from kiro_crew.edition_locks import LOCKED_BY_POLICY, config_path_locked + entries = SCHEMA_REGISTRY # Filter by tags (comma-separated, intersection) @@ -480,6 +482,9 @@ async def api_config_schema(request: web.Request) -> web.Response: if entry.sensitive or dataclasses.is_dataclass(d.get("defaultValue")): d["defaultValue"] = None _supply_live_enum(d) + # An enterprise build ignores this local value (RFC D3, kiro_crew.edition_locks). + if config_path_locked(entry.path): + d["lockedByPolicy"] = LOCKED_BY_POLICY result.append(d) return web.json_response({"entries": result}) diff --git a/src/kiro_crew/dashboard/handlers/computer_use.py b/src/kiro_crew/dashboard/handlers/computer_use.py index 15068ab1e2c..e6cfdff3fe7 100644 --- a/src/kiro_crew/dashboard/handlers/computer_use.py +++ b/src/kiro_crew/dashboard/handlers/computer_use.py @@ -82,6 +82,7 @@ PolicyStateError, ) from kiro_crew.config.loader import computer_use_state_path, read_config_text +from kiro_crew.edition_locks import LOCKED_BY_POLICY, local_enable_locked from kiro_crew.executors import subprocess_executor logger = logging.getLogger(__name__) @@ -392,6 +393,7 @@ def _snapshot() -> dict[str, Any]: return { "enabled": enable_state.is_enabled(state), + "locked_by_policy": local_enable_locked("computer_use"), "supported": supported, "platform": platform_id, "reason": reason, @@ -621,6 +623,11 @@ async def api_computer_use_config_save(request: web.Request) -> web.Response: {"error": "enabled must be a boolean", "code": "invalid_field_type"}, status=400, ) + if body["enabled"] and local_enable_locked("computer_use"): + _audit(request, operation=OP_CONFIG_SAVE, outcome="denied", resources="enabled=locked") + return web.json_response( + {"error": LOCKED_BY_POLICY, "code": "locked_by_policy"}, status=409 + ) state_patch[STATE_KEY_ENABLED] = body["enabled"] for key in _APP_LIST_KEYS: if key not in body: diff --git a/src/kiro_crew/dashboard/handlers/updates_channel.py b/src/kiro_crew/dashboard/handlers/updates_channel.py index 8bd719da010..291015d2d4b 100644 --- a/src/kiro_crew/dashboard/handlers/updates_channel.py +++ b/src/kiro_crew/dashboard/handlers/updates_channel.py @@ -318,6 +318,22 @@ async def api_update_channel(request: web.Request) -> web.Response: status=409, ) + # An enterprise build follows stable; a file write here would be ignored on read. + from kiro_crew.edition_locks import LOCKED_BY_POLICY, local_enable_locked + + if local_enable_locked("updates.channel") and requested.strip().lower() != "stable": + await _audit_update_event( + request, + operation="update.channel", + outcome="denied", + error="locked_by_policy", + resources=requested[:32], + ) + return web.json_response( + {"error": LOCKED_BY_POLICY, "code": "locked_by_policy", "channel": "stable"}, + status=409, + ) + try: # Offloaded: mkdir + write + os.replace are synchronous syscalls, and the # data home can be network-backed (NFS/SMB), where even a ten-byte write diff --git a/src/kiro_crew/dashboard/server_runtime/tunnel.py b/src/kiro_crew/dashboard/server_runtime/tunnel.py index 749076079c2..2ec7d50dbae 100644 --- a/src/kiro_crew/dashboard/server_runtime/tunnel.py +++ b/src/kiro_crew/dashboard/server_runtime/tunnel.py @@ -101,7 +101,11 @@ async def _start_aea_tunnel( app: web.Application, state: DashboardState, cfg: KiroCrewConfig, port: int ) -> None: """Start the tunnel when the config or the active ``TunnelProvider`` enables it.""" - _tunnel_enabled = cfg.tunnel.enabled + from kiro_crew.edition_locks import local_enable_locked + + # An enterprise build ignores the local switch (RFC D3); a tunnel the edition's + # own provider enables below is not a local source. + _tunnel_enabled = cfg.tunnel.enabled and not local_enable_locked("tunnel") # The enable gate is also routed through the active PlatformContext's # TunnelProvider. The Default TunnelProvider.enabled() returns False, so # standalone is gated solely by ``cfg.tunnel.enabled`` exactly as before; diff --git a/src/kiro_crew/edition_locks.py b/src/kiro_crew/edition_locks.py new file mode 100644 index 00000000000..b7999f6530b --- /dev/null +++ b/src/kiro_crew/edition_locks.py @@ -0,0 +1,84 @@ +"""Local enables an enterprise build ignores, for controls with no governance key yet. + +RFC ``rfc-crew-enterprise-edition`` decision D3: in an enterprise build only the +remote policy can turn a control on; local config, environment variables, profiles +and dashboard settings may only narrow it. A control that already has a governance +key gets that from the evaluator. The controls in :data:`UNKEYED_LOCKS` have no key +yet, so no remote rule can turn them on, and their local switch is ignored here: the +feature stays at its minimal set. A later change that adds a real key for one of them +replaces its row with a governance check. + +Each feature calls :func:`local_enable_locked` where it reads its own switch. In the +``oss`` edition it returns ``False`` and nothing changes. In an enterprise build it +returns ``True`` and logs once per control. A setting API reports a locked control +with :data:`LOCKED_BY_POLICY`. +""" + +from __future__ import annotations + +import logging +import threading +from typing import Dict + +logger = logging.getLogger(__name__) + +#: The reason a setting API reports for a local value an enterprise build ignores. +LOCKED_BY_POLICY = "locked by policy" + +#: Control id -> the local source that is ignored and what the feature does instead. +#: Ids follow the RFC appendix "Crew key" column. +UNKEYED_LOCKS: Dict[str, str] = { + "app_dev_mode": "app dev mode toggles are ignored; every app is served as installed", + "updates.channel": "the release channel file is ignored; the install follows stable", + "computer_use": "the computer_use.json opt-in is ignored; desktop control stays off", + "tunnel": "tunnel.enabled is ignored; no tunnel is started", + "telemetry.otlp_hosts": "telemetry.otlp_endpoint is ignored; no metrics leave the host", +} + +#: config.json path -> the control whose lock makes that local value inert. +CONFIG_PATH_LOCKS: Dict[str, str] = { + "tunnel.enabled": "tunnel", + "telemetry.otlp_endpoint": "telemetry.otlp_hosts", +} + +_logged: "set[str]" = set() +_logged_lock = threading.Lock() + + +def local_enable_locked(control: str) -> bool: + """True when this build ignores the local switch for *control*. + + Raises ``KeyError`` for a control not in :data:`UNKEYED_LOCKS`, so a misspelled + id fails in tests instead of silently reading as unlocked. + """ + reason = UNKEYED_LOCKS[control] + from kiro_crew.edition import is_enterprise + + if not is_enterprise(): + return False + with _logged_lock: + first = control not in _logged + _logged.add(control) + if first: + logger.warning("enterprise build: %s (%s)", reason, LOCKED_BY_POLICY) + return True + + +def locked_controls() -> "tuple[str, ...]": + """Every control this build locks, for a setting API to report. Empty on oss.""" + from kiro_crew.edition import is_enterprise + + return tuple(sorted(UNKEYED_LOCKS)) if is_enterprise() else () + + +def config_path_locked(path: str) -> bool: + """True when this build ignores the local value at config *path*. Never logs.""" + from kiro_crew.edition import is_enterprise + + return path in CONFIG_PATH_LOCKS and is_enterprise() + + +def reset_logged() -> None: + """Forget which locks have logged. Test helper.""" + with _logged_lock: + _logged.clear() diff --git a/src/kiro_crew/metrics/provider.py b/src/kiro_crew/metrics/provider.py index b72b11bbde6..e32babceef4 100644 --- a/src/kiro_crew/metrics/provider.py +++ b/src/kiro_crew/metrics/provider.py @@ -32,6 +32,7 @@ from __future__ import annotations import asyncio +import dataclasses import importlib.util import logging import os @@ -1056,7 +1057,28 @@ def otlp_egress_active(cfg: "TelemetryConfig") -> bool: resolve = getattr(provider, "otlp_destinations", None) if resolve is None: return False - return bool(_filter_metric_destinations(resolve(cfg))) + locked_cfg = _edition_locked_view(cfg) + if locked_cfg is None: + return False + return bool(_filter_metric_destinations(resolve(locked_cfg))) + + +def _edition_locked_view(cfg: "TelemetryConfig") -> "Optional[TelemetryConfig]": + """*cfg* as a provider may read it in this build, or ``None`` for no egress at all. + + An enterprise build blanks ``otlp_endpoint`` (:mod:`kiro_crew.edition_locks`, + RFC D3): no governance key names an OTLP host yet, so no remote rule allowed the + local value. Done here, before any provider sees it, so an edition's own + provider cannot read the local endpoint either; destinations it declares itself + stay. A config this cannot copy answers ``None`` rather than pass the value on. + """ + from kiro_crew.edition_locks import local_enable_locked + + if not getattr(cfg, "otlp_endpoint", "") or not local_enable_locked("telemetry.otlp_hosts"): + return cfg + if not dataclasses.is_dataclass(cfg) or isinstance(cfg, type): + return None + return dataclasses.replace(cfg, otlp_endpoint="") #: A process that does not install a platform context names where its telemetry @@ -1128,7 +1150,15 @@ def _otlp_destinations(cfg: "TelemetryConfig") -> "tuple[OtlpDestination, ...]": Lenient by design, unlike :func:`otlp_egress_active`: on this path an unresolvable provider must not export, so it contributes nothing and collection continues local-only. + + An enterprise build hands every provider a blank ``otlp_endpoint`` + (:mod:`kiro_crew.edition_locks`, RFC D3): the local value names no host a + remote rule allowed. Destinations the edition's own provider declares stay. """ + locked_cfg = _edition_locked_view(cfg) + if locked_cfg is None: + return () + cfg = locked_cfg try: supplied = safe_context_call( lambda: _telemetry_provider().otlp_destinations(cfg), # type: ignore[attr-defined] diff --git a/src/kiro_crew/platform/update_layout.py b/src/kiro_crew/platform/update_layout.py index 945251cb2d2..528acf4ca40 100644 --- a/src/kiro_crew/platform/update_layout.py +++ b/src/kiro_crew/platform/update_layout.py @@ -309,7 +309,14 @@ def release_channel() -> str: recovery breadcrumb and re-runs the leftover-archive sweep, which can ``shutil.rmtree``. Doing that on the event loop as a side effect of asking where a directory is is the blocking hazard this avoids. + + An enterprise build follows ``stable`` whatever the file says + (:mod:`kiro_crew.edition_locks`, RFC D3). """ + from kiro_crew.edition_locks import local_enable_locked + + if local_enable_locked("updates.channel"): + return "stable" try: raw = (data_home() / "channel").read_text(encoding="utf-8", errors="replace") except OSError: @@ -373,9 +380,7 @@ def cdn_bases_are_safe() -> bool: unattended path cannot drift apart on what they consider safe. """ feed_base, artifact_base = cdn_bases() - return bool( - _SAFE_CDN_BASE_RE.match(feed_base) and _SAFE_CDN_BASE_RE.match(artifact_base) - ) + return bool(_SAFE_CDN_BASE_RE.match(feed_base) and _SAFE_CDN_BASE_RE.match(artifact_base)) def wheel_update_command(channel: str | None = None) -> str: @@ -416,7 +421,7 @@ def wheel_update_command(channel: str | None = None) -> str: # An empty body would let sh exit 0 on nothing at all, which is the same # false success as the piped form. 'test -n "$_kc_body"; ' - f'printf \'%s\\n\' "$_kc_body" | sh -s -- --channel {channel}' + f"printf '%s\\n' \"$_kc_body\" | sh -s -- --channel {channel}" ) diff --git a/test/test_computer_use_api.py b/test/test_computer_use_api.py index 22056a8a597..1b50efba890 100644 --- a/test/test_computer_use_api.py +++ b/test/test_computer_use_api.py @@ -796,7 +796,7 @@ def test_no_refusal_in_this_module_is_prose_only(self) -> None: def test_the_ratchet_can_actually_fail(self) -> None: """Self-check: a scan matching nothing would pass the assertion above vacuously.""" coded = [f for f in self._findings() if f.bucket == "compliant"] - assert len(coded) == 20, f"scanner reached {len(coded)} coded sites, expected 20" + assert len(coded) == 21, f"scanner reached {len(coded)} coded sites, expected 21" assert all(f.code_value for f in coded) # -- PUT /api/computer-use/config -- diff --git a/test/test_dashboard_server_composition_contract.py b/test/test_dashboard_server_composition_contract.py index 91d1aab0fd3..4a234eb47fc 100644 --- a/test/test_dashboard_server_composition_contract.py +++ b/test/test_dashboard_server_composition_contract.py @@ -392,6 +392,7 @@ def test_the_shared_state_keeps_its_identity() -> None: "kiro_crew.config.resolution": "DEGRADED_WHOLE_CONFIG", "kiro_crew.connections.warm": "scavenge_warm_mint_artifacts shutdown_warm_mint", "kiro_crew.crew_log": "crew_log_emit", + "kiro_crew.edition_locks": "local_enable_locked", "kiro_crew.dashboard": "display_verdicts", "kiro_crew.dashboard.chat": "_run_chat", "kiro_crew.dashboard.handlers": "wf_handlers work_ledger", @@ -456,7 +457,7 @@ def test_the_lazy_imports_stay_inside_the_functions_that_need_them() -> None: for alias in node.names: local.setdefault(alias.asname or alias.name, set()).add(alias.name) assert local == {name: {module} for name, module in expected.items()} - assert len(expected) == 92 + assert len(expected) == 93 def test_a_star_import_carries_the_moved_public_names(tmp_path: Path) -> None: diff --git a/test/test_enterprise_unkeyed_locks.py b/test/test_enterprise_unkeyed_locks.py new file mode 100644 index 00000000000..486ef46b617 --- /dev/null +++ b/test/test_enterprise_unkeyed_locks.py @@ -0,0 +1,182 @@ +"""Enterprise build: local switches for controls with no governance key are ignored. + +RFC ``rfc-crew-enterprise-edition`` decision D3. Each control in +``kiro_crew.edition_locks.UNKEYED_LOCKS`` is checked at the point its feature reads +its own switch: on, in the ``oss`` edition, the local value still applies. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from types import SimpleNamespace +from unittest.mock import DEFAULT, MagicMock + +import pytest + +from kiro_crew import edition_locks +from kiro_crew.apps import dev_mode +from kiro_crew.computer_use import enable_state +from kiro_crew.config.sections_telemetry import TelemetryConfig +from kiro_crew.dashboard.handlers import updates +from kiro_crew.dashboard.handlers.agents import api_config_schema +from kiro_crew.metrics import provider as metrics_provider +from kiro_crew.platform import update_layout +from kiro_crew.platform.defaults import DefaultTelemetryProvider + + +@pytest.fixture(autouse=True) +def _fresh_lock_log(): + edition_locks.reset_logged() + yield + edition_locks.reset_logged() + + +@pytest.fixture +def enterprise(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("kiro_crew.edition.EDITION", "enterprise") + + +@pytest.fixture +def channel_file(tmp_path, monkeypatch: pytest.MonkeyPatch): + (tmp_path / "channel").write_text("nightly\n", encoding="utf-8") + monkeypatch.setattr(update_layout, "data_home", lambda: tmp_path) + + +def test_every_lock_id_is_listed(): + with pytest.raises(KeyError): + edition_locks.local_enable_locked("no-such-control") + + +def test_oss_locks_nothing(): + assert not any(edition_locks.local_enable_locked(c) for c in edition_locks.UNKEYED_LOCKS) + assert edition_locks.locked_controls() == () + assert not edition_locks.config_path_locked("tunnel.enabled") + + +def test_enterprise_lock_logs_once(enterprise, caplog): + with caplog.at_level(logging.WARNING, logger="kiro_crew.edition_locks"): + assert edition_locks.local_enable_locked("tunnel") + assert edition_locks.local_enable_locked("tunnel") + assert [r.getMessage() for r in caplog.records].count( + f"enterprise build: {edition_locks.UNKEYED_LOCKS['tunnel']} " + f"({edition_locks.LOCKED_BY_POLICY})" + ) == 1 + + +def test_config_paths_name_listed_controls(): + assert set(edition_locks.CONFIG_PATH_LOCKS.values()) <= set(edition_locks.UNKEYED_LOCKS) + + +def test_enterprise_reports_locked_config_paths(enterprise): + assert edition_locks.config_path_locked("tunnel.enabled") + assert edition_locks.config_path_locked("telemetry.otlp_endpoint") + assert not edition_locks.config_path_locked("telemetry.enabled") + + +def test_release_channel_file_is_ignored(enterprise, channel_file): + assert update_layout.release_channel() == "stable" + + +def test_oss_release_channel_file_still_applies(channel_file): + assert update_layout.release_channel() == "nightly" + + +def test_dev_mode_enable_is_refused(enterprise): + result = dev_mode.set_dev_mode("some-app", True) + assert result == {"error": edition_locks.LOCKED_BY_POLICY, "code": "locked_by_policy"} + + +def test_dev_mode_metadata_is_ignored(enterprise, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(dev_mode, "_read_installed", lambda name: SimpleNamespace(dev=True)) + monkeypatch.setattr(dev_mode, "_read_dev_sentinel", lambda: {"some-app"}) + dev_mode._set_dev_cache({"some-app"}) + try: + assert dev_mode.is_dev_mode("some-app") is False + assert dev_mode.is_dev_mode_cached("some-app") is False + assert dev_mode._load_dev_apps() == set() + finally: + dev_mode._set_dev_cache(set()) + + +def test_oss_dev_mode_metadata_still_applies(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(dev_mode, "_read_installed", lambda name: SimpleNamespace(dev=True)) + assert dev_mode.is_dev_mode("some-app") is True + + +def test_computer_use_opt_in_is_ignored(enterprise): + assert enable_state.is_enabled({"enabled": True}) is False + + +def test_oss_computer_use_opt_in_still_applies(): + assert enable_state.is_enabled({"enabled": True}) is True + + +@pytest.fixture +def default_telemetry(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(metrics_provider, "_telemetry_provider", DefaultTelemetryProvider) + return TelemetryConfig(otlp_endpoint="http://collector.example:4318/v1/metrics") + + +def test_otlp_endpoint_is_ignored_before_any_provider_reads_it(enterprise, default_telemetry): + assert metrics_provider._otlp_destinations(default_telemetry) == () + assert metrics_provider.otlp_egress_active(default_telemetry) is False + + +def test_oss_otlp_endpoint_still_applies(default_telemetry): + assert len(metrics_provider._otlp_destinations(default_telemetry)) == 1 + assert metrics_provider.otlp_egress_active(default_telemetry) is True + + +def _owner_request(body: object): + """A request stub the owner-gated update route accepts (same shape as its own tests).""" + req = MagicMock() + + async def _json() -> object: + return body + + req.json = _json + state = MagicMock() + state.owner_id = "" + req.app = {"state": state} + claims = {"app": "", "user": "local-app"} + req.__contains__.side_effect = lambda key: key in claims + req.__getitem__.side_effect = lambda key: claims[key] if key in claims else DEFAULT + req.get.side_effect = lambda key, *default: claims[key] if key in claims else DEFAULT + return req + + +def test_channel_switch_api_answers_locked_and_writes_nothing(enterprise, tmp_path, monkeypatch): + + from kiro_crew.platform.update_layout import InstallLayout + + monkeypatch.setattr(update_layout, "data_home", lambda: tmp_path) + monkeypatch.setattr(updates, "resolve_provider", lambda: None) + wheel = InstallLayout( + kind="wheel", proj="", is_git=False, is_externally_managed=False, guidance="" + ) + monkeypatch.setattr(updates, "detect_install_layout", lambda: wheel) + resp = asyncio.run(updates.api_update_channel(_owner_request({"channel": "nightly"}))) + assert resp.status == 409 + assert json.loads(resp.body)["code"] == "locked_by_policy" + assert not (tmp_path / "channel").exists() + + +def test_cli_dev_mode_refusal_is_audited(enterprise, monkeypatch: pytest.MonkeyPatch): + calls = [] + monkeypatch.setattr( + dev_mode, "sel", lambda: SimpleNamespace(log_api_access=lambda **kw: calls.append(kw)) + ) + dev_mode.set_dev_mode("some-app", True) + assert [(c["operation"], c["outcome"], c["error"]) for c in calls] == [ + ("app_dev_mode", "denied", "locked_by_policy") + ] + + +def test_schema_marks_locked_config_paths(enterprise): + req = MagicMock() + req.query = {} + entries = json.loads(asyncio.run(api_config_schema(req)).body)["entries"] + locked = {e["path"] for e in entries if e.get("lockedByPolicy")} + assert locked == set(edition_locks.CONFIG_PATH_LOCKS)