feat(session-control): add agent.member_dispatch operator ceiling - #9166
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound ceiling, but its fail-closed machinery is bespoke to the new knob, leaving the sibling Watch
Suggestions
[DESIGN-REVIEWED] ebd2794 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All checks are done — the change is well-derived, with one counted sibling gap worth flagging. Final review: First-Principles-Verdict: CONCERNS The ceiling is derived and tightens-only, but it fixes a fail-open class for the new knob while leaving the same two fail-opens on What this change shipsIntent: let an operator who turned session control off keep a crew member's DM thread chat-only without disabling the member — an ADDITION, derived from a review-lane defect on the landed member-dispatch work.
Watch
[FIRST-PRINCIPLES-REVIEWED] ebd2794 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/dashboard/session_control.py:411 -- False positive or not applicable? A repository writer can comment: |
a2e7ce5 to
827d7b0
Compare
Member DM sessions bypass the agent.session_control switch by design (the zero-configuration crew-member operating model). An operator who turned session control off had no lever short of disabling the member to keep it chat-only. Add agent.member_dispatch (bool, default true) consumed at both _member_caller switch-bypass gates via a shared _member_bypass helper: at its default the bypass is unchanged (today's behaviour), set false the member falls back under agent.session_control. Reads at the gate through _safe_bool and fails closed, the same direction session_control does. The creator-ownership fence is untouched and still binds a member either way. Refs #8073
827d7b0 to
ebd2794
Compare
What is the problem?
A crew member's DM session is a conductor by design: it dispatches work into
worker sessions it creates and patrols, with zero configuration. To make that
work, a member caller bypasses the global
agent.session_controlswitch at both_member_callerswitch-bypass gates insrc/kiro_crew/dashboard/session_control.py.The consequence, flagged by the Design and First Principles lanes on the original
work: an operator who explicitly turned session control off has no lever short of
disabling the member entirely to keep that member's thread chat-only. There was
no separate switch for the member bypass.
Why it matters to the user
An operator who withdraws session control is making a deliberate statement that
agents may not open and drive each other's sessions. Today that statement is
silently not honoured for crew members, and the only workaround is a blunt one
(disable the member), which also removes the member's legitimate chat use. The
operator needs a narrow ceiling: keep the member as a chat participant while
denying it the dispatch bypass.
How the fix solves it
Add
agent.member_dispatch(bool, default true), a sibling ofagent.session_controlandagent.member_acp_backendunderAgentConfig.member.
keys only on the immutable member slot-key prefix (
_member_caller), with nooperator input.
_member_bypass(caller_key)helper gates the bypass on_member_caller(caller_key) AND member_dispatch_enabled(). Both bypass gates(
create_sessionandauthorize_target) call the one helper, so the conditioncannot drift between them.
member_dispatch_enabled()readsagent.member_dispatchat the gate, synchronously, right beside the existingsession_control_enabled()read.Default true reproduces today's behaviour exactly, so installing this change
alters nothing until an operator opts in. Set false, a member caller stops
bypassing and falls back under
agent.session_controllike any ordinary caller.The knob reads through
_safe_bool(so a quoted"false"disables the bypassrather than loading as enabled), and fails closed in the same direction
session_control_enabled()does: an unreadable config withdraws the memberbypass rather than granting it. The creator-ownership fence
(
_caller_is_ownership_fenced, and the fence-reason selector) is deliberatelyuntouched -- it binds a member to sessions it created itself either way, even
when the global switch is on.
On the two questions a permission-gate change must answer:
bypass is decided by two things read together at the gate: whether the caller
is a member (immutable slot-key prefix) and whether the operator ceiling is on
(config, read synchronously right before the act). Neither is a proxy for the
bypass; both are inputs to it, evaluated in the same synchronous window as the
existing switch read beside them.
and the only new state (
member_dispatch: false) removes a bypass -- it makesa member more restricted, never less. This is not an opt-in that unlocks a path;
it is an opt-in that closes one.
On the enterprise scope question the issue raised ("consider whether an enterprise
SCOPE_CATALOGscope should cover it"): a lookup, not a new decision.session_controlitself is a plainagent.*bool with noSCOPE_CATALOGentry, and no existing catalog scope reaches the session-control surface. A scope
would need a new governance enforcement seam rather than a data-only append, which
is out of scope for a minimal ceiling. Left to a follow-up, and stated as such in
the module doc.
Note on the issue's second half (schema-parity test)
The issue asks for a second thing: a
WORKER_*_SCHEMAsubset-ofSESSION_*_SCHEMAparity test. That half's premise is gone on
main.src/kiro_crew/mcp_tools/workers.pyand the
worker_*tools were part of PR #8021's shape; #8021 was closed bymaintainer decision, and the member-dispatch feature landed instead via #8153 with
a per-session
session_*mount. There is no second schema to hold parity with, sothis PR does not implement it. This is called out rather than silently omitted.
Note on the knob's spelling
The issue names the knob
members.dispatch, written against #8021's shape. On thelanded #8153 design there is no
members:config namespace -- member configurationlives on
agent.*(agent.member_acp_backend,agent.session_control). Thein-tree-consistent spelling is therefore
agent.member_dispatch, a sibling of theswitch it ceilings. Today's behaviour is expressible as its default (
true), sothe knob's shape is right.
What tests we did
Scoped
pytest -n0on the touched files and their session-control consumers:test/test_member_session_control.py(15 tests, 6 new inTestMemberDispatchCeiling): the_member_bypasstruth table (member ANDceiling-on required; non-member never bypasses),
member_dispatch_enabled()reading the field and failing closed on a raising config read, a member falling
back to
session_control_disabledatauthorize_targetwhen the ceiling is offand the switch is off, the default behaviour preserved (ceiling on + switch off
still passes the config gate), and the config default being
true. The existingmember-bypass tests were made robust to the new ceiling by pinning
member_dispatch_enabled=Truein their shared helper (they assert the default).test/test_session_control.py,test/test_session_control_boundaries.py,test/test_cron_session_control.py(211 tests): the gate change touches sharedcode; these consumers still pass.
test/test_config_baseline.py,test/test_config_loader.py(503 tests): the newfield round-trips through load and the committed
config-baseline.jsonsnapshotwas regenerated (
scripts/generate_config_baseline.py); the diff is exactly theone new
agent.member_dispatchentry (+15 lines), nothing else.black --target-version py310,isort,flake8clean on all four changed Pythonfiles;
mypyclean on the three changed source files. On the security posture:this is a permission-ceiling change, so I attempted the organizational
security-guidance search; it is unreachable from this session. The query I would
have run is for least-privilege operator config ceilings on an automatic grant. The
change only ever tightens (default = current behaviour, opt-in removes a bypass),
and the ceiling fails closed.
Why no screenshot: backend config + gate wiring, a docs spec update, and a unit
test. No
website/surface is touched and there is no rendered delta.Any other suggestions on the work
SCOPE_CATALOGscope for member dispatch is a real follow-up, butit needs a governance enforcement seam (a chokepoint that reads the scope), not a
catalog append -- the same reason
session_controlis not a catalog scope today.members:config namespace,agent.member_dispatchwould be the natural thing to alias/move there; until then the
agent.*siblingis the consistent home.
Pattern harvest
Defect class avoided: a permission-gate knob whose default silently shifts an
existing deployment's behaviour, or whose gate keys on a mutable proxy. Here the
default is pinned to today's behaviour (verified by a test asserting
AgentConfig().member_dispatch is Trueand by the config-baseline diff being thesingle new entry), the two gates share one extracted predicate so they cannot
drift, and the new state only ever tightens so nothing is made newly reachable.
Rule candidate: when adding an operator ceiling on an existing automatic grant,
prove three things in the PR and in a test -- (1) the default equals current
behaviour so install is a no-op, (2) the two-or-more enforcement sites share one
extracted predicate rather than copy-pasted conditions, and (3) the new
configurable state removes reach rather than adding it. If the current behaviour
cannot be expressed as a default value of the knob, the knob's shape is wrong.
Refs #8073