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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/black-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/system-specs/modules/computer-use.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 15 additions & 1 deletion docs/system-specs/modules/platform-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(<id>)` 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

Expand Down
31 changes: 20 additions & 11 deletions src/kiro_crew/apps/dev_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -450,19 +463,15 @@ 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
# sensitive leaves (`~/.docker` is not itself on the list —
# 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 "
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]:
Expand Down
4 changes: 4 additions & 0 deletions src/kiro_crew/apps/routes_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
7 changes: 7 additions & 0 deletions src/kiro_crew/computer_use/enable_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/kiro_crew/dashboard/handlers/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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})
Expand Down
7 changes: 7 additions & 0 deletions src/kiro_crew/dashboard/handlers/computer_use.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions src/kiro_crew/dashboard/handlers/updates_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/kiro_crew/dashboard/server_runtime/tunnel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
84 changes: 84 additions & 0 deletions src/kiro_crew/edition_locks.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading