Platform Context (Composed Platform Providers)

September 5, 2026 · View on GitHub

The kiro_crew.platform package defines the Composed Platform Providers (CPP) contract: the seam that lets one core serve both the open-source edition and an enterprise companion without the core ever importing enterprise-specific code.

Authoring note: KiroCrew is the public edition of this seam. The daily de-branding content sync from the upstream authoring home strips the enterprise-tinted Defaults (e.g. the internal git host, .midway sandbox dirs) down to the public baseline; the enterprise companion re-adds them via overrides. The contract (interfaces + consumption-site wiring) is generic core infrastructure and survives the sync.

Model

The core defines a set of extension points — interfaces where behavior differs between editions — and ships a Default* adapter for each that reproduces today's KiroCrew behavior. An enterprise companion package (module separate from kiro_crew) depends on the public wheel and supplies enterprise adapters for the same interfaces.

The dependency runs one way: the companion depends on the core; the core never depends on the companion. Because the core ships a default for every interface, the public edition is complete standalone.

PlatformContext

kiro_crew.platform.context.PlatformContext is an immutable dataclass holding the chosen adapter for every extension point, plus four carriers. Boot installs the initial context once; a validated central-governance refresh replaces the active context only to carry a new governance value. policy_distribution.apply_ceiling performs that replacement through set_context, and governance_generation() makes dependent profile snapshots refresh rather than serving a profile composed against a retired ceiling:

