Skip to content

feat(gateway): migrate SessionsController.Seal off SessionId.IsSubAgent substring (Phase 5 / F-6 step 2b)#559

Merged
sytone merged 2 commits into
mainfrom
feat/seal-endpoint-typed-session-type
May 26, 2026
Merged

feat(gateway): migrate SessionsController.Seal off SessionId.IsSubAgent substring (Phase 5 / F-6 step 2b)#559
sytone merged 2 commits into
mainfrom
feat/seal-endpoint-typed-session-type

Conversation

@sytone

@sytone sytone commented May 26, 2026

Copy link
Copy Markdown
Owner

Summary

Migrates the last runtime call site for SessionId.IsSubAgent substring matching off the legacy stringy check and onto the typed Session.SessionType discriminator. 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 Seal endpoint surfaced by the critique sweep.

Changes

File Δ Purpose
src/gateway/BotNexus.Gateway.Api/Controllers/SessionsController.cs +17 (a) Replace !sid.IsSubAgent guard with !session.SessionType.Equals(SessionType.AgentSubAgent). (b) Add AuthorizeSessionCaller(session) check (mirrors GetMetadata / PatchMetadata). (c) Add using SessionType = BotNexus.Domain.Primitives.SessionType; alias.
tests/architecture/BotNexus.Architecture.Tests/AgentKindArchitectureTests.cs -5 / +14 Remove the SessionsController.cs allowlist entry (4 → 3 entries). Rewrite <remarks> declaring Phase 5 / F-6 structurally complete, citing #557 + #555 history.
tests/gateway/BotNexus.Gateway.Tests/SessionsControllerTests.cs +144 Six new tests: 3 typed-signal pins + 3 authorization pins.

Total: 3 files, ~165 LoC.

Motivation

SessionsController.Seal was the only remaining production runtime call site that classified sub-agent sessions by substring matching on the SessionId rather than reading the typed SessionType discriminator persisted on the session row. This caused two observable bugs:

  1. A session created with SessionType=AgentSubAgent but a plain SessionId (no ::subagent:: infix) was incorrectly rejected for sealing.
  2. A session created with SessionType=UserAgent but 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.ResolveSessionType off the same substring; this PR finishes the job at the controller layer.

After this PR, the only SessionId.IsSubAgent references left in production code are 3 allowlisted entries — all justified, with no runtime classification dependence:

  • SessionId.cs — declaration site.
  • SessionStoreBase.cs — read-only InferSessionType back-fill for legacy rows persisted before SessionType was 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:

Agent Model Verdict Action
seal-plan-vs-impl code-review / claude-opus-4.7 PASS — all 4 DoD line-items satisfied, scope-reduction explained, tests catch all 3 regression shapes (revert-to-substring, OR-combined, AND-combined). None.
seal-security security-review / gpt-5.5 PASS — no exploitable privilege-escalation, tampering, or fail-open path found; auth middleware still applies; SessionType.Equals is null-safe. None.
seal-bug-hunt rubber-duck / gpt-5.3-codex 1 HIGH + 1 LOW HIGH folded in (commit e105332f); LOW deferred (existing store integration tests already cover SessionType round-trip).

HIGH (folded in)

Seal endpoint lacked per-session authorization check. GetMetadata and PatchMetadata enforce AuthorizeSessionCaller, but Seal did 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 commit e105332f adds 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 SessionType via InMemorySessionStore (which stores object references rather than serializing), so persistence-layer regressions wouldn't surface in these specific tests. Deferred because SessionType round-trip is already covered for the FileSessionStore and SqliteSessionStore paths in SessionStoreExistenceQueryTests.cs:189 and SessionModelWave2Tests.cs:113. This PR adds controller-level pins; persistence-level pins live in their own suite.

Validation

dotnet build BotNexus.slnx --nologo --tl:off     # 0 warnings / 0 errors (TreatWarningsAsErrors=true)
dotnet test  BotNexus.slnx --no-build            # 0 failed
  - SessionsControllerTests:        57 / 57 passed (was 54 — +3 typed + +3 authz)
  - AgentKindArchitectureTests:     49 / 49 passed
  - Gateway.Tests project total:    1821 / 1822 passed (1 expected skip)

Phase 5 / F-6 status

This PR closes the last runtime call site. F-6 is structurally complete.

Closes #555
Refs #523, #557, #558

sytone and others added 2 commits May 26, 2026 13:13
…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 sytone merged commit 8696ca8 into main May 26, 2026
10 checks passed
@sytone sytone deleted the feat/seal-endpoint-typed-session-type branch May 26, 2026 20:55
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Gateway] Migrate SessionsController off SessionId.IsSubAgent (Phase 5 follow-up)

1 participant