feat(gateway): migrate SessionsController.Seal off SessionId.IsSubAgent substring (Phase 5 / F-6 step 2b)#559
Merged
Conversation
…nt substring (Phase 5 / F-6 step 2b — closes #555) Last runtime call site migration off SessionId.IsSubAgent. The Seal endpoint sub-agent eligibility check now reads the typed SessionType discriminator (session.SessionType == AgentSubAgent) instead of parsing the SessionId for the "::subagent::" infix. SessionType is already persisted on the session row by both stores: SqliteSessionStore writes it at line 604; FileSessionStore round-trips it via the SessionMeta sidecar at line 245. Legacy rows persisted before SessionType was reliably saved are back-filled by SessionStoreBase. InferSessionType — which remains allowlisted as legacy read-path. This is the LAST runtime call site to migrate. Architecture-fence allowlist now contains only: - SessionId.cs declaration site - SessionStoreBase.InferSessionType legacy read-path bucketing - InProcessIsolationStrategy defense-in-depth OR-gate Phase 5 / F-6 is structurally complete: no production runtime code outside the fence allowlist references SessionId.IsSubAgent. Behavioural pins (3 new tests in SessionsControllerTests): - Seal_WhenSessionTypeIsAgentSubAgentButSessionIdHasNoSubAgentInfix_ AllowsSealing — typed signal is now the source of truth. A session whose SessionType is AgentSubAgent must be sealable even when the SessionId has no "::subagent::" infix (substring check incorrectly rejected this). - Seal_WhenSessionTypeIsUserAgentButSessionIdHasSubAgentInfix_Returns400 — typed signal trumps the substring. A session whose SessionType is UserAgent must NOT be sealable even when the SessionId contains "::subagent::" (substring check incorrectly accepted this). - Seal_WhenSessionTypeIsSoul_Returns400 — regression pin: Soul sessions remain non-sealable (pre-migration: !IsSubAgent caught this; post-migration: SessionType != AgentSubAgent catches it). The existing 12 Seal tests continue to pass unchanged — they use "::subagent::"-shaped SessionIds which InferSessionType correctly classifies as AgentSubAgent on GetOrCreateAsync. Validation: - Build: 0 warnings, 0 errors under TreatWarningsAsErrors. - SessionsControllerTests: 54/54 passed (15 Seal tests). - AgentKindArchitectureTests: 49/49 passed. - Full solution suite: 0 failed / 41 skipped (matches baseline). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…t fold-in) Closes the per-session authorization gap on the Seal endpoint flagged by the multi-model critique sweep for PR #555. Without this guard, any authenticated caller able to discover a sub-agent sessionId could seal that session — a control-plane integrity and DoS risk. The fix mirrors the AuthorizeSessionCaller pattern already enforced by GetMetadata and PatchMetadata in the same controller. The same gap exists on Delete / Suspend / Resume (sibling state-mutating endpoints). Deferred to a separate follow-up issue to keep this PR scoped to the Seal endpoint that #555 is migrating. Tests added (3, all RED before fix, GREEN after): - Seal_WhenCallerIdentityDoesNotMatchSessionCaller_Returns403 - Seal_WhenCallerIdentityMatchesSessionCaller_Seals - Seal_WithMissingCallerIdentity_SkipsAuthorizationCheck Build: 0 warnings / 0 errors. Full suite: 0 failed (Gateway.Tests 1819 -> 1822 = +3 tests). Refs #523, #555 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
sytone
added a commit
that referenced
this pull request
May 26, 2026
…ession endpoints (closes #558) Closes the per-session authorization gap on the three remaining state-mutating endpoints in SessionsController. Without these guards, any authenticated caller able to discover another caller's sessionId could: - Delete that session - Suspend an Active session, taking it off-line - Resume a Suspended session This mirrors the AuthorizeSessionCaller pattern already enforced on GetMetadata, PatchMetadata, and (as of PR #559) Seal. The pattern is consistent across the controller now. Behaviour: - Suspend / Resume: load session (existing 404 on missing) -> caller authorization (new) -> existing state-machine guard. - Delete: now loads the session before deleting so it can authorize. The idempotent NoContent semantic for missing sessions is preserved - surfacing 404 to authenticated probes would create an existence- disclosure oracle and break DELETE retry idempotency. Tests (9 new RED-then-GREEN, 3 per endpoint): - Delete_WhenCallerIdentityDoesNotMatchSessionCaller_Returns403_AndDoesNotDelete - Delete_WhenCallerIdentityMatchesSessionCaller_DeletesAndReturnsNoContent - Delete_WhenSessionMissing_ReturnsNoContent_PreservingIdempotency - Suspend_WhenCallerIdentityDoesNotMatchSessionCaller_Returns403_AndDoesNotChangeState - Suspend_WhenCallerIdentityMatchesSessionCaller_Suspends - Suspend_WithMissingCallerIdentity_SkipsAuthorizationCheck - Resume_WhenCallerIdentityDoesNotMatchSessionCaller_Returns403_AndDoesNotChangeState - Resume_WhenCallerIdentityMatchesSessionCaller_Resumes - Resume_WithMissingCallerIdentity_SkipsAuthorizationCheck Build: 0 warnings / 0 errors under TreatWarningsAsErrors. Full suite: 0 failed (Gateway.Tests 1822 -> 1831 = +9). Closes #558 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
sytone
added a commit
that referenced
this pull request
May 26, 2026
…ession endpoints (closes #558) (#561) Closes the per-session authorization gap on the three remaining state-mutating endpoints in SessionsController. Without these guards, any authenticated caller able to discover another caller's sessionId could: - Delete that session - Suspend an Active session, taking it off-line - Resume a Suspended session This mirrors the AuthorizeSessionCaller pattern already enforced on GetMetadata, PatchMetadata, and (as of PR #559) Seal. The pattern is consistent across the controller now. Behaviour: - Suspend / Resume: load session (existing 404 on missing) -> caller authorization (new) -> existing state-machine guard. - Delete: now loads the session before deleting so it can authorize. The idempotent NoContent semantic for missing sessions is preserved - surfacing 404 to authenticated probes would create an existence- disclosure oracle and break DELETE retry idempotency. Tests (9 new RED-then-GREEN, 3 per endpoint): - Delete_WhenCallerIdentityDoesNotMatchSessionCaller_Returns403_AndDoesNotDelete - Delete_WhenCallerIdentityMatchesSessionCaller_DeletesAndReturnsNoContent - Delete_WhenSessionMissing_ReturnsNoContent_PreservingIdempotency - Suspend_WhenCallerIdentityDoesNotMatchSessionCaller_Returns403_AndDoesNotChangeState - Suspend_WhenCallerIdentityMatchesSessionCaller_Suspends - Suspend_WithMissingCallerIdentity_SkipsAuthorizationCheck - Resume_WhenCallerIdentityDoesNotMatchSessionCaller_Returns403_AndDoesNotChangeState - Resume_WhenCallerIdentityMatchesSessionCaller_Resumes - Resume_WithMissingCallerIdentity_SkipsAuthorizationCheck Build: 0 warnings / 0 errors under TreatWarningsAsErrors. Full suite: 0 failed (Gateway.Tests 1822 -> 1831 = +9). Closes #558 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This was referenced May 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Migrates the last runtime call site for
SessionId.IsSubAgentsubstring matching off the legacy stringy check and onto the typedSession.SessionTypediscriminator. Closes #555 and completes Phase 5 / F-6 structurally — all production runtime classification now flows through the typed signal.Fold-in commit also closes a per-session authorization gap on the
Sealendpoint surfaced by the critique sweep.Changes
src/gateway/BotNexus.Gateway.Api/Controllers/SessionsController.cs!sid.IsSubAgentguard with!session.SessionType.Equals(SessionType.AgentSubAgent). (b) AddAuthorizeSessionCaller(session)check (mirrorsGetMetadata/PatchMetadata). (c) Addusing SessionType = BotNexus.Domain.Primitives.SessionType;alias.tests/architecture/BotNexus.Architecture.Tests/AgentKindArchitectureTests.csSessionsController.csallowlist entry (4 → 3 entries). Rewrite<remarks>declaring Phase 5 / F-6 structurally complete, citing #557 + #555 history.tests/gateway/BotNexus.Gateway.Tests/SessionsControllerTests.csTotal: 3 files, ~165 LoC.
Motivation
SessionsController.Sealwas the only remaining production runtime call site that classified sub-agent sessions by substring matching on the SessionId rather than reading the typedSessionTypediscriminator persisted on the session row. This caused two observable bugs:SessionType=AgentSubAgentbut a plain SessionId (no::subagent::infix) was incorrectly rejected for sealing.SessionType=UserAgentbut a SessionId that happened to contain::subagent::was incorrectly accepted for sealing.Both shapes are reachable through legitimate flows: sub-agents spawned with custom id shapes hit case (1); user-typed session ids containing the substring hit case (2). PR #557 migrated
GatewayHost.ResolveSessionTypeoff the same substring; this PR finishes the job at the controller layer.After this PR, the only
SessionId.IsSubAgentreferences left in production code are 3 allowlisted entries — all justified, with no runtime classification dependence:SessionId.cs— declaration site.SessionStoreBase.cs— read-onlyInferSessionTypeback-fill for legacy rows persisted beforeSessionTypewas reliably saved.InProcessIsolationStrategy.cs— defense-in-depth OR-gate (typed signal is the primary).The architecture fence (
AgentKindArchitectureTests) will now fail CI if any new code introduces a substring check outside the allowlist.Multi-model critique sweep
All 3 critique agents launched in parallel before opening the PR. Findings table:
seal-plan-vs-implclaude-opus-4.7seal-securitygpt-5.5SessionType.Equalsis null-safe.seal-bug-huntgpt-5.3-codexe105332f); LOW deferred (existing store integration tests already cover SessionType round-trip).HIGH (folded in)
Sealendpoint lacked per-session authorization check.GetMetadataandPatchMetadataenforceAuthorizeSessionCaller, butSealdid not — meaning any authenticated caller able to discover a sub-agent sessionId could seal someone else's session (control-plane integrity / DoS risk). Fold-in commite105332fadds the check using the established pattern, with 3 new pinning tests (mismatched → 403, matching → seal, missing identity → bypass).The same gap exists on
Delete,Suspend,Resume— left out of this PR to preserve scope, filed as #558 for a focused follow-up.LOW (deferred)
In-memory test seeding aliases the same object reference. The new typed-signal pins seed
SessionTypeviaInMemorySessionStore(which stores object references rather than serializing), so persistence-layer regressions wouldn't surface in these specific tests. Deferred becauseSessionTyperound-trip is already covered for theFileSessionStoreandSqliteSessionStorepaths inSessionStoreExistenceQueryTests.cs:189andSessionModelWave2Tests.cs:113. This PR adds controller-level pins; persistence-level pins live in their own suite.Validation
Phase 5 / F-6 status
This PR closes the last runtime call site. F-6 is structurally complete.
Closes #555
Refs #523, #557, #558