FieldKindDefault adapterCompanion supplies
contract_versioncarrier (int)CONTRACT_VERSIONmust match core
profilecarrier (str)"standalone""enterprise"
cfgcarrier (KiroCrewConfig)loaded configsame
providersadapterDefaultProviderRegistry (Kiro-CLI-ACP only)re-registers a companion-registered backend
publishadapterDefaultPublishRegistry (registers no provider → publish unavailable)registers enterprise artifact/publish providers
agent_runtimeadapterDefaultAgentRuntime (run_first_run_setup wired; managed_mcp_servers RESERVED)extra one-time first-run provisioning
agent_executableadapterDefaultAgentExecutableResolver (identity)resolves an edition-managed launcher to its direct executable before core sandboxing
sandboxsettingsDefaultSandboxPolicy (_STRICT_DIRS/_CC_DIRS)additional edition-specific credential dirs
credentialsadapterDefaultCredentialPolicy (AKIA/ASIA redaction; exempt_exact_hosts()frozenset())internal token regexes + trusted-tenant exempt hosts
securityconcretePolicyAuthority() (baseline only)PolicyAuthority(overlay=…) ADD-only
governanceconcrete carrierload_security_policy() result or Nonebundled Level-1 ceiling
slack_gateadapterDefaultSlackEnterpriseGate (default-open)fail-closed enterprise allowlist
identityadapterDefaultIdentityProvider (sso_status.py stub; whoami/issuer RESERVED)enterprise SSO / directory
agent_identityadapterDefaultAgentIdentityProvider (disabled: enabled() -> False; no workload, no Gateway spec, no tokens). Distinct from operator-SSO identity. IdentityProvider.whoami / issuer stay RESERVED and are not consumed to satisfy this seam. A later stack PR may swap this adapter when an optional extra opts in; this PR composes only the Default.edition workload identity + token vending
embeddingsadapterRESERVEDDefaultEmbeddingSource; the public runtime is the bundled in-process llama-cpp model, so no method is consumed (swap via embeddings.register_embedding_backend)— (slot inert)
mcp_toolingadapterDefaultMcpToolingProvider (all methods empty)enterprise MCP server + skills + provider MCP scopes
agent_catalogadapterDefaultAgentCatalogProvider (builtin_agents()[])edition agent-catalog rows
prompt_sourcesadapterDefaultPromptSourceProvider (prompt_source_roots()[])edition prompt/SOP roots
skill_discoveryadapterDefaultSkillDiscoveryProvider (skill_providers()[])edition skill discovery providers for the multi-provider search
tipsadapterDefaultTipsProvider (tips_pool()None)the edition's feature-tip pool. The one REPLACE-capable seam: a supplied TipsPool takes over from the public curated file AND the docs-scan catalog instead of being unioned into them, because a public tip advertises a capability an edition build may not have or may not expose. An empty pool means "this build shows no tips"
denied_rulesadapterDefaultDeniedRuleProvider (denied_rules()[])edition denied-command rules that are default-on but USER-DISABLEABLE (distinct from security, the un-weakenable overlay floor)
import_sourcesadapterDefaultImportSourceProvider (import_sources()[])edition onboarding-import sources
capability_manageradapterDefaultCapabilityManager (available()False)operations-based external package manager: MCP servers, skills, agent packages, and client plugins
external_accessadapterDefaultExternalAccessPolicy (admits_registry() / admits_cloud_deployment()True)allowlist installable content to an internal registry; withhold cloud deployment
registryadapterDefaultAppRegistryPolicy (public-forge baseline)internal git hosts
apps_loaderadapterDefaultAppsLoader (OSS builtins)internal app sources (code-reviewer; team_manager/mimir follow-on)
package_manageradapterRESERVEDDefaultPackageManager; installs are inline in cli_doctor.py (use CapabilityManager)— (slot inert)
knowledgeadapterDefaultKnowledgeProvider (no extra connectors)enterprise doc connector (extra_connectors)
tunneladapterDefaultTunnelProvider (no-op)internal tunnel supervisor
telemetryadapterDefaultTelemetryProvider (no-op, RUM off; OTLP destination from telemetry.otlp_endpoint)RUM/Cognito config + its own OTLP collector
dashboardadapterDefaultDashboardContributor (no routes/services, no login handler)secretary/taskkeeper routes + enterprise SSO PTY login
jailadapterDefaultJailProvider (no-op, never jails)enterprise process isolation
mobile_connectadapterDefaultMobileConnectProvider (personal-install pair: tailnet_qr + login_link (id == kind by design))edition-specific phone-connection methods (descriptor-only {id, kind}; minting stays on each method's own endpoint; an empty list hides the dashboard entry; list + mint governed by capabilities.mobile_connect)
feature_appstupleRESERVED(); apps register via apps_loader (provenance record only)— (slot inert)

external_access note — three surfaces the core offers unconditionally, none of which had a composition point. Two are installable-content registries: skill discovery (skills.sh) and MCP server discovery (the official registry) hardcoded their public provider at registration time, so a managed deployment could not restrict where installable code came from without patching the core. The third is cloud deployment: kiro_crew/deploy/ provisions S3, CloudFront, IAM roles and a reaper Lambda in the operator's own account and carried no capability gate at all — capabilities.publish, which bounds publish-provider destinations, does not reach it.

admits_registry(kind, name, api_base) is consulted in both _build_registry() functions; a refused provider is never registered, so it is ABSENT rather than failing per request and no later install path is left to gate. admits_cloud_deployment(target) is consulted by deploy/handlers.py: the read at GET /api/deploy/config reports cloudDeploymentEnabled so the frontend hides the console instead of rendering one whose every button 403s, and every mutating route is wrapped at registration so a new endpoint is gated by being listed rather than by remembering an in-handler check. Read endpoints stay open deliberately — a 403 on config would leave the page unable to explain itself.

Both decisions take the concrete target as well as a label, because a name is self-chosen while the URL or target determines where bytes go; an allowlist pinned to the target stops admitting a provider that repoints at a different host. _shared.py::admits_registry / admits_cloud_deployment are the single call points: they deny on a composed-adapter error (reaching that fallback means an operator intended to restrict something), let PlatformCompositionError propagate, and SEL-audit both outcomes — a log carrying only denials cannot show whether the permitted path was ever taken.

registry note — the public DefaultAppRegistryPolicy encodes the public-forge baseline and ships no internal-host set. The enterprise companion re-adds the internal git host (and any further internal git hosts) via its own override.

Core code reads adapters directly when it has the context, or via current_context() for module-level functions (e.g. hooks.py deny path). current_context() lazily builds the standalone default if boot has not run.

installed_context() returns the INSTALLED context or None as a bare attribute read — it never resolves, never raises, and does no I/O. Use it ONLY where the answer for "no context" is already the conservative one, because it skips the config load and entry-point discovery that current_context() performs on every call while unbooted. A caller that must honour a companion's policy has to go through current_context() and take the fail-closed PlatformCompositionError.

Whether the no-context answer is conservative is a property of the CALLER, so the accessor cannot check it and this paragraph cannot enforce it — while it was the only guard, the caller set grew from one to three and the sentence here still said "the one such caller". Every call site is therefore declared in context.PEEK_CALLERS with that reason, and test_platform_cpp_seam_coverage.py fails the build on an undeclared peek AND on an entry whose call site is gone. Today's three: the exempt-host lookup (below), redact_log_via_context (above), and governance.active_policy_distribution. The gate is a review forcing function, not a proof — it cannot verify that a written justification is true, only that one was written where a reviewer will read it.

Boot sequence

cfg = KiroCrewConfig.load()
ctx = boot_platform(cfg)      # platform/bootstrap.py (idempotent)

boot_platform is the single idempotent entry point — cli.main and run_gateway both call it; only the first call resolves the profile and installs the context. bootstrap_context:

  1. build_default_context(cfg, profile=resolve_profile(...)) composes all Default* adapters and selects the applicable Level-1 governance ceiling through load_security_policy.
  2. If profile != standalone: discover_companion_context (fail-closed), then validate contract_version and the ADD-only security floor.
  3. assert_governance_paths_protected, assert_policy_signature_satisfied, and assert_profiles_within_ceiling validate the final context before set_context; these gates prevent an agent-writable trust root, an absent required signature, or a profile looser than its ceiling from becoming active.
  4. ctx.providers.register_acp_backends() once (Default no-op).
  5. ctx.publish.register_publish_providers() once (Default registers the personal cloud drive under its OWN key, PERSONAL_DRIVE_PROVIDER, so the destination is listed and selectable but does NOT capture the unnamed default — a publish that names no destination still resolves DEFAULT_PROVIDER, which stays unregistered, and gets a 503; a companion edition registers its own destination instead).

Profile resolution

resolve_profile(cfg, *, entry_points) precedence (first match wins):

  1. KIROCREW_PROFILE env (standalone | enterprise; unknown → standalone).
  2. Non-empty kirocrew.plugins entry-point group (companion installed).
  3. Identity signal: a present ~/.midway directory (a cheap stat, no subprocess) — only when the opt-in KIROCREW_MIDWAY_PROFILE_PROBE env var is truthy. OFF by default so a stray ~/.midway left by some other tool cannot force the public edition into the enterprise profile (which has no companion to compose and would fail-closed at boot, bricking every command). The companion's managed launcher sets KIROCREW_MIDWAY_PROFILE_PROBE=1.
  4. Otherwise standalone.

The profile is a load trigger, not a security decision: capability comes from the installed companion, so a spoofed signal at worst loads a stricter posture on a host that has nothing to enforce it. The core does NOT spawn a whoami subprocess — entry-point presence + the opt-in ~/.midway stat cover the trigger cases; the companion's own identity provider refines the principal once loaded.

Fail-closed discovery

discover_companion_context (only for non-standalone profiles) looks up the kirocrew.plugins entry-point group via importlib.metadata:

  • Empty → raise PlatformCompositionError (refuse to boot with OSS defaults).
  • More than one → raise (ambiguous).
  • Loads the single entry point (build_enterprise_context) and returns its context.

bootstrap_context then asserts contract_version match and runs assert_security_floor before installing the companion context.

Level-1 governance ceiling and distribution

PlatformContext.governance is the optional Level-1 GovernanceCeiling that enforcement chokepoints read through current_context(). governance.load_security_policy selects the first available source: an explicit local policy, a centrally distributed policy, a companion-bundled policy, then the data-home policy; no source leaves editable standalone defaults. The local source remains first so an operator can roll back a bad fleet-wide publication without waiting for the central control plane.

policy_distribution.resolve_distribution accepts the central source from fleet environment settings or the distribution declaration of an already-selected lower-tier policy, with environment settings taking precedence individually. A declared distribution source cannot carry credentials; request headers remain host-local and cache_only() prevents child processes from receiving the means to contact the fleet control plane.

governance._parse_controls rejects every unknown governed key, including an unrecognised sandbox child. Only documented non-governed sandbox flags are accepted in the reserved internal scope. This fails closed instead of recording a misspelled sandbox floor as a valid but unenforced policy control.

policy_distribution.load_distributed_policy serves a validated last-known-good cache when the source is unavailable, and its unavailable disposition decides the no-cache case. refresh_now rejects an invalid live candidate and retains the running ceiling; validate_ceiling applies the same signature and profile-floor checks as installation before apply_ceiling replaces the active context. This asymmetry is load-bearing: a transient outage cannot remove governance, and a malformed central update cannot weaken or interrupt a governed running host.

The cache is covered by assert_governance_paths_protected and is therefore part of the sensitive-path trust root: write access would let an agent substitute both policy bytes and provenance. register_policy_fetcher is append-only, so an edition can add a transport without shadowing a built-in fetcher or bypassing shared validation and cache handling. distribution_posture exposes operational state without returning the control-plane URL or request credentials. governance_profiles.ProfileStore._ceiling_token includes governance_generation() so a profile snapshot is never reused after apply_ceiling installs a replacement ceiling.

ADD-only security floor

PolicyAuthority (concrete class in security_authority.py) is the deny-floor authority. The invariant — a companion may add deny patterns but never remove or weaken the floor — is enforced structurally:

  • is_denied and effective_patterns are @final. No subclass overrides the decision or the union construction.
  • The only override surface is the SecurityOverlay Protocol, whose extra_deny_patterns() is concatenated to BASELINE_DENY. There is no method anywhere that subtracts from that union.
  • assert_security_floor(authority) (run at boot) verifies the authority is a PolicyAuthority and that it has not overridden the @final decision methods; it also keeps a (now-vacuous) effective set ⊇ BASELINE_DENY superset check so a future non-empty static floor is auto-enforced. A weakening companion fails composition and boot aborts.
  • The actual evaluation (two-pass, git-publish verb anchoring, SEL audit) is reused verbatim from security.is_denied via the extra_patterns parameter.

BASELINE_DENY is () — the floor definition. The built-in denied-command patterns are default-ON but user-DISABLEABLE (Settings → Security; see security.md), so they can no longer be an unconditional compiled BASELINE_DENY = tuple(security.BUILTIN_DENY_PATTERNS) — that would re-apply every built-in inside PolicyAuthority.is_denied and make user opt-out inert. BASELINE_DENY therefore narrows to the empty tuple: the static, un-weakenable OSS floor is now empty. The un-opt-out-able floor is supplied dynamically by (a) the companion's ADD-only SecurityOverlay (structurally un-removable via the @final union) and (b) the governance commands-scope pins (resolve_pinned_commands, applied tightest-wins in hooks.py — see governance.md). The disableable built-in rules ride in through the resolved effective set (denied_regexes): the hooks layer computes compute_effective_denied(...) and passes it into current_context().security.is_denied(target, extra_patterns=…, denied_regexes=…). The always-on keystone denials that are NOT rule-toggleable (git-publish / protected-branch, exfiltration shapes, sensitive-path) run unconditionally inside security.is_denied, independent of the tiers. A user opt-out of a built-in is orthogonal to — and can never weaken — the companion overlay or the governance ceiling: the overlay travels via extra_patterns and is never routed through the opt-out, and a governance pin re-adds a rule the user disabled.

The enforcement hot path (hooks.py tool-deny) reads current_context().security.is_denied(target, extra_patterns=…, denied_regexes=…), passing the resolved effective denied-command set (enabled built-ins ∪ user user_added, with governance-pinned ids force-re-added) as denied_regexes, on top of the companion overlay (glob tier) and the redefined empty baseline. A standalone install with no opt-out and an empty overlay resolves to today's full built-in list → behavior identical for the default install.

ADD-only constrains the contract boundary (a plugin/companion). It does not constrain a user who edits the open source. For managed fleets, the enforced controls live at the device/fleet layer (out of scope here).

Plugin admission control

The structural gates above reject a plugin for being wrong (no plugin, bad contract version, weakened floor). Plugin admission (admission.py) is the policy layer that lets a managed fleet reject a plugin for not being trusted — the control surface for a plugin marketplace and a ban capability. It runs inside discover_companion_context before ep.load() (verify-before-run), so a rejected plugin's code never executes.

Defense in depth, evaluated by evaluate_admission(ep, policy):

  1. Kill-switch (banned) — a fleet bans a plugin by name; the ban always wins, in any mode (R-08 / M-09 remote-disable).
  2. Marketplace allowlist (approved) — when present, only listed plugins are admitted. Adding a plugin to the list is the marketplace review gate.
  3. Verify-before-run signature (require_signature) — the plugin ships a signed kirocrew_plugin.json manifest; admission verifies the signature against a trust key the policy carries (R-11 / M-12 supply chain). POC uses HMAC; production uses an asymmetric publisher key. The signature covers a canonical payload (name/publisher/version/capabilities), so tampering with declared capabilities invalidates it.
  4. Capability ceiling (capability_ceiling) — the manifest declares requested capabilities (tools, egress, credential paths); admission rejects a plugin whose declared capabilities exceed the fleet ceiling, or that requests a capability category the fleet doesn't grant at all.

Trust-root invariant: the policy loads from a fleet-controlled source (KIROCREW_ADMISSION_POLICY env path, else ~/.kiro/crew/admission_policy.json), never from the plugin — a plugin cannot approve, sign, or un-ban itself. The manifest is read import-free from the plugin's installed distribution files, so plugin code never runs before the decision.

Default-open / fail-closed: the public edition ships no policy → admit everything (standalone unchanged). A present-but-unreadable policy fails closed (enforce + signature + empty allowlist = admit nothing). A rejected plugin raises PluginAdmissionError (a PlatformCompositionError), aborting boot.

Policy shape (admission_policy.json):

{
  "mode": "enforce",
  "require_signature": true,
  "require_policy_signature": true,
  "trust_keys": {"p13n": "<publisher key>", "fleet-control": "<issuer key>"},
  "approved": ["enterprise"],
  "banned": ["some-rogue-plugin"],
  "capability_ceiling": {"egress": ["*.example.com"], "tools": ["enterprise-mcp"]}
}

This policy is also the trust root for the security ceiling. require_policy_signature (default false) additionally demands a verified identity.signature on security_policy.json, keyed by that document's identity.issuer in the same trust_keys map — one key store, not two. It is a separate flag from require_signature on purpose: a fleet that signs its plugins has not thereby promised to sign its governance ceiling, and conflating them would break managed fleets on upgrade. The flag lives here rather than inside the security policy because a document cannot be the authority on whether it must be authentic. canonical_signing_bytes / hmac_signature are shared by both checks so the two trust roots cannot drift apart. The governance loader reads these two fields through read_policy_trust_root() — a side-effect-free reader that records no posture and emits no SEL, because unlike load_admission_policy (once per process at boot) it runs on a repeating path. See governance.md → "Policy authenticity".

What admission does NOT do: it gates the plugin contract boundary, not a source-editing user. For a managed fleet the enforced root of trust is the signed, fleet-distributed policy + the device layer; admission is the in-process enforcement point that consumes them.

Contract versioning

CONTRACT_VERSION bumps on any field add/rename or interface-semantics change. A companion built against a different version refuses to compose. Because the companion's build_enterprise_context starts from build_default_context and only dataclasses.replaces the fields it overrides, any extension point the core later adds is inherited at its default until the companion writes an override.

Pinned at 1 pre-launch. There is no shipped release yet and the companion is rebuilt in lockstep with the core from the same source, so the composition-time mismatch guard always compares 1 == 1. Bumping per-field would only churn the seam without protecting any deployed companion. Every seam added pre-launch landed under this same 1, with no bump:

  • the governance carrier (the enterprise security ceiling);
  • the agent_executable resolver (edition-neutral direct-executable resolution before the core applies its sandbox);
  • the knowledge (connector registry), dashboard (route/service/login-handler contributor), and jail (process-isolation) extension points;
  • the agent_identity slot (AgentIdentityProvider — agent workload identity and token vending, distinct from operator-SSO identity);
  • wiring an existing but previously-unconsumed Protocol method into a call site (e.g. ProviderRegistry.create_factory going live, AppsLoader bundling feature apps) — no shape change, so no bump regardless;
  • adding TunnelProvider.register_callbacks / status_snapshot when the tunnel lifecycle was routed through the seam — a v1 method addition to an existing Protocol.

Start incrementing only after the first public release, when a separately-built companion can pin against a frozen contract.

Companion packaging

The companion declares (in its pyproject.toml):

[project.entry-points."kirocrew.plugins"]
enterprise = "kirocrew_enterprise.compose:build_enterprise_context"
[project.scripts]
kirocrew-enterprise = "kirocrew_enterprise.cli:main"
dependencies = ["kirocrew"]

The kirocrew-enterprise binary sets KIROCREW_PROFILE=enterprise and delegates to the core main — the explicit composition-root path that a security review reads.

Distribution build version

A distribution that repackages one core release as several builds of its own (an enterprise bundle vending 0.6.0.10, 0.6.0.11, … of the same 0.6.0) names the build it shipped in a BUILD_VERSION file placed beside kiro_crew/__init__.py at packaging time (declared as optional package data; absent in this repo and in the wheels it publishes). kiro_crew/__init__ resolves it at import, replacing __version__ for every reader — the About page's version chip (version_display), /api/status, /api/health, kirocrew --version, the diagnostics report, and the governance minimum-version floor (update_required), which a fleet writes in the distribution's build numbers. Two readers deliberately do not change: the anonymous usage beacon (beacon.release clamps to major.minor.patch for cardinality, so a stamped build sends the same value as its base), and the About page's changelog, where changelog.running_release folds the stamp back onto its three-segment release so the build marks the 0.6.0 row rather than opening an empty 0.6.0.12 row. Import time is the only point that works: dashboard/handlers/updates.py, dashboard/ws.py, config/loader.py and cli_server.py bind from kiro_crew import __version__ copies that nothing later can reach, so this is deliberately not a PlatformContext field — a context is composed after those imports.

A file in the package rather than an environment variable, on purpose: the version feeds an enterprise ceiling (the minimum-version floor) that the local operator must not be able to weaken, and an env var is theirs to set. The file sits in the same site-packages tree as the code and carries exactly its trust — whoever can write it can write __init__.py — so it adds no bypass the bytes did not already have, and it is the same model the release lanes use when they rewrite the version literal for a nightly or insider build. Being in the bytes, it is also what a shadow-venv probe or any fresh interpreter naturally reports.

Fail-closed on shape: only the core base itself, or the base followed by exactly one .N ASCII-numeric segment over a bare numeric base, is honoured (the read is capped, and an oversized, undecodable or unreadable file falls back to the base). A different base, a prerelease stamp, a non-numeric or second segment, or a suffix over an rc / nightly base is a build this core is not; it is ignored with one warning and the base stays, so a process can never claim bytes it does not run. The contract is the file — its name and that shape — not a Python API; the helpers in kiro_crew/__init__ are private. The accepted shape parses as a PEP 440 release wherever the version is compared (_is_newer orders 0.6.0.12 above 0.6.0 and below 0.6.1; release_channel.channel still reads stable), and base_version — the stable-channel display fold — preserves the stamped string, which is what lets the chip show it.

Update lane. A distribution owns its own update path, normally the command provider selected by check_command / apply_command update pins, which never consults the public release feed. Should a stamped build reach the feed check, channel_move_pending compares by release (changelog.release_of_build folds 0.6.0.12 to 0.6.0), so a stamped build on the lane that shipped its release is on that lane, not permanently "ahead" of it; the update_available verdict needs no fold (0.6.0 is not newer than 0.6.0.12). The wheel's dist-info metadata is not touched by the stamp — pip and importlib.metadata still report the base — so a downstream that builds its own wheel and needs those to differ must bump its own project version as well.

Consumption-site wiring

Core consumption sites read the context rather than the module global they previously used. Standalone behavior is preserved because each Default adapter delegates to that same global. Wired sites:

  • cli.py:main / slack/gateway.py:run_gatewayboot_platform(cfg) once at startup (gateway raises fail-closed; cli is defensive — standalone never raises).
  • slack/gateway.py gateway boot — AgentRuntime.run_first_run_setup() through safe_context_call. Previously this imported agent.run_first_run_setup directly, bypassing the seam; routing it through the context makes first-run provisioning genuinely extensible (an edition adds its own one-time steps). The Default adapter delegates to that same function, so standalone behavior is byte-identical — asserted in test_cpp_wiring_standalone.py (test_default_agent_runtime_delegates_to_agent_first_run_setup + test_gateway_first_run_setup_routes_through_the_seam). Best-effort: the gateway's surrounding except keeps a failure non-fatal to startup, and PlatformCompositionError still propagates fail-closed.
  • sandbox.py_build_launcher_script / _build_seatbelt_profile source the sensitive-dir lists from current_context().sandbox (the .aws-exclusion at the cc branch is preserved). namespace_argv / sandbox_exec_argv resolve argv[0] through current_context().agent_executable before applying the core sandbox. The public Default is identity; a companion may return the direct executable behind an edition-managed launcher to avoid nested isolation, but cannot disable or weaken the outer sandbox. A transient adapter error falls back to the original executable (outer sandbox still applies); a PlatformCompositionError propagates fail-closed.
  • hooks.py — the deny check routes through current_context().security.is_denied; the kiro-hooks egress (dashboard/handlers/hooks.py) scrubs command/matcher through the shared redact_via_context shim.
  • Credential redaction — all egress scrubs route through the single kiro_crew.platform.redact_via_context shim (the one canonical fail-closed-aware shim; modules import it as redact). Covers: agent.py SEL-audit callers, mcp_core.py chat-history/spawn output, mcp_cron.py deny-reason + script-vet + timezone messages, and dashboard/handlers/files.py file-content egress (slot append, file-watch, file_read, download gate) as well as the filename/path/description gates. Standalone is byte-for-byte the prior exfil-then-credential two-pass (the Default CredentialPolicy.redact delegates to security.redact); a loaded companion adds its internal-token regexes uniformly across every egress surface.
  • Gate-side LOG lines (kiro_crew.platform.redact_log_via_context) — the same companion-aware redaction for an operational log or audit line, which unlike an egress sink cannot afford to raise. redact_via_context propagates PlatformCompositionError because refusing to SEND is both safe and the point; a log line is not an output boundary, and there the same raise buys nothing while costing availability. The two no-companion states are treated DIFFERENTLY, and conflating them is the subtle part: a context that IS installed whose policy failed to compose withholds the line's TEXT (LOG_WITHHELD_PLACEHOLDER), because the host is known non-standalone and the baseline would be a real downgrade; whereas NO installed context keeps the baseline, because nothing there evidences a companion and withholding would destroy diagnostics. Transient adapter errors still degrade to baseline (inherited), so a merely flaky companion does not blank the logs. Covers platform/update_provider.py (a failed update command's stderr), task_planner.py (an unparseable LLM response before the diagnostic ERROR line) and name_grant.py (a model-authored tool title before the shared SEL row — the one that PERSISTS rather than rotating out of a log window). Resolves NOTHING: the no-context branch is answered by the bare installed_context() read, whose contract this fits exactly — the no-context answer is the same as the default-context answer, since DefaultCredentialPolicy.redact delegates to security.redact — so a per-line caller never re-pays the unmemoized config load described in the exemption bullet below.
  • mcp_gateway/backend.py deliberately stays on the baseline redactor for its backend stderr and spawn-argv log lines. gatewayd is NOT the composition process — it never runs boot_platform (see the reasoning at mcp_gateway/app_call.py, which relies on the same fact for the security ceiling) — so routing those sites through the context could not reach a companion policy in the first place, and would only trade the baseline for a lazily-composed standalone default resolved per stderr line on the event loop. Closing this residual means handing gatewayd a composed context, the same remedy that module already names for the ceiling, and is a separate change.
  • Which spelling a gate-side log line uses is gated in CI (test_security_posture.TestGateSideLogRedactorSpelling). The egress classification (security_posture._REDACTION_SINKS vs NON_EGRESS_REDACTION_MODULES) is a different axis and cannot cover this one: all three sites converged above sat correctly bucketed as non-egress, with accurate reasons, while reading the companion-blind baseline pass. The gate scans for the PROPERTY — a baseline redactor's result reaching a log or audit write, either nested in the call or through a local assigned from one in the same scope — rather than for a filename or a subject name, because a grep scoped to files mentioning stderr is exactly what missed task_planner.py and name_grant.py. _BASELINE_LOG_SITE_CENSUS records the per-module residual measured when the gate went up (76 sites in 26 modules, acp/client.py the largest at 7); it is a census, NOT an approved-exception list and NOT a to-do list, since for a process that never composes — gatewayd above — the baseline is the honest answer. Growth in any module, or a first site in a module absent from it, fails until someone either calls redact_log_via_context or raises that number and records which PROCESS the site runs in and why the baseline is correct there. A count that has fallen must be lowered, so a converted module does not leave free slots behind.
  • Exfil exact-host heuristic exemption (CredentialPolicy.exempt_exact_hosts()) — security.scan_exfiltration_urls / redact_exfiltration_urls read the companion-supplied exact-host set and, for a URL whose domain is an EXACT member, skip ONLY the base64-blob / query-length heuristics (which false-positive on legitimate long base64 document pointers, e.g. SharePoint :fl: / Loop nav=<base64> links). Narrow-only: the exemption can only relax the heuristics, NEVER the hard-credential floor — the S3-presigned fast-path and the unconditional _HARD_CREDENTIAL_RE path+query scan run FIRST (before the exemption is consulted), so a real AWS key / SSH-or-PEM header / Slack token on an exempted host — including one embedded in the URL PATH — is still flagged and redacted. Matched EXACTLY (not by suffix) so a shared multi-tenant domain does not exempt every tenant. The set is guarded with getattr(policy, "exempt_exact_hosts", None) (a pre-method companion adapter degrades to the empty set) and is NEVER sourced from config.json — an agent-writable exemption would be a hole in the redaction ceiling, so the companion adapter is the only supplier. EVERY failure degrades to frozenset() — the empty set means MORE redaction (every host runs the heuristics), the safe direction, and stricter than any companion-supplied list could be; NO logging on the degrade path (runs inside the stdio MCP servers). The set is read via installed_context(), so a process with no installed context takes that same empty set WITHOUT resolving one: resolving would load config and discover entry points per call, and on a non-standalone profile current_context() never memoizes its fail-closed verdict, so a per-line caller (_pump_stderr redacting backend stderr) would re-pay that synchronous I/O for every line on the gateway event loop. This is deliberately INVERTED vs redact_via_context, which must keep propagating: that seam SUBSTITUTES a companion's redaction for the baseline, so a missing context there would fail OPEN, whereas here it fails STRICTER. Because this lookup only ever RELAXES the heuristics, it can never be the reason a credential reaches a log — the credential pass (redact_credentials) is independent of it and unchanged by a missing context. Deferred-import exception: security reads the set through a FUNCTION-LOCAL import of kiro_crew.platform.context (the sel.py pattern), so the CPP import-direction invariant holds — platform/defaults.py imports security at module load, and security never reaches platform at module-load time (only at call time). v1 method addition to the existing CredentialPolicy Protocol; no CONTRACT_VERSION bump; DefaultCredentialPolicy returns frozenset() so standalone redaction is byte-identical.
  • agent.pycurrent_context().mcp_tooling.extra_mcp_servers() merged additively (setdefault) into the agent config build + dynamic refresh.
  • slack/events.py / slack/handler.py / dashboard/handlers_system.py — Slack enterprise gate + SSO status route through slack_gate / identity.
  • mcp_gateway/manager.pyGatewayManager._spawn_once resolves current_context().identity.credential_watch_paths() (v1 method addition to IdentityProvider; Default returns []) and threads each path to the gateway daemon as a repeatable --credential-watch-path argv flag. The seam is resolved in the already-booted gateway process, never in the daemon: gatewayd is a separately spawned subprocess that does not call boot_platform, and current_context()'s lazy default fails closed on non-standalone profiles — so the argv flag is the only channel. Absent flag (the public default) ⇒ the daemon creates no watcher task and its run flow is byte-identical. With a flag, mcp_gateway/credwatch.py polls the file and fires only on a content-digest change (an mtime bump with byte-identical content — the no-op-rewrite storm — never fires; the first observation is the silent baseline, whether the file is present OR absent). An absent baseline that later appears DOES fire (a "no credential -> credential" transition drains any backend prewarmed during the absent startup window — prewarm is scheduled before the watcher's first probe), and a present -> absent deletion fires too (credential revocation — otherwise pooled backends keep the revoked credential until deadline/restart); the baseline moves to absent so a re-appearance fires again. Genuine absence only — a transient stat/read OSError is skipped without firing. Firing triggers a blue-green drain (pool.drain_all_to_bluegreen) + re-warm so pooled backends respawn with the rotated credential. The core never hardcodes or interprets any credential path/content — the bytes are only hashed. Read through safe_context_call (fallback []), so a pre-method companion adapter degrades to no-watcher instead of raising.
  • apps/manager.py — builtin discovery + orphan detection merge current_context().apps_loader sources.
  • apps/registry.py / apps/routes.py — clone-sandbox-mode decision routes through current_context().registry (_context_clone_sandbox_mode).
  • Telemetry record_event sites — dashboard/server.py records gateway_start at boot; dashboard/chat_runner.py and slack/handler.py record one interaction event per successful chat turn (immediately after the record_success call, non-cancelled / non-retrying branch only; cancelled turns emit nothing). The interaction payload is strictly metadata — session_key, surface ("dashboard" / "slack"), and model — never prompt/response text or file contents. All sites are best-effort (try/except-Exception, debug log); the Default provider is a no-op so standalone is byte-identical. Phase-1 scope is dashboard + slack only (cli_chat/cron/subagent/task_executor sites are deliberately not wired).
  • Preflight checks (IdentityProvider.preflight_checks()) — kiro_crew.preflight.run_preflight_checks() runs seam-supplied pre-launch checks at exactly two sites: the gateway dispatch in cli.py (before faulthandler/lock/asyncio.run) and _token in cli_server.py (before TTL parsing). The method returns already-resolved callables — checks are never module:function strings resolved from config (an agent-writable config importing arbitrary callables at next start would be a code-exec escalation). SystemExit from a check propagates so a check can abort the launch; every other exception is logged and swallowed per check. When called with no explicit list, the runner resolves the checks through safe_context_call (fallback []), so a transient context failure can never block standalone startup while PlatformCompositionError still propagates fail-closed. DefaultIdentityProvider.preflight_checks() returns [] — standalone startup is byte-identical; the companion returns e.g. an SSO-session freshness prompt. Placement rationale: the checks cannot live in boot_platform (it runs for every subcommand, incl. the mcp-core/mcp-cron stdio servers where an interactive prompt would corrupt the JSON-RPC stream) nor in DashboardContributor.start_services (it never runs for token and fires only inside gateway async startup) — so the two command dispatch sites host the call. v1 method addition to the existing IdentityProvider Protocol; no CONTRACT_VERSION bump.
  • tunnel/manager.py — the tunnel lifecycle routes through the seam. The stub TunnelManager delegates start / stop / public_url UNCONDITIONALLY to current_context().tunnel (via safe_context_call / async_safe_context_call — re-raise PlatformCompositionError, degrade other errors); there is no isinstance/identity check against DefaultTunnelProvider (that would be an edition branch by proxy). start() first registers the connect/disconnect CORS-reflection callbacks with the provider (register_callbacks), then delegates start(); when the provider is not enabled (the public Default) it falls through to the byte-identical "not available in OSS" disabled notice. The status property prefers the provider's status_snapshot() and otherwise reports its own local TunnelStatus — the Default returns None, so the standalone /api/tunnel/status payload and test_tunnel_manager.py assertions are unchanged. Precedence: an explicit local lifecycle write wins — stop() (STOPPED) and the OSS-disabled start() (DISABLED) pin the local status so a stale/lagging companion snapshot cannot resurrect a "connected" state after teardown; the next start() clears the pin. The snapshot is projected onto a FRESH TunnelStatus each read, so a key a later snapshot omits (e.g. a cleared error/url) resets to its default rather than persisting a stale value. public_url returns the provider URL only while state is CONNECTED (mirrors the pre-seam stub), so a companion that keeps its last URL while RECONNECTING/ERROR is not reported as live. register_callbacks + status_snapshot are a v1 addition to the existing TunnelProvider Protocol (no CONTRACT_VERSION bump). ensure_available(*, install=True) -> str is a further v1 addition (no bump): an idempotent "make the tunnel reachable, provisioning on demand" entry point returning one of "connected" / "starting" / "disabled" / "unavailable". WIRED at slack/allowlist.py::send_dashboard_link, which calls it (via async_safe_context_call) only when a tunnel URL is wanted for a Slack dashboard link but none is live yet; the DefaultTunnelProvider returns "disabled", so the standalone path is unchanged (it still falls back to the local URL). Narrow-only: the method can start/provision a companion tunnel but never bypasses the tunnel/setup.py token-auth deny gate. The token-auth deny gate in tunnel/setup.py is evaluated BEFORE the manager is constructed or start() reached, so a companion tunnel cannot start without dashboard token auth; the connect/disconnect callbacks and /api/tunnel/status stay wrapped AROUND the provider. Teardown is wired at dashboard/server.py::_wire_tunnel_shutdown — an app.on_cleanup hook that reads state.tunnel_manager lazily (the manager is assigned later, by setup_tunnel). It covers BOTH start paths, because a live tunnel does not imply a manager: with a manager it calls TunnelManager.stop(); with state.tunnel_manager still None — the on-demand link path (slack.use_tunnel_urlensure_available()) provisions and starts a tunnel straight on the provider and never constructs a manager — it stops current_context().tunnel directly. Exactly one path runs per shutdown (the manager delegates to the same provider), so nothing is stopped twice, and both are idempotent, so a shutdown path that runs twice is harmless. Both paths go through one guard: bounded by _TUNNEL_STOP_TIMEOUT_SECS, every failure logged and swallowed — including a fail-closed current_context() lookup, which is evaluated INSIDE the guard — so a hanging or raising provider cannot block or abort the remaining on_cleanup handlers. Registration order is load-bearing: the hook is appended immediately after the web.Application is created, ahead of every other cleanup registration and well before runner.setup() freezes the signal lists. aiohttp dispatches on_cleanup in registration order under a hard shutdown deadline, so a tunnel hook queued behind the other subsystems can be starved (instances cleanup waiting on SSH children that ignore SIGTERM eats the deadline, the gateway force-exits, and the tunnel is never stopped); the lazy state.tunnel_manager read is what makes the early registration safe. Because the manager is edition-neutral, one hook tears down EVERY provider (the Default's stop() is a no-op). start_api_server (the --slack-only/headless path) never calls setup_tunnel, so it needs no hook. Import direction: tunnel/ imports kiro_crew.platform.context; platform/ keeps zero imports of kiro_crew.tunnel.
  • dashboard/server.py — tunnel enable-gate ORs in current_context().tunnel.enabled(). Dashboard contributor (wave 3): in start_dashboard only, the /api/sso-login route binds dashboard.sso_login_handler() (or the built-in stub when None), dashboard.contribute_routes(app) mounts edition routes before the SPA catch-all + AppRunner.setup(), and dashboard.start_services(app) / stop_services(app) ride app.on_startup / app.on_cleanup — appended BEFORE runner.setup() freezes the signal lists. The sync calls fail-closed via safe_context_call; the two async lifecycle hooks via async_safe_context_call (the async sibling — same re-raise-PlatformCompositionError / degrade-other contract, centralized so the fail-closed policy cannot diverge). stop_services takes the same app handle as start_services (symmetric) so a companion need not stash services in process-global state.
  • dashboard/handlers_system.pyfrontend_rum_config() added to the status payload only when non-None.
  • config/loader.py build_provider_factory(cfg) (wave 3 wiring) — the LLM-provider factory build sites (cli_chat, cli_server, session.reload_provider_factory, slack/gateway, cli, cli_commands) route through current_context().providers.create_factory(cfg) instead of cfg.create_provider_factory() directly. The Default returns exactly cfg.create_provider_factory() (identity), so the public edition is unchanged; the companion selects its Bedrock-hosted backend only when opted in. The fallback is passed as a lazy fallback_factory so the happy path builds the factory exactly once (no eager double-build) and a failure inside the fallback is still caught by the shim.
  • dashboard/handlers/knowledge.py — the SyncScheduler connector map merges current_context().knowledge.extra_connectors(cfg) after the built-ins (local_folder/obsidian_vault); Default returns {} so standalone is unchanged.
  • cli.py main (wave 3 jail gate, factored into _jail_reexec_gate) + cli_doctor.py — for _JAILED_COMMANDS (chat/tui/run/consolidate/eval — the rule is "every command that builds a provider factory / runs in-process agent work"; gateway is excluded so its execv self-update path is never nested in a jail). Order: (0) re-entry guard — if the KIROCREW_JAILED marker is PRESENT (any non-empty value) we are already the jailed child, so return immediately (no re-probe / re-jail). The gate sets this marker right before invoking the backend so the re-exec'd child inherits it; a try/finally restores the prior value on the no-re-exec paths so it never leaks into an in-process run. A companion that re-execs with a fresh environment MUST set the marker to any non-empty value (detection is by presence, not truthiness) or the on-mode child would re-probe, get an "already jailed" None, and deadlock on the fail-closed floor. (1) if off this invocation (--no-jail OR KIROCREW_NO_JAIL truthy — 1/true/yes/on via the shared env_flag_enabled, so a =0/=false typo does NOT bypass isolation), or the re-normalized agent.jail mode is off, return and run in-process (no probe). (2) Probe current_context().jail.available(): a clean False (the public Default) is a pure no-op even under mode == "on" (exactly as the help text promises) and _child_argv() is not even built; a PlatformCompositionError always propagates; a transient probe error degrades to no-op under auto but FAILS CLOSED (exit 2) under on (availability unknown ≠ absent — an on-mode host must not run un-jailed on a flaky probe). (3) With a backend present, jail.maybe_reexec_into_jail(_child_argv(), mode) runs; a non-None return is the jailed child's exit code (propagated via sys.exit). Single fail-closed floor: under mode == "on", anything other than a real re-exec (None return OR a swallowed backend error) refuses to run un-jailed (exit 2). The mode is re-normalized at the gate via _normalize_jail (so a programmatically-set off-spec value is handled like the load-time path); --no-jail is accepted on every jailed subparser. cli_doctor reports jail.available() / status_detail(). The host probes a companion backend builds on — sandbox.userns_available() / sandbox.is_wsl() — are CACHED and never block on a running event loop (userns_available() delegates to the probe-cache machinery; a cold on-loop call defers to the background warm and returns False with a transient classification). Boot code should call sandbox.prewarm_backend() before companion composition so the cache is warm by the time a jail backend probes it.

