[parked] feat(channels): govern which chat connections may attach, and control it - #2488
[parked] feat(channels): govern which chat connections may attach, and control it#2488chenmingwei23 wants to merge 1 commit into
Conversation
|
Do not merge yet — held deliberately. Kept as a draft (a draft cannot be merged, which is the only self-lock GitHub allows an author). Outstanding before this is ready:
I will mark it ready and say so here when those clear. |
UX Review (Fable 5) — 🟡 CONCERNSAdvisory UX-level review of UX-Verdict: CONCERNS The governance card makes promises the form doesn't keep: "the sender field below is not editable" while the field stays fully editable. Watch
Suggestions
[UX-REVIEWED] d1eabcc |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've now traced all the security-sensitive paths: the matcher change ( A few things I verified and cleared:
One observation I considered and dropped: the new No findings. [OPUS-REVIEWED] d1eabcc Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — 🔴 changes requested (blocking)GPT 5.6 found at least one blocking issue that must be resolved before merging This comment is updated in place on each push. BLOCKING -- src/kiro_crew/messaging/identity.py:124 -- sender policy is skipped by every inbound caller BLOCKING -- src/kiro_crew/dashboard/handlers/channel_trust.py:84 -- mutation overwrites malformed roster data BLOCKING -- src/kiro_crew/messaging/trust.py:249 -- first-run roster write is non-atomic FINDING -- src/kiro_crew/dashboard/handlers/channel_trust.py:43 -- [BLOCK-MERGE] d1eabcc |
Design Review (Fable 5) — 🟡 CONCERNSAdvisory design-level review of Design-Verdict: CONCERNS Enrolment design is sound, but the claimed sender-admission gate ships unwired — no production caller ever passes a sender — while the UI advertises it. Watch
Suggestions
[DESIGN-REVIEWED] d1eabcc |
530fc7b to
9d66181
Compare
A credential was the whole admission decision: a bot token in config plus an enabled transport meant the gateway connected it, and the only thing that could refuse was an enterprise `channels` policy most installs do not have. So the effective default was "any bot holding a token may attach", and nothing in the dashboard said which principal was driving a session, what bounded it, or offered a way to change that. Backend - messaging/connections.py: a connection is the governed principal. Its governance item is always fully qualified (`telegram/default`), so a per-bot rule is reachable and a transport that later adds a connection has to come through this gate instead of inventing its own addressing. - A `channel` matcher where a bare-transport pattern covers every connection on that transport, so each existing `channels` policy keeps its exact meaning. Posture keys resolve exact-before-container for the same reason: the queried member is qualified while every shipped posture is authored for a transport, and a plain dict lookup would miss it and fall through to a silent permit. The posture query separator becomes `#`, because a member id now contains `/`. - messaging/trust.py: an operator-owned roster of the connections allowed to attach. Absent or malformed reads as tampering and admits NOTHING, so the old default-open behaviour cannot be restored by deleting a file. A permissive roster is seeded ONCE from the already-configured channels, so an upgrade is transparent. Its path joins security._SENSITIVE_HOME_DIRS: the agent can neither read the list of principals allowed to talk to it nor add one of its own. - The inbound gate asks three fail-closed questions — enrolment, member, sender. Enrolment is re-checked per message, so revoking a bot takes effect on its next message rather than at the next restart. The sender leaf reads the policy-only `channels.posture.<connection>.senders` and INTERSECTS with the config allowlist, so it can only narrow and no install loses access. - Refusals are audited distinguishably: enrolment as rule=trust-roster / layer=operator, a sender as an item naming the `#senders` leaf. Two switches, because they have different owners - `messaging.connection_governance` in config — the operator's, and liftable from config/CLI like any other setting. - `capabilities.channel_connections` in the trust-root policy — the fleet's, and NOT liftable by the running app or its agent. A fleet whose per-surface scoping will arrive as crew members pins this off and gets the pre-roster behaviour back rather than half-adopting a surface it plans to replace. Either being off makes the feature inert: no roster is consulted, none is seeded (the file's presence is the intended-open signal), and every surface renders nothing. The switch itself is deliberately NOT fail-closed, unlike every gate behind it: a gate refusing on doubt costs one blocked message, while this refusing on doubt would stop every channel on a host that never opted out. Dashboard - GET /api/connections reports enrolment, the ceiling verdict, whether the sender list is pinned, and whether the roster was READABLE — so a surface can say "could not be read" instead of rendering a fail-closed instance as an operator who enrolled nobody. - POST /api/connections/enrol and /revoke are the control. They edit the keystone roster the same way Settings > Security edits `denied_commands.json`: under the shared config lock, atomic at 0600, the blocking read-modify-write offloaded, one SEL record per attempt, and the refreshed snapshot returned. Idempotent, and a corrupt roster is a 409 that leaves the file byte-for-byte rather than discarding what is in it. - Four surfaces, each extending something that already had the right shape: a header chip beside InboundLinkChip, a lock among the existing session-row glyphs, the Channels card that now carries the enrol/revoke control, and a list under the Security sentence that could only NAME the surfaces carrying their own ceiling. The Channels card is where the control lives because that is where an operator already goes to make a bot work: a credentialed but unenrolled connection silently fails to attach, so the page showing the credentials has to be the page that says "and it is not allowed in yet" — with the way to allow it. No new profile concept and no new bind type: per-connection control rides the existing `channels` ScopedMap, so nothing has to be unpicked when per-surface scoping moves to crew members.
9d66181 to
d1eabcc
Compare
|
Parked — do not merge. Per-surface scoping is going to arrive as crew members, so this waits for that rather than shipping a surface it would duplicate. Staying a draft on purpose (a draft cannot be merged, which is the only self-lock GitHub gives an author). The branch is pushed and the work is complete and verified, so resuming is a rebase and a re-run, not a rebuild. What is in the branch (
|
|
@chenmingwei23 The 2026-09-08 open-PR audit read this at d1eabcc and found nothing here duplicated on main, so we are not closing it. Keeping it open with a rebase path. None of this layer exists on main today: no What moved under you: main grew an unrelated Ask: rebase onto main, re-home those hunks, and rename the new surface away from Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
|
Closing this. The feature is not being pursued. Recording the salvage assessment so a future attempt does not have to re-derive it. State at close3787 commits behind main; The feature was never superseded
Every hook point still exists, in the same shapeMeasured against main @ 67716d7:
The conflict count overstates the costConflict size measures how far main moved, not how much this branch changed:
The three largest conflicts carry 25 branch lines between them: take main's file The parking premise has expiredThe capability pin existed so a fleet awaiting per-surface scoping "as crew |
1. What is the problem?
A credential was the entire admission decision for a chat connection. Putting a
bot token into
config.jsonand enabling the transport was enough: the gatewayconnected whatever it found configured, and the only thing that could refuse was
an enterprise
channelspolicy that most installs do not have. The effectivedefault was any bot holding a token may attach.
Three consequences followed:
so there was no answer to "who decided this principal may talk to my agent?".
config.json.allowed_user_ids(and weixin'sdm_policy) sit in a file the agent can rewrite, are snapshotted by thetransport at construction, and produce no per-decision audit record.
looked like any other tab, and there was no product surface for deciding which
bot may attach.
The governance model already had the right slot:
channelsis aScopedMapwithmembersand a policy-onlyposture. It was addressed per TRANSPORT, though, soa rule could only speak about a protocol, never about one of the credentials
speaking it.
2. Why this issue matters to the user
An instance is increasingly shared — a team attaches several bots to one
KiroCrew. Under a transport-grained ceiling those bots are indistinguishable, so
"the ops bot may not write files" cannot be said without also saying it about the
owner's personal bot. And because attachment was default-open, a stale credential
left in config keeps a live inbound path into the operator's agent, with nothing
in the product acknowledging it exists or letting anyone close it.
3. How our fix solves it
Symptom — any bot with a token attaches; nothing says what bounds it and
nothing lets you change it.
Root cause — the unit of governance was the transport, and there was no
operator-owned record of which connections may attach at all.
Chain:
messaging/connections.pymakes aconnection the governed principal, always fully qualified
(
telegram/default), so a per-bot rule is reachable and a transport thatlater grows a second connection must come through this gate.
channelmatchermakes a bare-transport pattern cover every connection on that transport —
same shape as the existing
@server/@server/toolrule for MCP.messaging/trust.pyholds anoperator-owned roster; a connection not on it does not connect. The roster is
a trust root (
_SENSITIVE_HOME_DIRS), so the agent can neither read the listof principals allowed to talk to it nor add one of its own.
reads as tampering and admits NOTHING — that is what stops default-open from
being restored by deleting one file. A permissive roster is seeded ONCE from
the already-configured channels, so an install whose channels worked before
keeps working.
fail-closed questions — enrolment, member, sender. The sender leaf reads the
policy-only
channels.posture.<connection>.sendersand intersects withthe config allowlist, so it can only narrow. Enrolment is re-checked per
message, so a revoke bites on the next message rather than the next restart.
POST /api/connections/enrol//revokeedit the keystone roster exactly the waySettings > Security edits
denied_commands.json— shared config lock, atomic0600 write, blocking work offloaded, one SEL record per attempt, refreshed
snapshot returned. The control sits on the Channels card because that is where
an operator already goes to make a bot work.
Two switches, because they have different owners
messaging.connection_governance(config)capabilities.channel_connections(trust-root policy)A fleet whose per-surface scoping will arrive as crew members pins the capability
off and gets the pre-roster behaviour back, rather than half-adopting a surface it
plans to replace. Either switch being off makes the feature inert: no roster is
consulted, none is seeded (the file's presence is the intended-open signal),
and every surface renders nothing.
The switch itself is deliberately not fail-closed, unlike every gate behind
it. A gate refusing on doubt costs one blocked message; this refusing on doubt
would stop every channel on a host that never opted out — an unrelated governance
hiccup becoming a total outage.
Other deliberate choices
the existing
channelsScopedMap, so nothing has to be unpicked whenper-surface scoping moves to crew members.
at all" (the operator's question); the ceiling answers "what may it then do"
(the enterprise's). Refusals are audited distinguishably —
rule=trust-roster/layer=operatorversus a policy layer — so a reader cantell "nobody approved this bot" from "the ceiling refused it".
policy sees the pages unchanged.
unreadable roster gets its own wording everywhere — and offers no write, since
the API refuses to overwrite a corrupt roster rather than discard it.
Two changes worth a reviewer's attention
/to#. A member id now contains/, so splitting on it would read one connection's posture rule as the wholetransport's. One production call site (
slack/enterprise.py) and oneconformance vector are updated; two new vectors cover container resolution.
offline. The failure is loud and the log names the marker to delete so the next
start re-seeds. That is the intended direction for a tampered trust root, but it
is a real operational edge and should be reviewed as one.
4. What tests we did
Backend — 61 new tests, plus the existing suites for every touched module:
test_channel_trust.py(16): roster load/seed semantics, fail-closed onabsent / unreadable / wrong-version, one malformed entry not disabling the
rest, a named connection not admitted by its sibling, the start gate, and the
trust-root fencing.
test_channel_trust_api.py(13): enrol takes effect with no restart, idempotentenrol/revoke, revoke matching the terse spelling too, a corrupt roster left
byte-for-byte, and the written file being 0600 and still agent-fenced.
test_channel_connection_switch.py(13): the config switch, the policy pin, aprofile-layer denial NOT counting as a pin, disabled being fully inert
(no gate, no seed, no surface), and the switch itself not fail-closing.
test_channel_inbound_governance.py(10): the three questions, revoking withouta restart, a caller that knows no sender not being failed on a leaf it cannot
answer, and the audit attribution of each refusal shape.
test_connections_endpoint.py(9): the read model, three-state verdict,roster-unreadable reporting, per-connection pinning.
test_governance_policy.py: the#separator vector updated, plus two newvectors proving a transport-authored posture still reaches a qualified
connection and that a specific posture key beats a container one.
conftest.pyseeds a roster for every test so the default test posture matchesthe default runtime posture (a fresh tmp home has none, which turned 16
pre-existing chokepoint tests red on the tamper path).
Gates, all green locally:
isort,flake8,mypy(865 files),tsc,npm run i18n:check13/13, andvitest.Verified end-to-end on an isolated pod, not just in unit tests. The enrol
button was clicked against a real gateway: the card flipped from "not allowed to
attach yet" to "is allowed to attach", the button became Revoke, and the
connection dropped out of the Security page's bounded list — proving the write
reached the roster and both surfaces read the same truth.
Known failures, characterised against a clean base
Two things fail on this host, and both fail on a clean
maincheckout too (samemachine,
PYTHONPATHforcing base sources so the comparison is base code + basetests, not branch code + base tests):
sync,
gh, systemd). 15 were branch-only and 8 base-only, but 14 of those 15come from the same test classes as the base-only 8 — one flake family sampling
differently per run. Exactly one was genuinely mine
(
test_messaging_identity.py::test_inbound_governed_deny_is_sel_audited, theaudited item becoming qualified) and it is fixed.
ContextBreakdownPanel.test.tsxhits the 15s per-test timeout.It does the same on base, and passes in isolation every time (15/15).
5. Any other suggestions on the work
not write files / may not spawn" needs a Level-2 profile, and that scoping
belongs to a crew member. Building it now would build the thing that has to be
migrated.
(
mcp_coresend_message,chat_runnermirroring,chat_compaction_notice). Nothing is broken — a transport-level rule stillcovers every connection through the
channelmatcher — but a rule naming oneconnection is enforced at attach and inbound and not on those paths. Worth
closing before multi-connection transports land; it is a four-site change.
ceiling grammar, the API and all four surfaces already handle N connections;
what remains is per-transport credential config plus teaching
link.channel_namespace_ofandsel._infer_sourcethetelegram.<name>surface segment. Landing this first means such a transport cannot return
without an admission decision.
sel._infer_sourcemis-attributes a dotted surface segment. A key liketelegram.ops-bot:…matches no namespace and falls to the trailing Slackdefault. Not fixed here on purpose: nothing on
mainproduces that shape, sothe fix belongs with whatever reintroduces named connections rather than as
dead code.
config-baseline.jsonis 19 fields stale on main. This PR adds only its ownentry rather than regenerating, to keep unrelated churn out of the diff.