Platform Context (Composed Platform Providers)
August 20, 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,
.midwaysandbox 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 a frozen dataclass built once at
boot holding the chosen adapter for every extension point, plus three carriers:
| Field | Kind | Default adapter | Companion supplies |
|---|---|---|---|
contract_version | carrier (int) | CONTRACT_VERSION | must match core |
profile | carrier (str) | "standalone" | "enterprise" |
cfg | carrier (KiroCrewConfig) | loaded config | same |
providers | adapter | DefaultProviderRegistry (Kiro-CLI-ACP only) | re-registers a companion-registered backend |
publish | adapter | DefaultPublishRegistry (registers no provider → publish unavailable) | registers enterprise artifact/publish providers |
agent_runtime | adapter | DefaultAgentRuntime (run_first_run_setup wired; managed_mcp_servers RESERVED) | extra one-time first-run provisioning |
agent_executable | adapter | DefaultAgentExecutableResolver (identity) | resolves an edition-managed launcher to its direct executable before core sandboxing |
sandbox | settings | DefaultSandboxPolicy (_STRICT_DIRS/_CC_DIRS) | additional edition-specific credential dirs |
credentials | adapter | DefaultCredentialPolicy (AKIA/ASIA redaction; exempt_exact_hosts() → frozenset()) | internal token regexes + trusted-tenant exempt hosts |
security | concrete | PolicyAuthority() (baseline only) | PolicyAuthority(overlay=…) ADD-only |
slack_gate | adapter | DefaultSlackEnterpriseGate (default-open) | fail-closed enterprise allowlist |
identity | adapter | DefaultIdentityProvider (sso_status.py stub; whoami/issuer RESERVED) | enterprise SSO / directory |
embeddings | adapter | RESERVED — DefaultEmbeddingSource; 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_tooling | adapter | DefaultMcpToolingProvider (all methods empty) | enterprise MCP server + skills + provider MCP scopes |
agent_catalog | adapter | DefaultAgentCatalogProvider (builtin_agents() → []) | edition agent-catalog rows |
prompt_sources | adapter | DefaultPromptSourceProvider (prompt_source_roots() → []) | edition prompt/SOP roots |
import_sources | adapter | DefaultImportSourceProvider (import_sources() → []) | edition onboarding-import sources |
capability_manager | adapter | DefaultCapabilityManager (available() → False) | operations-based external package manager: MCP servers, skills, agent packages, and client plugins |
external_access | adapter | DefaultExternalAccessPolicy (admits_registry() / admits_cloud_deployment() → True) | allowlist installable content to an internal registry; withhold cloud deployment |
registry | adapter | DefaultAppRegistryPolicy (public-forge baseline) | internal git hosts |
apps_loader | adapter | DefaultAppsLoader (OSS builtins) | internal app sources (code-reviewer; team_manager/mimir follow-on) |
package_manager | adapter | RESERVED — DefaultPackageManager; installs are inline in cli_doctor.py (use CapabilityManager) | — (slot inert) |
knowledge | adapter | DefaultKnowledgeProvider (no extra connectors) | enterprise doc connector (extra_connectors) |
tunnel | adapter | DefaultTunnelProvider (no-op) | internal tunnel supervisor |
telemetry | adapter | DefaultTelemetryProvider (no-op, RUM off) | RUM/Cognito config |
dashboard | adapter | DefaultDashboardContributor (no routes/services, no login handler) | secretary/taskkeeper routes + enterprise SSO PTY login |
jail | adapter | DefaultJailProvider (no-op, never jails) | enterprise process isolation |
feature_apps | tuple | RESERVED — (); apps register via apps_loader (provenance record only) | — (slot inert) |
external_accessnote — 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 bydeploy/handlers.py: the read atGET /api/deploy/configreportscloudDeploymentEnabledso 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 onconfigwould 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_deploymentare the single call points: they deny on a composed-adapter error (reaching that fallback means an operator intended to restrict something), letPlatformCompositionErrorpropagate, and SEL-audit both outcomes — a log carrying only denials cannot show whether the permitted path was ever taken.
registrynote — the publicDefaultAppRegistryPolicyencodes 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 (the
exempt-host lookup below is the one such caller), 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.
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:
build_default_context(cfg, profile=resolve_profile(...))— allDefault*.- If profile != standalone:
discover_companion_context(fail-closed). - Validate
contract_versionand the security floor;set_context. ctx.providers.register_acp_backends()once (Default no-op).ctx.publish.register_publish_providers()once (Default no-op → thepublish_providerregistry stays empty and publishing is unavailable).
Profile resolution
resolve_profile(cfg, *, entry_points) precedence (first match wins):
KIROCREW_PROFILEenv (standalone|enterprise; unknown → standalone).- Non-empty
kirocrew.pluginsentry-point group (companion installed). - Identity signal: a present
~/.midwaydirectory (a cheap stat, no subprocess) — only when the opt-inKIROCREW_MIDWAY_PROFILE_PROBEenv var is truthy. OFF by default so a stray~/.midwayleft by some other tool cannot force the public edition into theenterpriseprofile (which has no companion to compose and would fail-closed at boot, bricking every command). The companion's managed launcher setsKIROCREW_MIDWAY_PROFILE_PROBE=1. - 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.
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_deniedandeffective_patternsare@final. No subclass overrides the decision or the union construction.- The only override surface is the
SecurityOverlayProtocol, whoseextra_deny_patterns()is concatenated toBASELINE_DENY. There is no method anywhere that subtracts from that union. assert_security_floor(authority)(run at boot) verifies the authority is aPolicyAuthorityand that it has not overridden the@finaldecision methods; it also keeps a (now-vacuous)effective set ⊇ BASELINE_DENYsuperset 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_deniedvia theextra_patternsparameter.
BASELINE_DENY is now () — the floor redefinition. 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):
- Kill-switch (
banned) — a fleet bans a plugin by name; the ban always wins, in any mode (R-08 / M-09 remote-disable). - Marketplace allowlist (
approved) — when present, only listed plugins are admitted. Adding a plugin to the list is the marketplace review gate. - Verify-before-run signature (
require_signature) — the plugin ships a signedkirocrew_plugin.jsonmanifest; 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. - 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
governancecarrier (the enterprise security ceiling); - the
agent_executableresolver (edition-neutral direct-executable resolution before the core applies its sandbox); - the
knowledge(connector registry),dashboard(route/service/login-handler contributor), andjail(process-isolation) extension points; - wiring an existing but previously-unconsumed Protocol method into a call site
(e.g.
ProviderRegistry.create_factorygoing live,AppsLoaderbundling feature apps) — no shape change, so no bump regardless; - adding
TunnelProvider.register_callbacks/status_snapshotwhen 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.
2026-07-18 governance-seam re-triage. A re-triage of the CPP seam against the
16 upstream commit groups landed four of the above seam additions on this branch,
each in its own commit — IdentityProvider.preflight_checks() (G1, "Preflight
checks" below), CredentialPolicy.exempt_exact_hosts() (G3, "Exfil exact-host
heuristic exemption" below), TunnelProvider.register_callbacks /
status_snapshot (G2, "tunnel/manager.py" below), and
IdentityProvider.credential_watch_paths() (G6, blue-green pooled-backend drain
on credential rotation, "mcp_gateway/manager.py" below) — plus the metadata-only
interaction telemetry event (G8, "Telemetry record_event sites" below, no
Protocol change). All are v1 additions with no CONTRACT_VERSION bump. G6
was first built on the stacked branch feat/govseam-post-pr18 (it depends on
PR #18's mcp_gateway/ reshape) and was consolidated onto this branch once that
work merged. The re-triage added no new Protocols and no new SCOPE_CATALOG
rows; the full per-SHA verdict record is kept with the upstream sync tooling.
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.
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_gateway—boot_platform(cfg)once at startup (gateway raises fail-closed; cli is defensive — standalone never raises).slack/gateway.pygateway boot —AgentRuntime.run_first_run_setup()throughsafe_context_call. Previously this importedagent.run_first_run_setupdirectly, 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 intest_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 surroundingexceptkeeps a failure non-fatal to startup, andPlatformCompositionErrorstill propagates fail-closed.sandbox.py—_build_launcher_script/_build_seatbelt_profilesource the sensitive-dir lists fromcurrent_context().sandbox(the.aws-exclusion at the cc branch is preserved).namespace_argv/sandbox_exec_argvresolve argv[0] throughcurrent_context().agent_executablebefore 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); aPlatformCompositionErrorpropagates fail-closed.hooks.py— the deny check routes throughcurrent_context().security.is_denied; the kiro-hooks egress (dashboard/handlers/hooks.py) scrubs command/matcher through the sharedredact_via_contextshim.- Credential redaction — all egress scrubs route through the single
kiro_crew.platform.redact_via_contextshim (the one canonical fail-closed-aware shim; modules import it asredact). Covers:agent.pySEL-audit callers,mcp_core.pychat-history/spawn output,mcp_cron.pydeny-reason + script-vet + timezone messages, anddashboard/handlers/files.pyfile-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 DefaultCredentialPolicy.redactdelegates tosecurity.redact); a loaded companion adds its internal-token regexes uniformly across every egress surface. - Exfil exact-host heuristic exemption (
CredentialPolicy.exempt_exact_hosts()) —security.scan_exfiltration_urls/redact_exfiltration_urlsread 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:/ Loopnav=<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_REpath+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 withgetattr(policy, "exempt_exact_hosts", None)(a pre-method companion adapter degrades to the empty set) and is NEVER sourced fromconfig.json— an agent-writable exemption would be a hole in the redaction ceiling, so the companion adapter is the only supplier. EVERY failure degrades tofrozenset()— 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 viainstalled_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 profilecurrent_context()never memoizes its fail-closed verdict, so a per-line caller (_pump_stderrredacting backend stderr) would re-pay that synchronous I/O for every line on the gateway event loop. This is deliberately INVERTED vsredact_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:securityreads the set through a FUNCTION-LOCAL import ofkiro_crew.platform.context(thesel.pypattern), so the CPP import-direction invariant holds —platform/defaults.pyimportssecurityat module load, andsecuritynever reachesplatformat module-load time (only at call time). v1 method addition to the existingCredentialPolicyProtocol; noCONTRACT_VERSIONbump;DefaultCredentialPolicyreturnsfrozenset()so standalone redaction is byte-identical. agent.py—current_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 throughslack_gate/identity.mcp_gateway/manager.py—GatewayManager._spawn_onceresolvescurrent_context().identity.credential_watch_paths()(v1 method addition toIdentityProvider; Default returns[]) and threads each path to the gateway daemon as a repeatable--credential-watch-pathargv flag. The seam is resolved in the already-booted gateway process, never in the daemon: gatewayd is a separately spawned subprocess that does not callboot_platform, andcurrent_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.pypolls 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/readOSErroris 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 throughsafe_context_call(fallback[]), so a pre-method companion adapter degrades to no-watcher instead of raising.apps/manager.py— builtin discovery + orphan detection mergecurrent_context().apps_loadersources.apps/registry.py/apps/routes.py— clone-sandbox-mode decision routes throughcurrent_context().registry(_context_clone_sandbox_mode).- Telemetry
record_eventsites —dashboard/server.pyrecordsgateway_startat boot;dashboard/chat_runner.pyandslack/handler.pyrecord oneinteractionevent per successful chat turn (immediately after therecord_successcall, non-cancelled / non-retrying branch only; cancelled turns emit nothing). The interaction payload is strictly metadata —session_key,surface("dashboard"/"slack"), andmodel— 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: thegatewaydispatch incli.py(before faulthandler/lock/asyncio.run) and_tokenincli_server.py(before TTL parsing). The method returns already-resolved callables — checks are nevermodule:functionstrings resolved from config (an agent-writable config importing arbitrary callables at next start would be a code-exec escalation).SystemExitfrom 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 throughsafe_context_call(fallback[]), so a transient context failure can never block standalone startup whilePlatformCompositionErrorstill 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 inboot_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 inDashboardContributor.start_services(it never runs fortokenand fires only inside gateway async startup) — so the two command dispatch sites host the call. v1 method addition to the existingIdentityProviderProtocol; noCONTRACT_VERSIONbump. tunnel/manager.py— the tunnel lifecycle routes through the seam. The stubTunnelManagerdelegatesstart/stop/public_urlUNCONDITIONALLY tocurrent_context().tunnel(viasafe_context_call/async_safe_context_call— re-raisePlatformCompositionError, degrade other errors); there is noisinstance/identity check againstDefaultTunnelProvider(that would be an edition branch by proxy).start()first registers the connect/disconnect CORS-reflection callbacks with the provider (register_callbacks), then delegatesstart(); when the provider is not enabled (the public Default) it falls through to the byte-identical "not available in OSS" disabled notice. Thestatusproperty prefers the provider'sstatus_snapshot()and otherwise reports its own localTunnelStatus— the Default returnsNone, so the standalone/api/tunnel/statuspayload andtest_tunnel_manager.pyassertions are unchanged. Precedence: an explicit local lifecycle write wins —stop()(STOPPED) and the OSS-disabledstart()(DISABLED) pin the local status so a stale/lagging companion snapshot cannot resurrect a "connected" state after teardown; the nextstart()clears the pin. The snapshot is projected onto a FRESHTunnelStatuseach read, so a key a later snapshot omits (e.g. a clearederror/url) resets to its default rather than persisting a stale value.public_urlreturns 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_snapshotare a v1 addition to the existingTunnelProviderProtocol (noCONTRACT_VERSIONbump).ensure_available(*, install=True) -> stris 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 atslack/allowlist.py::send_dashboard_link, which calls it (viaasync_safe_context_call) only when a tunnel URL is wanted for a Slack dashboard link but none is live yet; theDefaultTunnelProviderreturns"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 thetunnel/setup.pytoken-auth deny gate. The token-auth deny gate intunnel/setup.pyis evaluated BEFORE the manager is constructed orstart()reached, so a companion tunnel cannot start without dashboard token auth; the connect/disconnect callbacks and/api/tunnel/statusstay wrapped AROUND the provider. Teardown is wired atdashboard/server.py::_wire_tunnel_shutdown— anapp.on_cleanuphook that readsstate.tunnel_managerlazily (the manager is assigned later, bysetup_tunnel). It covers BOTH start paths, because a live tunnel does not imply a manager: with a manager it callsTunnelManager.stop(); withstate.tunnel_managerstillNone— the on-demand link path (slack.use_tunnel_url→ensure_available()) provisions and starts a tunnel straight on the provider and never constructs a manager — it stopscurrent_context().tunneldirectly. 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-closedcurrent_context()lookup, which is evaluated INSIDE the guard — so a hanging or raising provider cannot block or abort the remainingon_cleanuphandlers. Registration order is load-bearing: the hook is appended immediately after theweb.Applicationis created, ahead of every other cleanup registration and well beforerunner.setup()freezes the signal lists. aiohttp dispatcheson_cleanupin 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 lazystate.tunnel_managerread is what makes the early registration safe. Because the manager is edition-neutral, one hook tears down EVERY provider (the Default'sstop()is a no-op).start_api_server(the--slack-only/headless path) never callssetup_tunnel, so it needs no hook. Import direction:tunnel/importskiro_crew.platform.context;platform/keeps zero imports ofkiro_crew.tunnel.dashboard/server.py— tunnel enable-gate ORs incurrent_context().tunnel.enabled(). Dashboard contributor (wave 3): instart_dashboardonly, the/api/sso-loginroute bindsdashboard.sso_login_handler()(or the built-in stub whenNone),dashboard.contribute_routes(app)mounts edition routes before the SPA catch-all +AppRunner.setup(), anddashboard.start_services(app)/stop_services(app)rideapp.on_startup/app.on_cleanup— appended BEFORErunner.setup()freezes the signal lists. The sync calls fail-closed viasafe_context_call; the two async lifecycle hooks viaasync_safe_context_call(the async sibling — same re-raise-PlatformCompositionError/ degrade-other contract, centralized so the fail-closed policy cannot diverge).stop_servicestakes the sameapphandle asstart_services(symmetric) so a companion need not stash services in process-global state.dashboard/handlers_system.py—frontend_rum_config()added to the status payload only when non-None.config/loader.pybuild_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 throughcurrent_context().providers.create_factory(cfg)instead ofcfg.create_provider_factory()directly. The Default returns exactlycfg.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 lazyfallback_factoryso 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— theSyncSchedulerconnector map mergescurrent_context().knowledge.extra_connectors(cfg)after the built-ins (local_folder/obsidian_vault); Default returns{}so standalone is unchanged.cli.pymain(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";gatewayis excluded so its execv self-update path is never nested in a jail). Order: (0) re-entry guard — if theKIROCREW_JAILEDmarker 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; atry/finallyrestores 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) ifoffthis invocation (--no-jailORKIROCREW_NO_JAILtruthy —1/true/yes/onvia the sharedenv_flag_enabled, so a=0/=falsetypo does NOT bypass isolation), or the re-normalizedagent.jailmode isoff, return and run in-process (no probe). (2) Probecurrent_context().jail.available(): a cleanFalse(the public Default) is a pure no-op even undermode == "on"(exactly as the help text promises) and_child_argv()is not even built; aPlatformCompositionErroralways propagates; a transient probe error degrades to no-op underautobut FAILS CLOSED (exit 2) underon(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-Nonereturn is the jailed child's exit code (propagated viasys.exit). Single fail-closed floor: undermode == "on", anything other than a real re-exec (Nonereturn 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-jailis accepted on every jailed subparser.cli_doctorreportsjail.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 returnsFalsewith a transient classification). Boot code should callsandbox.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 intoslack/gateway.py::_is_heartbeat_safe_toolafter the coreHEARTBEAT_SAFE_TOOLSexact-match. Defaultfrozenset(). ADD-only; never sourced from config. -
AppsLoader.registry_rows() -> List[Dict]— ADD-only merged byapps/registry.py::_load_registry_fileafter bundledapp-registry.json(same-namecore row wins). Default[]. -
AppsLoader.default_registries() -> List[Dict]— external app registries the edition pins, merged with the operator'sconfig.registriesbyapps/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 ofExternalRegistryConfig({name, repo, branch, trust}). Unlikeregistry_rows, the edition row wins anamecollision — 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 insideKiroCrewConfig, so a config save can never persist an edition default into the operator's file. Default[]. -
DashboardContributor.on_user_message(app, message)— fired once per user message bydashboard/chat_handlers.py::api_chatbefore the turn, inside a fail-safesafe_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 byagent_discovery.list_agents()AFTER the on-disk scan of~/.kiro/agentsand, when the caller supplies aproject_dir,<project>/.kiro/agents(via_with_edition_agents, throughsafe_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--agentagainst 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.jsonconvention 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 ofAgentInfofields (namerequired;filename/description/model/skills/mcp_servers/source/package/scopeoptional). EXECUTABLE INVARIANT: every returned row MUST be spawnable — the edition guarantees a resolvable agent config exists for itsname(materialized under~/.kiro/agentsor otherwise resolvable by the ACP backend).list_agents()is the single executable-agent allowlist consumed by_do_agents_sync()(which PERSISTS rows intoconfig.json'scfg.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 ACPsession/set_mode— do NOT return non-executable rows. Default[](discovery is the on-disk scan only). Split out ofMcpToolingProviderinto 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 thelist_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), andasync sync_plugins() -> CapabilityResult(the writer that reconciles the drift).GET /api/capability/pluginsreturns 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[]/[]— forplugins_out_of_syncan 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 everysync_pluginsreinstalls a plugin that already exists. -
CapabilityManager(operations-based external package/capability manager) — replaces the formerexternal_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.pyuninstall) 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 anMcpToolingProvider.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_blockinglogs a loud warning for any listed row outside everyextra_skills()root. Two further constraints bind the keys an edition may hand out. A root the core already keys itself is notpackage/territory:~/.kiro/skills, the data home skills dir, configuredskills.extra_paths, and the active project's.kiro/skillsare keyedkiro-user/,kiro-workspace/, or unprefixed, so advertising one of them fromextra_skills()(legitimate — it makes the loader index it) does NOT also expose it underpackage/;_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: apackage/<name>request prefers<root>/<name>/SKILL.mdover a nested<root>/<Pkg>/<name>/SKILL.md, and when two DISTINCT files tie within a tier it resolves toNone— 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-internalversion_setfield is exposed on the op or the public/api/capability/skills/installschema; 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 withasyncio.wait_forviaplatform.capability_bound.BoundedCapabilityManager, applied at context composition (PlatformContext.__post_init__binds everyCapabilityManageronce, idempotently — so the companion'sdataclasses.replacepath is not double-wrapped). Applying it at the seam — not at the dashboard accessor_capability_manager()— means EVERY reader ofcurrent_context().capability_managerinherits 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: tightCAPABILITY_UNINSTALL_TIMEOUT(60s) for uninstall, generousCAPABILITY_INSTALL_TIMEOUT(600s) for install so a legitimate cold package-manager download is not cancelled mid-mutation (which could leave partial state), and a tightCAPABILITY_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_pluginstakes the INSTALL bound (it may shell a package manager once per drifted package). Only the synchronousavailable()probe is unwrapped (no I/O)./api/mcp/applyorders the two mutations to be both lock-safe and race-safe: it runs the companionuninstall_mcpcalls 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-levelasyncio.wait_fordeadline — 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 withbridges.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, whosefinallycalls 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 acapability_resultsentry 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: aCancelledErrorraised 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 viarun_in_executor, and thefinallyawaits 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), violatingno-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_mcpis 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 publicDefaultCapabilityManager.available()isFalse→ 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.pyresolves an app manifest'sdependencies.capabilities.{mcp,skills}throughinstall_mcp/install_skill(anduninstall_*on cleanup) rather than shelling out to a named binary, reading the manager viacurrent_context()so it inherits theBoundedCapabilityManagertimeout wrapper. The seam is probed lazily — a commands-only manifest never touches it. Whenavailable()isFalsethe entries are recorded asfailed(unresolved) and the app still installs, so a public install surfaces the unmet dependency instead of silently reporting success.dependencies.capabilities.agentsis declarable but never gateway-installed: the Protocol exposeslist_agentsonly (package/agent install routes were removed), so those entries always report unresolved — declare themmanagedBy: appor install them out of band. The wire keyaimis 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-renameaim/*/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/applyand the MCP uninstall path write each returned scope'sglobal_json(and strip itsagent_mcp_file) IN ADDITION to the core Kiro global, keyed byf"{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 dataclassMcpScope(id: str, global_json: Path, agent_mcp_file: Optional[Path] = None, label: str = "")ininterfaces.py:idis the short scope key used in the request body (f"{id}Global", e.g.ccGlobal),global_jsonis the provider's global MCP config file,agent_mcp_fileis the rendered per-agent MCP file to strip on uninstall (orNone), andlabelis the human display name the dashboard shows on the scope badge (e.g.Claude; defaults toid). All are v1 additions to the existingMcpToolingProviderProtocol; theDefault*returns empty/Noneso a standalone process is byte-identical, and noCONTRACT_VERSIONbump. 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.json→ccGlobal) 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/applyomitted-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 corekiroGlobalkey isomit → delete(defaults toFalse— the bundled SPA always sends it explicitly, so an omission means "not present"), while every seam scopef"{id}Global"isomit → 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.jsonlayout. Default[]so the standalone edition lists only~/.kiro/promptsuser prompts; a companion returns its resolved package prompt roots. Split out ofMcpToolingProviderinto its own Protocol (a distinct concern). v1 addition;Defaultreturns[]. -
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 traversinghome_dir, or astale_mcp_binariesentry 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.supersededis 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;Defaultreturns[].
McpToolingProvider is intentionally scoped to MCP tooling only —
extra_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.py →
agent_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.
-
browser/auth.py::register_browser_auth_provider(provider)— module-level registration hook (twin ofregister_acp_backends); everybrowser/authhelper delegates to it when present, else the OSS default.browser/cli.pyauth subcommands now delegate through the helpers. -
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_DIRSgains.midway(live SSO bearer cookie; inert on a host without~/.midway). -
config.dashboard.mwinit_flags(str) +_EDITABLE_CONFIGPATCH entry. -
config.knowledge.doc_ingest_hosts(list) — SSRF-safe allowlist for the server-side fetch path only; empty = deny-by-default. The agent-drivenauto_add_documentspath (renamed fromauto_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 atload(), re-emitted byto_dict(), so an edition section is not dropped onsave()/PATCH. Excluded from the JSON schema (build_json_schemaskips leading-underscore fields). Data-preservation half of the eventualConfigSchemaContributor; Settings-visibility half is TODO. -
ACP claude seam (all inside the dormant
_is_claudepath, inert on kiro-cli):AcpClient._claude_session_mcp_servers()(Default[]) feeds bothsession/new+session/loadmcpServers;_spawncalls the companion-attached_write_claude_local_settings()(viagetattr) on the PRIMARY spawn path;AcpClient/AcpProvidertake apermission_modekwarg (DefaultNone);acp/types.pyaddsCC_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;
InterceptDecisionenum =PROCESS | REDIRECTED | DROPPED):SlackEnterpriseGate.intercept_message(orch, *, channel, sender_id, clean_text, thread_ts, msg_ts) -> InterceptDecision— wired inslack/events.py::_route_message. DefaultPROCESS(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-modechannel_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 throughsafe_context_call(..., fallback=DROPPED): a raised adapter error degrades deny-by-default, aPlatformCompositionErroris 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 indashboard/token_auth.pyafterbind_token_peeron the first (non-cookie) exchange, withchannel/thread_tsread from the token's signedextrapayload. OBSERVER only; the anchor a challenge auth-window opens on. Default no-op.safe_context_call(fallback=None)re-raisesPlatformCompositionError— no outerexceptwraps 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: nativeslack/handler.py::handle_messageAND the defaultslack/renderer.py::SlackRenderer.on_done(thehandle_message_transportpath that normal non-review traffic uses —SlackRenderertakes auser_idkwarg for it). Decorator-introduced text is re-scanned (redact_exfiltration_urlsredact_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): theintercept_messagecall runs after the user-allowlist check and after the interceptor-specific retry-dedup guard, but BEFORE any content recording/ processing. A non-PROCESSdecision (1) records anintercept:<msg_ts>key in theSeenCacheso a Slack ack-timeout re-delivery does not re-mint the challenge (PROCESS never records, preserving the pairedapp_mention/messagedual-event and the standalone inline path), and (2) emits asel().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 theapps/registry.pyclone sites), so a companion's extended trusted-host set applies to registry-blob fetches too. The otherwrap_argvsites 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:
-
Declaration at the dataclass.
context.RESERVED_SLOTS(whole fields) andcontext.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 inPlatformContext, so the fact is visible in the source an edition author actually reads — not only in aninterfaces.pydocstring. -
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 (notisinstance) decides "is default", so a companion subclass of aDefault*adapter still warns — it can change behavior. -
An anti-rot gate.
test/test_platform_cpp_seam_coverage.pydrives offdataclasses.fields(PlatformContext)and discovers real consumption sites byastanalysis ofsrc/kiro_crew(excludingplatform/itself and_vendor/). It asserts, in both directions:- every field is EITHER consumed by non-
platformcore code OR listed inRESERVED_SLOTS— so a new field with no call site fails the build instead of becoming the next dead seam; - every
RESERVED_SLOTS/RESERVED_METHODSentry 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 bybootstrap.pyitself 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. - every field is EITHER consumed by non-
Current reserved surface:
| Slot / method | Why inert | Use 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 from | embeddings.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 point | CapabilityManager 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 only | AppsLoader.manifest_sources() / bundled_app_names() |
AgentRuntime.managed_mcp_servers | the agent config is built from the agent._MANAGED_MCP_SERVERS global directly | McpToolingProvider.extra_mcp_servers() (wired, ADD-only) |
IdentityProvider.whoami / .issuer | nothing in the core displays the principal or branches on the issuer | return 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.