Edition seam additions (v1, no CONTRACT_VERSION bump)

Existing-Protocol methods added / wired so a companion can re-introduce behavior the public fork dropped without the core importing it. All are v1 additions (a Default* no-op reproduces today's OSS behavior exactly — a standalone process is byte-identical) with no CONTRACT_VERSION bump.

  • SlackEnterpriseGate.heartbeat_safe_tools() -> frozenset[str] — unioned into slack/gateway.py::_is_heartbeat_safe_tool after the core HEARTBEAT_SAFE_TOOLS exact-match. Default frozenset(). ADD-only; never sourced from config.

  • AppsLoader.registry_rows() -> List[Dict] — ADD-only merged by apps/registry.py::_load_registry_file after bundled app-registry.json (same-name core row wins). Default [].

  • AppsLoader.default_registries() -> List[Dict] — external app registries the edition pins, merged with the operator's config.registries by apps/registry.py::_effective_registries, which is the single list every registry consumer reads (index fetch/refresh, the trusted-host allowlist, row lookup, install, the blob-proxy allowlist). Rows are the field shape of ExternalRegistryConfig ({name, repo, branch, trust}). Unlike registry_rows, the edition row wins a name collision — and when the two rows name DIFFERENT repositories, neither is served, because the index cache is keyed by name and the displaced row's cache would otherwise be read under the winner's identity. Merged at the consumption sites, never inside KiroCrewConfig, so a config save can never persist an edition default into the operator's file. Default [].

  • TelemetryProvider.otlp_destinations(cfg) -> Sequence[OtlpDestination] — the OTLP collectors this edition sends telemetry to. Read by metrics/provider.py::_otlp_destinations once per recorder build (through safe_context_call, fallback ()), which attaches one PeriodicExportingMetricReader per destination naming the "metrics" signal, AFTER the built-in local JSONL reader. ADD-only: an edition can add a destination but can never remove or replace the local sink, relax consent or attribute sanitisation, or set the export cadence. OtlpDestination carries name (non-secret, log-safe — the endpoint value is never logged), endpoint, signals, and an optional authenticated session; the session is the seam a ROTATING credential composes against, since requests re-evaluates Session.auth per export where OTEL_EXPORTER_OTLP_HEADERS freezes at construction (static per-destination headers ride on Session.headers). Must be cheap and side-effect-free per call: it is read once per recorder build AND on every egress-posture read (Privacy panel status, each telemetry.enabled write), so an edition builds its transport once rather than minting a credential inside it. A provider that does not implement the method answers "no destinations" rather than "unknown", so a pre-seam edition keeps the two surfaces agreeing. Signal-agnostic on purpose: the OTLP metric, span and log exporters take the same constructor arguments, so a later core that emits traces or logs reads the same descriptor instead of needing another method. Deny-by- default — an empty endpoint, or a signal this core does not emit, is dropped. Default: one destination for a non-empty telemetry.otlp_endpoint, none otherwise (byte-identical to the endpoint-only exporter it replaced).

  • DashboardContributor.on_user_message(app, message) — fired once per user message by dashboard/chat_handlers.py::api_chat before the turn, inside a fail-safe safe_context_call. OBSERVER only. Default no-op.

  • McpToolingProvider.extra_skills() — now WIRED: SkillsLoader.__init__ appends returned paths as lowest-precedence extra skill roots (sensitivity- + existence-checked). Default [].

  • AgentCatalogProvider.builtin_agents() -> List[Dict[str, Any]] — ADD-only agent-catalog rows merged by agent_discovery.list_agents() AFTER the on-disk scan of ~/.kiro/agents and, when the caller supplies a project_dir, <project>/.kiro/agents (via _with_edition_agents, through safe_context_call), de-duped by name so an on-disk agent of the same name wins. Within the on-disk scan a project agent shadows a user-level one of the same name (and the shadowing is logged), mirroring kiro-cli — which resolves --agent against its cwd first, and which Kiro Crew spawns with the session's project directory as that cwd, so the project entry is the one that would actually run. Kiro Crew's legacy <project>/.kiro/*.agent-spec.json convention is deliberately NOT scanned here (only the Slack handler opts into it): kiro-cli cannot activate such a name, and this list is a dispatch surface. Each row is a plain dict of AgentInfo fields (name required; filename/description/model/skills/mcp_servers/source/package/scope optional). EXECUTABLE INVARIANT: every returned row MUST be spawnable — the edition guarantees a resolvable agent config exists for its name (materialized under ~/.kiro/agents or otherwise resolvable by the ACP backend). list_agents() is the single executable-agent allowlist consumed by _do_agents_sync() (which PERSISTS rows into config.json's cfg.agents), subagent._validate_agent() (spawn), and conductor generation, so a catalog-only row with no config behind it would be persisted and offered for spawning yet fail at ACP session/set_mode — do NOT return non-executable rows. Default [] (discovery is the on-disk scan only). Split out of McpToolingProvider into its own Protocol — agent-catalog contribution is a distinct concern from MCP tooling; each edition hook lands on its own interface rather than accreting onto the nearest existing one.

  • Agent packages + plugins (the symmetry completion). list_agents() was originally READ-only, so an edition could show installed agent packages but not manage them — the dashboard had install/uninstall for MCP servers and skills and a dead end for agents. install_agent/uninstall_agent(package) closes that asymmetry (POST /api/capability/agents/{install,uninstall}); both rebuild the agent config and clear the list_agents() cache, because an agent package carries agents PLUS its own skills and prompt sources. Alongside them, three ops cover plugin packages — agent-client integrations an edition's package manager installs next to the agent packages themselves: async list_plugins() -> List[Dict[str, Any]] (informational rows), async plugins_out_of_sync() -> List[str] (the DRIFT set: packages installed as agents but missing their plugin counterpart — which presents to a user as an agent their client cannot see), and async sync_plugins() -> CapabilityResult (the writer that reconciles the drift). GET /api/capability/plugins returns the rows and the drift set in ONE response so the UI cannot render a list and a reconcile affordance that disagree mid-install. All three are deliberately client-agnostic: the core neither knows nor names any particular editor or CLI, and an edition with no plugin concept returns []/[] — for plugins_out_of_sync an empty list genuinely means "in sync", which is why its Default is not a fail-closed error like the mutation stubs. Implementer note (learned wiring a real edition): package managers commonly publish one plugin per package SUBSET (<Package>-<subset>) while agent packages carry the bare <Package>. Comparing the two name spaces directly reports EVERY package as drifted. Resolve a plugin's owner by matching against the known installed package set (longest match wins) rather than splitting on a separator — a split silently misattributes any package whose own name contains that separator, which makes the package permanently "drifted" so every sync_plugins reinstalls a plugin that already exists.

  • CapabilityManager (operations-based external package/capability manager) — replaces the former external_capability_bin() binary-name seam. Rather than naming a binary whose exact CLI grammar the core then hardcodes, the edition implements OPERATIONS and OWNS its own invocation grammar, output parsing, and error translation; the core (/api/capability/* handlers + mcp.py uninstall) calls an operation and only serializes the result / applies side effects (config sync, agent rebuild). Ops: available() -> bool; async list_mcp()/list_skills()/list_agents() -> List[Dict[str, Any]] (structured entries — the manager parses its own output; the core keeps no text grammar; list_skills() containment invariant: every skill row MUST live under an McpToolingProvider.extra_skills() root, because the skill browser (/api/skills/package/<name>/tree + detail) resolves paths by searching those roots — a row outside them lists but 404s on tree/detail, so an edition satisfying both Protocols MUST keep them consistent; the core enforces this at runtime — collect_skills_blocking logs a loud warning for any listed row outside every extra_skills() root. Two further constraints bind the keys an edition may hand out. A root the core already keys itself is not package/ territory: ~/.kiro/skills, the data home skills dir, configured skills.extra_paths, and the active project's .kiro/skills are keyed kiro-user/, kiro-workspace/, or unprefixed, so advertising one of them from extra_skills() (legitimate — it makes the loader index it) does NOT also expose it under package/; _edition_package_roots() computes that difference once and both catalog enumeration and path resolution read it, so the two cannot drift. Resolution is exact-first and refuses ambiguity: a package/<name> request prefers <root>/<name>/SKILL.md over a nested <root>/<Pkg>/<name>/SKILL.md, and when two DISTINCT files tie within a tier it resolves to None — HTTP 404, with the competing candidates logged — rather than picking one, because the key cannot express which was meant (paths that merely symlink to the same file are not a tie). An edition that wants both of two same-named skills reachable MUST therefore key them distinguishably); async install_mcp/uninstall_mcp(server_id), async install_skill/uninstall_skill(package), async install_agent/uninstall_agent(package)CapabilityResult(ok, message) (the manager translates its own errors — the core never matches package-manager error strings, and no Amazon-internal version_set field is exposed on the op or the public /api/capability/skills/install schema; LIVENESS: operations MUST be internally time-bounded — a slow companion op must not stall MCP handlers. As defense-in-depth the core also wraps every mutation op with asyncio.wait_for via platform.capability_bound.BoundedCapabilityManager, applied at context composition (PlatformContext.__post_init__ binds every CapabilityManager once, idempotently — so the companion's dataclasses.replace path is not double-wrapped). Applying it at the seam — not at the dashboard accessor _capability_manager() — means EVERY reader of current_context().capability_manager inherits the bound, whether it goes through that accessor or reads the context directly (a subagent, conductor, MCP-tool, or apps-backend consumer), so a future non-dashboard call site cannot silently obtain an unbounded manager and reintroduce the hang class. Bounds are DIFFERENTIATED: tight CAPABILITY_UNINSTALL_TIMEOUT (60s) for uninstall, generous CAPABILITY_INSTALL_TIMEOUT (600s) for install so a legitimate cold package-manager download is not cancelled mid-mutation (which could leave partial state), and a tight CAPABILITY_READ_TIMEOUT (30s) on the async READ ops (list_mcp/list_skills/list_agents/registry/list_plugins/ plugins_out_of_sync) — the dashboard POLLS those list endpoints, so a stalled unbounded read would accumulate pending gateway tasks on every poll (the same wedge class the bound exists to prevent), even though reads mutate nothing. sync_plugins takes the INSTALL bound (it may shell a package manager once per drifted package). Only the synchronous available() probe is unwrapped (no I/O). /api/mcp/apply orders the two mutations to be both lock-safe and race-safe: it runs the companion uninstall_mcp calls FIRST, in a phase BEFORE acquiring the process-wide MCP file lock (_get_mcp_lock) — deduped by name, bounded-concurrent (_MCP_DEFERRED_UNINSTALL_CONCURRENCY) under ONE phase-level asyncio.wait_for deadline — then removes the scope-file config entries under the lock. So no slow companion op is awaited while the lock is held (no timeout×N stall, and the batch is capped by _MCP_APPLY_MAX_CHANGES), AND the package is removed before its config is (config removal is the last, lock-serialized step). Because the apply is a two-phase TRANSACTION and the file lock only serializes individual writes (not the phase boundary), the whole apply additionally runs under a process-wide async apply mutex (_get_apply_lock, spanning BOTH phases) so two concurrent applies cannot interleave (one re-adding a server from a preserved spec after another removed its package); the narrower file lock is retained inside for cross-process coordination with bridges.py;

    Uninstall ordering & the crash window. Package-first ordering (chosen for the concurrent-re-add TOCTOU fix above) flips the partial-failure DIRECTION from benign (config removed, package orphaned-but-harmless) to harmful (package removed, config persists → the server fails at every subsequent session start until re-applied). The core closes this: BOTH phases run inside one outer try, whose finally calls a guaranteed-cleanup sweep (_sweep_dangling_uninstalls) that re-purges — via the shared, idempotent _purge_server_config — the config of every REQUESTED uninstall the locked loop did not reach. It sweeps by REQUEST, matching Phase 2's own uninstall branch (which removes config unconditionally, without consulting the companion outcome): keying on the request rather than on a capability_results entry closes the window where a cancellation lands the instant AFTER the companion removed a package but BEFORE its result was recorded (which would otherwise leave config dangling). Removing config for a requested uninstall is the user's intent and errs toward the BENIGN failure direction (config gone, package possibly orphaned-but-harmless — recoverable by reinstall) rather than the harmful one. Wrapping BOTH phases (not just the locked loop) is load-bearing: a CancelledError raised DURING Phase 1 — gateway shutdown / client disconnect, before the Phase-2 lock is ever taken — must still trigger the sweep. The sweep is a blocking acquire→purge→release (its own _McpFileLockSync) dispatched to a worker thread via run_in_executor, and the finally awaits that future to completion (shielded) before re-raising. Running it off the event loop satisfies two constraints at once: (1) deadlock-free — a loop-blocking acquire here would wedge any other task that holds the MCP lock (it could never resume to release it), violating no-blocking-call-on-event-loop; off the loop the lock-holder resumes normally; and (2) runs-to-completion — a worker thread is not cancelled when the request task is, and awaiting the future before re-raising means the purge finishes even on cancellation (an un-awaited/shield-only future would run orphaned and loop teardown could destroy it mid-write). This narrows the window to the irreducible hard-kill case (SIGKILL / power loss between the Phase-1 op and the config write), which is itself self-healing: the same package-then-config apply is idempotent, so a re-apply (or a manual uninstall) converges the state. list_mcp is a pass- through read and does NOT auto-reconcile — the sweep + idempotent re-apply are the recovery path.

    async registry() -> List[Dict] (the manager parses its own registry output into entries; the core passes them through as {"servers": [...]}). The public DefaultCapabilityManager.available() is False → the handlers return HTTP 503; a companion implements registry-backed management. This is the operations-based Protocol the prior binary-name seam's contract note anticipated — chosen now, pre-launch, so no external CLI grammar fossilizes in the core.

    Second consumer — App Kit dependency resolution. apps/dependencies.py resolves an app manifest's dependencies.capabilities.{mcp,skills} through install_mcp/install_skill (and uninstall_* on cleanup) rather than shelling out to a named binary, reading the manager via current_context() so it inherits the BoundedCapabilityManager timeout wrapper. The seam is probed lazily — a commands-only manifest never touches it. When available() is False the entries are recorded as failed (unresolved) and the app still installs, so a public install surfaces the unmet dependency instead of silently reporting success. dependencies.capabilities.agents is declarable but never gateway-installed: the Protocol exposes list_agents only (package/agent install routes were removed), so those entries always report unresolved — declare them managedBy: app or install them out of band. The wire key aim is a deprecated READ alias (Dependencies.from_dict) that is never re-emitted, so a manifest round-trip migrates it; ledger keys/types likewise resolve the pre-rename aim/* / aim.* spellings so an upgraded install does not orphan tracked dependencies.

  • McpToolingProvider.extra_mcp_scopes() -> List[McpScope] — provider-specific GLOBAL MCP config scopes. /api/mcp/apply and the MCP uninstall path write each returned scope's global_json (and strip its agent_mcp_file) IN ADDITION to the core Kiro global, keyed by f"{scope.id}Global" in the request body. Default []the core writes the Kiro global ONLY; a companion returns e.g. the Claude Code scope (~/.claude.json) to keep that provider's config in sync. Backed by the new frozen dataclass McpScope(id: str, global_json: Path, agent_mcp_file: Optional[Path] = None, label: str = "") in interfaces.py: id is the short scope key used in the request body (f"{id}Global", e.g. ccGlobal), global_json is the provider's global MCP config file, agent_mcp_file is the rendered per-agent MCP file to strip on uninstall (or None), and label is the human display name the dashboard shows on the scope badge (e.g. Claude; defaults to id). All are v1 additions to the existing McpToolingProvider Protocol; the Default* returns empty/None so a standalone process is byte-identical, and no CONTRACT_VERSION bump. MCP discovery (mcp_discovery._extra_scope_sources) reads this SAME seam, so the scopes discovery scans are exactly the scopes apply/uninstall manage: the core scans the Kiro globals only, and a companion's provider scope (~/.claude.jsonccGlobal) is both scanned AND managed — no discover-but-can't-uninstall "zombie" servers. The dashboard is seam-aware too: GET /api/mcp/scopes (api_mcp_global_scopes) returns the configured extra scopes as [{id: "<id>Global", label}], and the Installed-Integrations "Globals" column renders the core Kiro badge unconditionally plus one badge per returned scope — so a companion re-surfaces its Claude toggle with no core edit, and the public build (empty list) shows Kiro only.

    • /api/mcp/apply omitted-field semantics (contract). A per-server change object carries a presence boolean per scope. The two scope families deliberately differ on an omitted key: the core kiroGlobal key is omit → delete (defaults to False — the bundled SPA always sends it explicitly, so an omission means "not present"), while every seam scope f"{id}Global" is omit → preserve (defaults to the scope's current on-disk presence via _scope_has_entry). The asymmetry is intentional and load-bearing: the OSS SPA does not know a companion's scope keys, so it omits them, and omit-preserve prevents an unrelated apply from silently deleting a companion-managed server from its provider global (e.g. ~/.claude.json). A companion frontend that toggles a seam scope MUST send that scope's boolean explicitly to change it. (Equivalent alternative, not chosen: unify all scopes on preserve-on-omit and have every client always send explicit booleans.)
  • PromptSourceProvider.prompt_source_roots() -> List[Path] — WIRED: the dashboard prompt listing (handlers/__init__._list_aim_prompts) walks each returned root generically (rglob('*.sop.md')) for prompt/SOP markdown, replacing the former hardcoded ~/.aim/packages + eventId/.version-manifest.json layout. Default [] so the standalone edition lists only ~/.kiro/prompts user prompts; a companion returns its resolved package prompt roots. Split out of McpToolingProvider into its own Protocol (a distinct concern). v1 addition; Default returns [].

  • SkillDiscoveryProvider.skill_providers() -> List[SkillProvider] — WIRED: the dashboard discover registry (handlers/discover._build_registry) registers each returned provider after the built-in one, gated per provider by the same external_access.admits_registry("skill", name, api_base) decision the built-in provider passes through — a managed allowlist applies uniformly, with no edition-specific carve-out. ADD-only and de-duped by name (the built-in wins a collision, so an edition cannot silently replace an identity the policy admitted). Each returned object implements the kiro_crew.skill_providers.base.SkillProvider protocol and SHOULD expose an api_base naming its catalog endpoint (the identity an allowlist is written against; a provider without one is gated on an empty base). Provider-sourced result fields flow through the existing _redact_external scrub before reaching the dashboard. Read fail-closed through safe_context_call (fallback []). Default [], so the public edition searches only the built-in catalog. v1 addition; Default returns [].

  • ImportSourceProvider.import_sources() -> List[ImportSource] — WIRED: onboarding_import._sources() unions the returned descriptors over the core builtins for every scan, apply, and id-validation path, so a registered source is accepted by /api/onboarding/import/* and rendered by the import wizard with no core branching. Default [], so the public edition offers only the foreign agents it ships. One descriptor carries id, display name, root resolution, and the agent's own managed MCP server names, so a registration cannot half-work; a malformed one (no id, a reserved id, no resolvable root, an id that reuses a builtin, a traversing home_dir, or a stale_mcp_binaries entry naming a shared runtime) is dropped with a warning rather than shadowing a builtin or reclaiming unrelated MCP servers. A descriptor names neither a reader nor a layout: the engine does all reading, so a registered source cannot bypass the content gates inside the engine's readers. superseded is a separate opt-in: only an agent this product REPLACES has its leftover MCP entries reclaimed from the user's global provider config, because a live foreign agent's servers are still in use. A registered source is read as an install of this product's own on-disk layout — a predecessor, a rename, or a fork. v1 addition; Default returns [].

McpToolingProvider is intentionally scoped to MCP tooling onlyextra_mcp_servers(), extra_skills(), and extra_mcp_scopes(). The former grab-bag members were split into dedicated Protocols this session (AgentCatalogProvider, PromptSourceProvider, CapabilityManager) so the CPP layer keeps its "one adapter per concern" shape; every future edition hook lands on its own interface rather than accreting onto the nearest existing one.

Agent-discovery module rename (this session). aim_agents.pyagent_discovery.py; the AimAgent dataclass → AgentInfo. The agent source classification was generalized: the old KiroCrewAICapabilities-specific hardcode was removed, so a package-installed agent is now classified source="package" (alongside "kirocrew" for kirocrew.json/kirocrew-lite.json and "builtin" for the rest) rather than the former "aim" literal. Importers (subagent, mcp_core, conductor_skill, dashboard agents) were updated to the new module/class names.

  • register_browser_auth_provider(provider) — removed with the playwright-cli migration. The module that carried this seam is gone, so there is no browser-auth provider hook to register at all: browsing runs through the external playwright-cli binary (kiro_crew/browser_cli/), and a logged-in session reaches that binary through the CLI's own surfaces (state-save / state-load, attach --extension) rather than through an in-process provider.

  • hooks.register_internal_read_path(read_id, rel_path) — guarded seam adding a fixed-path entry to _INTERNAL_READ_ALLOWLIST (rejects ../absolute/ non-sensitive/repoint).

  • security._SENSITIVE_HOME_DIRS gains .midway (live SSO bearer cookie; inert on a host without ~/.midway).

  • config.dashboard.mwinit_flags (str) + _EDITABLE_CONFIG PATCH entry.

  • config.knowledge.doc_ingest_hosts (list) — SSRF-safe allowlist for the server-side fetch path only; empty = deny-by-default. The agent-driven auto_add_documents path (renamed from auto_ingest_doc_links) is NOT gated on it: the agent hands over text it already fetched, Kiro Crew fetches nothing.

  • KiroCrewConfig._extra_sections (private) — unknown top-level config.json sections captured at load(), re-emitted by to_dict(), so an edition section is not dropped on save()/PATCH. Excluded from the JSON schema (build_json_schema skips leading-underscore fields). Data-preservation half of the eventual ConfigSchemaContributor; Settings-visibility half is TODO.

  • ACP claude seam (inert on kiro-cli, and implemented in the core now that CC is selectable): AcpClient._session_mcp_servers() translates the materialized kiro agent spec (acp/session_mcp.py) and feeds both session/new + session/load mcpServers — gated on membership in ACP_BACKENDS_SESSION_MCP_ARRAY, not on _is_claude, since reading no agent file is a property of the transport rather than of Anthropic; _spawn calls _write_claude_local_settings() (still identity-gated — it writes a literal .claude/ path) on the PRIMARY spawn path (permission mode, availableModels, resolved model — snapshotted and restored by _reset_state); AcpClient/AcpProvider take a permission_mode kwarg (Default None); acp/types.py adds CC_PERMISSION_MODE_DEFAULT / CC_PERMISSION_MODE_AUTO.

  • Slack message-gate seams (let an edition compose a fail-closed challenge-and-redirect posture without editing the core; InterceptDecision enum = PROCESS | REDIRECTED | DROPPED):

    • SlackEnterpriseGate.intercept_message(orch, *, channel, sender_id, clean_text, thread_ts, msg_ts) -> InterceptDecision — wired in slack/events.py::_route_message. Default PROCESS (inline, OSS-identical). Ordering is security-critical: the call site is placed immediately after the user-allowlist check and BEFORE any content is recorded or processed (observe-mode channel_history.push, audio transcription, image/file download, and the non-observe history push all follow it), so an unverified sender's content can never be persisted to channel history where a later verified turn could pull it into agent context and bypass the gate. Gated on _user_authorized (an unauthorized sender is not a challenge candidate and keeps its ephemeral-rejection UX). Invoked through safe_context_call(..., fallback=DROPPED): a raised adapter error degrades deny-by-default, a PlatformCompositionError is re-raised. The public Default cannot raise, so standalone never reaches the fallback.
    • DashboardContributor.on_token_consumed(user_id, channel, session_exp, thread_ts) — fired in dashboard/token_auth.py after bind_token_peer on the first (non-cookie) exchange, with channel/thread_ts read from the token's signed extra payload. OBSERVER only; the anchor a challenge auth-window opens on. Default no-op. safe_context_call(fallback=None) re-raises PlatformCompositionError — no outer except wraps it (that would swallow the invariant).
    • DashboardContributor.decorate_reply(text, *, channel, user_id) -> str — outbound-reply transform, AFTER the redaction passes. Default identity. Wired on BOTH Slack reply paths: native slack/handler.py::handle_message AND the default slack/renderer.py::SlackRenderer.on_done (the handle_message_transport path that normal non-review traffic uses — SlackRenderer takes a user_id kwarg for it). Decorator-introduced text is re-scanned (redact_exfiltration_urls
      • redact_credentials) on both paths so a decorator cannot smuggle a URL/ credential past redaction; the native path logs only the redaction COUNT (never the warning strings, which embed a truncated credential prefix).

    Interceptor ordering + dedup + audit (security-critical, all in _route_message): the intercept_message call runs after the user-allowlist check and after the interceptor-specific retry-dedup guard, but BEFORE any content recording/ processing. A non-PROCESS decision (1) records an intercept:<msg_ts> key in the SeenCache so a Slack ack-timeout re-delivery does not re-mint the challenge (PROCESS never records, preserving the paired app_mention/message dual-event and the standalone inline path), and (2) emits a sel().log_api_access(operation="slack.message.intercept") audit event — the interceptor is a permission decision distinct from the allowlist check, so its verdict reaches the SEL trail.

Deferred / non-mapping sites

  • apps/routes.py_fetch_git_blob's per-URL clone-sandbox-mode decision IS wired: it routes through _context_clone_sandbox_mode (same as the apps/registry.py clone sites), so a companion's extended trusted-host set applies to registry-blob fetches too. The other wrap_argv sites run local lifecycle scripts (no per-URL git host), so they have no clone decision to route.

Reserved (declared-inert) contract surface

Some published extension points have no consumption site in the core: overriding them changes nothing. That is legitimate — the contract is kept stable pre-launch — but it MUST NOT be silent. An implementor reads PlatformContext, writes an adapter, dataclasses.replaces it in, and needs to find out immediately, not after a debugging session.

Three arms make the inertness impossible to miss:

  1. Declaration at the dataclass. context.RESERVED_SLOTS (whole fields) and context.RESERVED_METHODS (individual methods on otherwise-live fields) each map a name → the reason it is inert and the wired alternative. Every reserved field also carries a [RESERVED] marker comment on its declaration in PlatformContext, so the fact is visible in the source an edition author actually reads — not only in an interfaces.py docstring.

  2. A loud runtime signal. PlatformContext.__post_init__ calls _warn_reserved_slots, which logs ONE warning per reserved slot carrying a non-default value, naming the offending adapter and the alternative. It warns rather than raises: an edition may compose an adapter in anticipation of a slot being wired, and refusing to compose would turn a harmless forward-looking override into a boot failure (a breaking change for an existing companion). Deduped per (slot, adapter type) per process, so a composition root that rebuilds the context does not spam the log. Class identity (not isinstance) decides "is default", so a companion subclass of a Default* adapter still warns — it can change behavior.

  3. An anti-rot gate. test/test_platform_cpp_seam_coverage.py drives off dataclasses.fields(PlatformContext) and discovers real consumption sites by ast analysis of src/kiro_crew (excluding platform/ itself and _vendor/). It asserts, in both directions:

    • every field is EITHER consumed by non-platform core code OR listed in RESERVED_SLOTS — so a new field with no call site fails the build instead of becoming the next dead seam;
    • every RESERVED_SLOTS/RESERVED_METHODS entry has NO consumption site — so wiring a reserved slot fails the build until its entry is deliberately removed, and a marker can never go stale and mislead the next reader.

    Two boot-protocol carriers (contract_version, publish) are consumed only by bootstrap.py itself and are enumerated in the test's _BOOT_CONSUMED_IN_PLATFORM; a companion test proves each is genuinely read there, so the allowance cannot be used to park a dead field.

Current reserved surface:

Slot / methodWhy inertUse instead
embeddings (whole slot)the public embedding runtime is the bundled in-process llama.cpp model — there is no HTTP embed path to source a model/endpoint/signature fromembeddings.register_embedding_backend()
package_manager (whole slot)external-tool installs (ollama, ffmpeg, whisper) are inline step-by-step brew/curl/pip logic in cli_doctor.py, not a single plan-resolution pointCapabilityManager for registry-backed MCP/skill/agent installs
feature_apps (whole slot)bundled apps are discovered via AppsLoader and registered by apps/manager.py; the tuple is a provenance record onlyAppsLoader.manifest_sources() / bundled_app_names()
AgentRuntime.managed_mcp_serversthe agent config is built from the agent._MANAGED_MCP_SERVERS global directlyMcpToolingProvider.extra_mcp_servers() (wired, ADD-only)
IdentityProvider.whoami / .issuernothing in the core displays the principal or branches on the issuerreturn them in the wired status() payload

FeatureApp is deliberately the ONE Protocol with no Default* adapter: it describes a single app (an item), not a policy the core queries, so there is no behavior for a default to reproduce — the default for the slot is the empty tuple build_default_context already composes. A DefaultFeatureApp would have to invent a fictional app to be instantiable.

Removing a reserved slot outright would be a contract-narrowing change (an out-of-tree companion that composes it today would fail to compose), so the slots are kept and marked rather than deleted. None of this warrants a CONTRACT_VERSION bump: no field is added, removed, or renamed, and no interface semantics change — the version stays pinned at 1 pre-launch.