From 65f8858639f8e6ff5aaf5e3245a05da83bbac242 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Tue, 11 Aug 2026 15:21:03 +0400 Subject: [PATCH 1/3] feat(profiles): add stateless profile context handles (#377) --- CHANGELOG.md | 1 + docs/library-api.md | 23 +- ...8-11-stateless-profile-context-decision.md | 8 +- package.json | 2 +- src/audit/audit-trail.ts | 11 +- src/audit/audit-types.ts | 2 + src/cli/exit-codes.ts | 4 + src/index.ts | 21 +- src/mcp/server/miftah-server.ts | 607 +++++++++++++-- src/mcp/server/operation-pipeline.ts | 6 +- src/profiles/profile-context-handle.ts | 709 ++++++++++++++++++ src/profiles/profile-manager.ts | 3 +- src/runtime/create-miftah-runtime.ts | 18 +- src/utils/errors.ts | 4 + tests/cli-exit-codes.test.ts | 4 + tests/package-contract.test.ts | 107 ++- ...ofile-context-handle-docs-contract.test.ts | 31 + tests/profile-context-handle.test.ts | 560 ++++++++++++++ tests/public-api.test.ts | 38 + .../stateless-profile-context-runtime.test.ts | 347 +++++++++ vitest.config.ts | 2 + 21 files changed, 2435 insertions(+), 73 deletions(-) create mode 100644 src/profiles/profile-context-handle.ts create mode 100644 tests/profile-context-handle-docs-contract.test.ts create mode 100644 tests/profile-context-handle.test.ts create mode 100644 tests/stateless-profile-context-runtime.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd36e50..8321a975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. The format ### Added +- [#377](https://github.com/mohanagy/miftah/issues/377) Added the opt-in production profile-context boundary for trusted modern stateless hosts. Short-lived authenticated-encrypted handles bind a named profile to the verified issuer, subject, audience, chat, deployment, and monotonic sealing-key epoch; deployment-wide revocation, exact expiry, removed-profile checks, bearer-free keyed audit correlation, audited replacement-before-revocation ordering, and fixed fail-closed errors apply on every request. Reserved tool or request metadata is stripped before audit argument capture and upstream forwarding, modern discovery is independent of prior selection calls, and existing stdio plus CLI-owned session-aware HTTP behavior remains unchanged until protocol-era negotiation is enabled separately. - [#376](https://github.com/mohanagy/miftah/issues/376) Added a public authenticated request-context boundary for future modern stateless handling. Trusted embedding hosts can provide verified issuer, subject, audience, per-chat, issuance, and expiry claims; Miftah derives only opaque deployment-bound and separately keyed audit correlations, fails closed on missing, malformed, expired, or mismatched context, and never falls back to MCP `clientInfo`, arbitrary headers, request metadata, tool arguments, or mutable profile state. The existing CLI-owned Streamable HTTP server remains the legacy session-aware path until a supported host supplies the trusted per-chat claim and modern protocol integration is enabled. ## [1.0.0] - 2026-08-11 diff --git a/docs/library-api.md b/docs/library-api.md index 98ef75f4..b95e2844 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -9,6 +9,11 @@ | `MIFTAH_VERSION` | The package version compiled into Miftah's CLI and MCP metadata. | | `CURRENT_CONFIG_VERSION` | The canonical configuration format written by current Miftah presets and examples. | | `createMiftahRuntime` | Creates an MCP wrapper from a configuration file without exposing process, profile, or server internals. | +| `ProfileContextHandleService` | Mints, resolves, replaces, and revokes short-lived opaque profile selectors for a trusted modern stateless host. | +| `ProfileContextHandleError` | Fixed-code error class that never includes a handle, decrypted payload, identity claim, or backend detail. | +| `InMemoryProfileContextRevocationStore` | Bounded same-process implementation for tests and single-process hosts; it is not deployment-wide storage. | +| `PROFILE_CONTEXT_ARGUMENT` | Reserved model-visible tool argument carrying a profile-context handle in modern mode. | +| `PROFILE_CONTEXT_META_KEY` | Reserved request metadata key carrying the same handle for methods without tool arguments. | | `createAuthenticatedRequestContextBoundary` | Derives an opaque deployment and chat binding only from claims verified by a trusted embedding host. | | `requireAuthenticatedRequestContext` | Fails closed when a modern request has no configured trusted authentication boundary. | | `AuthenticatedRequestContextError` | Fixed-code error class that carries no identity, provider, request, or key details. | @@ -18,7 +23,7 @@ | `generateConfigSchema` | Generates the editor-facing JSON Schema for the configuration contract. | | `presetConfig` | Creates a supported configuration preset in memory. | -`createMiftahRuntime` returns `MiftahRuntime`, which exposes the resolved `config`, `connect(transport)`, and `close()` methods. Supply an MCP SDK transport such as `StdioServerTransport`; transport types are provided by the direct `@modelcontextprotocol/sdk` dependency. +`createMiftahRuntime` returns `MiftahRuntime`, which exposes the resolved `config`, `connect(transport)`, and `close()` methods. Its optional `MiftahRuntimeOptions` enables the modern profile-context boundary described below. Supply an MCP SDK transport such as `StdioServerTransport`; transport types are provided by the direct `@modelcontextprotocol/sdk` dependency. ```ts import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; @@ -38,9 +43,23 @@ Claims are rejected at exact expiry. Provider failures and missing claims return The current CLI-owned Streamable HTTP server remains the documented legacy session-aware path and does not synthesize these claims from its static bearer token. Until a supported host supplies a verified per-chat claim and the modern protocol path is enabled, deploy a profile-scoped or operator-locked endpoint and do not claim chat-scoped switching. +## Stateless profile-context handles + +`ProfileContextHandleService` is the production account-selection primitive for an embedding host that has already authenticated each modern request. Pass it, together with the host's `AuthenticatedRequestContextBoundary`, through `createMiftahRuntime(configPath, { modernProfileContext })`. The configuration must enable a concrete audit journal so profile transitions cannot silently bypass their required audit commit. The host must provide its verified request result through the MCP SDK `authInfo` request field. Miftah authenticates first and derives the binding only through the configured boundary; it does not trust `clientInfo`, arbitrary headers, raw request `_meta`, tool arguments, or a model-created conversation identifier as identity. + +Each handle is a short-lived AES-256-GCM bearer bound to the deployment, sealing-key epoch, existing profile, verified issuer, subject, audience, and trusted per-chat claim. All instances in a deployment must use the same atomic `ProfileContextKeyringSnapshot` and deployment-wide `ProfileContextRevocationStore`. A retained `ProfileContextKeyEpoch` may resolve old handles only during its declared overlap, and an instance rejects minting-key rollback. Key-manager, clock, randomness, and revocation failures fail closed. The audit correlation is produced with a separate key. Never reuse the sealing key, authenticated-context binding key, or either audit key. + +`InMemoryProfileContextRevocationStore` is bounded and appropriate only for tests or multiple services in one process. A round-robin or multi-process deployment must provide shared revocation storage whose successful writes are visible to every instance before the API reports completion. The keyring provider and revocation store are trusted deployment infrastructure; the handle contains no OAuth token or upstream credential. + +In modern mode, account-sensitive tool schemas include the reserved model-visible `PROFILE_CONTEXT_ARGUMENT`. The first `miftah_use_profile` call may omit it and receives a new handle. A switch requires the current handle, commits a bearer-free audit transition, then revokes the old handle before returning its replacement. Tools that do not carry ordinary arguments use `PROFILE_CONTEXT_META_KEY`. Miftah strips either form before audit argument capture and before every upstream call, rejects duplicate or nested bearer placement, and records only the separately keyed correlation. Tool discovery is derived from configuration and does not change because a prior request selected another profile. + +A valid handle selects a profile; it is not operation authorization or idempotency. Policy, approval, identity, lease, OAuth, and upstream checks still run for every request. Missing, malformed, tampered, expired, revoked, cross-principal, cross-chat, cross-deployment, and removed-profile handles return fixed `ProfileContextHandleErrorCode` failures. The modern runtime never reads or mutates `ProfileManager`'s legacy active profile. + +The package exports `ProfileContextHandleServiceOptions`, `ModernProfileContextRuntimeOptions`, `MintedProfileContext`, `ResolvedProfileContext`, `ProfileContextReplacementAudit`, `ProfileContextKeyringProvider`, `ProfileContextKeyringSnapshot`, `ProfileContextKeyEpoch`, and `ProfileContextRevocationStore` for host integration. The current CLI-owned Streamable HTTP entry point does not enable this option; protocol-era negotiation and transport selection remain separate work, so existing stdio and session-aware HTTP behavior stays unchanged. + ## Type exports -The package root also exports `AuthenticatedRequestContext`, `AuthenticatedRequestContextBoundary`, `AuthenticatedRequestContextBoundaryOptions`, `AuthenticatedRequestContextErrorCode`, `VerifiedHttpRequestClaims`, and `VerifiedHttpRequestClaimsProvider` for the trusted host boundary. The configuration contract exposes `ActiveProfileStateScope`, `AuditConfig`, `AuditIntegrityConfig`, `AuditRotationConfig`, `GitHubProfileRoutingMatch`, `HttpServerConfig`, `IdentityConfig`, `IdentityFingerprint`, `IdentityProbeConfig`, `JiraProfileRoutingMatch`, `LinearProfileRoutingMatch`, `MiftahConfig`, `MiftahConfigVersion`, `OAuthConfig`, `OAuthConnectionConfig`, `OAuthConnectionRef`, `PluginConfig`, `PluginKind`, `PluginsConfig`, `PolicyConfig`, `PostHogProfileRoutingMatch`, `ProcessConfig`, `ProfileConfig`, `ProfileIsolationConfig`, `ProfileIsolationContainerVolume`, `ProfileIsolationFile`, `ProfileLeaseConfig`, `ProfileRoutingConfig`, `ProfileRoutingMatchConfig`, `ProfileUpstreamOverride`, `RiskLevel`, `RoutingConfig`, `RoutingMatcherPluginConfig`, `RoutingRule`, `SecurityConfig`, `SecretProviderPluginConfig`, `SentryProfileRoutingMatch`, `SecretsConfig`, `ServerConfig`, `StateConfig`, `ToolDiscoveryMode`, `ToolingConfig`, `TransportType`, `UnknownToolRisk`, `UpstreamConfig`, and `ValidatedRoutingConfig`. +The package root also exports `AuthenticatedRequestContext`, `AuthenticatedRequestContextBoundary`, `AuthenticatedRequestContextBoundaryOptions`, `AuthenticatedRequestContextErrorCode`, `VerifiedHttpRequestClaims`, and `VerifiedHttpRequestClaimsProvider` for the trusted host boundary. `MiftahRuntimeOptions`, `ModernProfileContextRuntimeOptions`, `MintedProfileContext`, `ResolvedProfileContext`, `ProfileContextHandleErrorCode`, `ProfileContextHandleServiceOptions`, `ProfileContextReplacementAudit`, `ProfileContextKeyEpoch`, `ProfileContextKeyringProvider`, `ProfileContextKeyringSnapshot`, and `ProfileContextRevocationStore` describe modern profile-context hosting. The configuration contract exposes `ActiveProfileStateScope`, `AuditConfig`, `AuditIntegrityConfig`, `AuditRotationConfig`, `GitHubProfileRoutingMatch`, `HttpServerConfig`, `IdentityConfig`, `IdentityFingerprint`, `IdentityProbeConfig`, `JiraProfileRoutingMatch`, `LinearProfileRoutingMatch`, `MiftahConfig`, `MiftahConfigVersion`, `OAuthConfig`, `OAuthConnectionConfig`, `OAuthConnectionRef`, `PluginConfig`, `PluginKind`, `PluginsConfig`, `PolicyConfig`, `PostHogProfileRoutingMatch`, `ProcessConfig`, `ProfileConfig`, `ProfileIsolationConfig`, `ProfileIsolationContainerVolume`, `ProfileIsolationFile`, `ProfileLeaseConfig`, `ProfileRoutingConfig`, `ProfileRoutingMatchConfig`, `ProfileUpstreamOverride`, `RiskLevel`, `RoutingConfig`, `RoutingMatcherPluginConfig`, `RoutingRule`, `SecurityConfig`, `SecretProviderPluginConfig`, `SentryProfileRoutingMatch`, `SecretsConfig`, `ServerConfig`, `StateConfig`, `ToolDiscoveryMode`, `ToolingConfig`, `TransportType`, `UnknownToolRisk`, `UpstreamConfig`, and `ValidatedRoutingConfig`. `MiftahConfigVersion` is the union of format versions accepted by this installed release. `CURRENT_CONFIG_VERSION` is the version generated by presets; it does not cause `loadConfig` to rewrite a legacy file. Use the explicit [configuration migration command](cli.md#migrate-config) when an on-disk upgrade is intended. diff --git a/docs/plans/2026-08-11-stateless-profile-context-decision.md b/docs/plans/2026-08-11-stateless-profile-context-decision.md index ad501fb3..5e45cdbb 100644 --- a/docs/plans/2026-08-11-stateless-profile-context-decision.md +++ b/docs/plans/2026-08-11-stateless-profile-context-decision.md @@ -1,6 +1,6 @@ # Stateless Profile Context Decision -Status: Accepted for follow-up implementation; the executable model in `tests/prototypes` is non-shipping research. +Status: Accepted. Issues #376 and #377 implement the trusted authentication and production profile-context primitives; transport negotiation and the remaining protocol-era integrations stay in the follow-ups below. The executable model in `tests/prototypes` remains non-shipping research. Issues: [#362](https://github.com/mohanagy/miftah/issues/362), [#364](https://github.com/mohanagy/miftah/issues/364), [#376](https://github.com/mohanagy/miftah/issues/376), [#377](https://github.com/mohanagy/miftah/issues/377) @@ -95,12 +95,12 @@ The model proves: - the encrypted handle contains neither the profile nor subject in plaintext; - returned results and fixed errors contain only a keyed audit correlation, never the capability bearer. -The prototype does not prove production key custody, distributed-store availability, real host chat claims, packaged SDK interoperability, or schema integration. Those remain release gates. +The prototype alone does not prove production key custody, distributed-store availability, or real host chat claims. The production implementation and its real SDK integration tests now cover schema threading, two-instance handle use, chat isolation, transition revocation, deterministic tool discovery, and bearer stripping; a production host still owns trusted claim verification, key custody, and deployment-wide revocation availability. ## Implementation follow-ups -- [#376](https://github.com/mohanagy/miftah/issues/376): establish the verified issuer/subject/audience/chat binding and safe host fallback. -- [#377](https://github.com/mohanagy/miftah/issues/377): implement production sealing, key epochs, revocation, schema threading, request-scoped resolution, and legacy separation. +- [#376](https://github.com/mohanagy/miftah/issues/376): completed the verified issuer/subject/audience/chat binding and safe host fallback. +- [#377](https://github.com/mohanagy/miftah/issues/377): implements production sealing, key epochs, revocation, schema threading, request-scoped resolution, and legacy separation. - [#363](https://github.com/mohanagy/miftah/issues/363): negotiate modern stateless and legacy session-aware protocol eras before selecting either runtime path. - [#365](https://github.com/mohanagy/miftah/issues/365): validate standard MCP routing headers independently of profile-context resolution and make catalogs deterministic/cacheable. - [#366](https://github.com/mohanagy/miftah/issues/366): bind MRTR confirmations and cancellation to the exact authenticated context, profile handle, and request. diff --git a/package.json b/package.json index d3cb83e4..6b708c3f 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "build": "tsup", "dev": "tsup --watch", "test": "vitest run", - "test:core": "vitest run tests/authenticated-request-context-docs-contract.test.ts tests/authenticated-request-context.test.ts tests/config.test.ts tests/config-loader.test.ts tests/config-diagnostics.test.ts tests/config-migration.test.ts tests/config-public-contract.test.ts tests/config-runtime-parity.test.ts tests/config-schema-contract.test.ts tests/executable-resolver.test.ts tests/http-server.test.ts tests/presets.test.ts tests/profile-manager.test.ts tests/provider-routing-matchers.test.ts tests/provider-routing-matchers-docs-contract.test.ts tests/routing-context.test.ts tests/routing-policy.test.ts tests/secret-provider-availability.test.ts tests/secret-providers.test.ts tests/secrets.test.ts tests/tooling-config.test.ts tests/windows-config-acl.test.ts tests/windows-config-migration-acl-failure.test.ts tests/windows-config-migration-acl.test.ts tests/windows-secret-process-resolution.test.ts", + "test:core": "vitest run tests/authenticated-request-context-docs-contract.test.ts tests/authenticated-request-context.test.ts tests/config.test.ts tests/config-loader.test.ts tests/config-diagnostics.test.ts tests/config-migration.test.ts tests/config-public-contract.test.ts tests/config-runtime-parity.test.ts tests/config-schema-contract.test.ts tests/executable-resolver.test.ts tests/http-server.test.ts tests/presets.test.ts tests/profile-context-handle-docs-contract.test.ts tests/profile-context-handle.test.ts tests/profile-manager.test.ts tests/provider-routing-matchers.test.ts tests/provider-routing-matchers-docs-contract.test.ts tests/routing-context.test.ts tests/routing-policy.test.ts tests/secret-provider-availability.test.ts tests/secret-providers.test.ts tests/secrets.test.ts tests/stateless-profile-context-runtime.test.ts tests/tooling-config.test.ts tests/windows-config-acl.test.ts tests/windows-config-migration-acl-failure.test.ts tests/windows-config-migration-acl.test.ts tests/windows-secret-process-resolution.test.ts", "test:oauth-console": "vitest run tests/oauth tests/remote-oauth tests/console tests/identity tests/provider-adapter-contract.test.ts tests/preset-catalog.test.ts tests/init-command.test.ts tests/cli-exit-codes.test.ts tests/cli-parse.test.ts tests/config-migration.test.ts tests/audit-integrity.test.ts", "test:package": "vitest run tests/package-contract.test.ts", "test:coverage": "vitest run --coverage", diff --git a/src/audit/audit-trail.ts b/src/audit/audit-trail.ts index c2bb2272..bcc25037 100644 --- a/src/audit/audit-trail.ts +++ b/src/audit/audit-trail.ts @@ -17,6 +17,7 @@ export interface AuditOperationInput { sourceProfile: string; profile?: string; arguments?: Record; + profileContextCorrelation?: string; } export interface AuditScopeUpdate { @@ -38,6 +39,7 @@ export interface AuditScopeUpdate { profileLeaseState?: AuditEvent["profileLeaseState"]; profileLeaseExpiresAt?: AuditEvent["profileLeaseExpiresAt"]; profileLockState?: AuditEvent["profileLockState"]; + profileContextCorrelation?: AuditEvent["profileContextCorrelation"]; } export interface AuditScopeResult { @@ -81,6 +83,7 @@ export interface AuditProfileInput { profileLeaseState?: AuditEvent["profileLeaseState"]; profileLeaseExpiresAt?: AuditEvent["profileLeaseExpiresAt"]; profileLockState?: AuditEvent["profileLockState"]; + profileContextCorrelation?: AuditEvent["profileContextCorrelation"]; status?: AuditStatus; } @@ -220,7 +223,10 @@ export class AuditTrail { ...(input.profileLeaseExpiresAt === undefined ? {} : { profileLeaseExpiresAt: input.profileLeaseExpiresAt }), - ...(input.profileLockState === undefined ? {} : { profileLockState: input.profileLockState }) + ...(input.profileLockState === undefined ? {} : { profileLockState: input.profileLockState }), + ...(input.profileContextCorrelation === undefined + ? {} + : { profileContextCorrelation: input.profileContextCorrelation }) }; } @@ -310,6 +316,9 @@ export class AuditScope { ? {} : { profileLeaseExpiresAt: this.event.profileLeaseExpiresAt }), ...(this.event.profileLockState === undefined ? {} : { profileLockState: this.event.profileLockState }), + ...(this.event.profileContextCorrelation === undefined + ? {} + : { profileContextCorrelation: this.event.profileContextCorrelation }), ...(this.event.arguments === undefined ? {} : { arguments: this.event.arguments }), ...(terminalResult.errorCode === undefined ? {} : { errorCode: terminalResult.errorCode }) }); diff --git a/src/audit/audit-types.ts b/src/audit/audit-types.ts index 2041d250..a0ae008f 100644 --- a/src/audit/audit-types.ts +++ b/src/audit/audit-types.ts @@ -78,6 +78,8 @@ export interface AuditEvent { profileLeaseState?: ProfileLeaseStatus["state"]; profileLeaseExpiresAt?: string; profileLockState?: ProfileLockStatus["state"]; + /** Keyed non-capability correlation for a validated modern profile context. */ + profileContextCorrelation?: string; operation: "tools/call" | "resources/read" | "prompts/get" | string; name: string; status: AuditStatus; diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts index bf187b03..87bf35e4 100644 --- a/src/cli/exit-codes.ts +++ b/src/cli/exit-codes.ts @@ -51,6 +51,10 @@ export const ERROR_EXIT_CODES = { PROFILE_SELECTION_REQUIRED: CLI_EXIT_CODES.policy, PROFILE_IDENTITY_SELECTION_REQUIRED: CLI_EXIT_CODES.policy, PROFILE_IDENTITY_CONFIRMATION_REQUIRED: CLI_EXIT_CODES.policy, + PROFILE_CONTEXT_UNAVAILABLE: CLI_EXIT_CODES.operation, + PROFILE_CONTEXT_INVALID: CLI_EXIT_CODES.policy, + PROFILE_CONTEXT_EXPIRED: CLI_EXIT_CODES.policy, + PROFILE_CONTEXT_REVOKED: CLI_EXIT_CODES.policy, PROFILE_STATE_WRITE_FAILED: CLI_EXIT_CODES.config, SECRET_ENV_MISSING: CLI_EXIT_CODES.secret, SECRET_PROVIDER_FAILED: CLI_EXIT_CODES.secret, diff --git a/src/index.ts b/src/index.ts index 991ecdbb..546aa22a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,13 +3,32 @@ export { MIFTAH_VERSION } from "./version.js"; export { CURRENT_CONFIG_VERSION } from "./config/versions.js"; export type { MiftahConfigVersion } from "./config/versions.js"; export { createMiftahRuntime } from "./runtime/create-miftah-runtime.js"; -export type { MiftahRuntime } from "./runtime/create-miftah-runtime.js"; +export type { MiftahRuntime, MiftahRuntimeOptions } from "./runtime/create-miftah-runtime.js"; export type { ConfigDiagnostic } from "./config/diagnostics.js"; export { AuthenticatedRequestContextError, createAuthenticatedRequestContextBoundary, requireAuthenticatedRequestContext } from "./http/authenticated-request-context.js"; +export { + InMemoryProfileContextRevocationStore, + PROFILE_CONTEXT_ARGUMENT, + PROFILE_CONTEXT_META_KEY, + ProfileContextHandleError, + ProfileContextHandleService +} from "./profiles/profile-context-handle.js"; +export type { + MintedProfileContext, + ModernProfileContextRuntimeOptions, + ProfileContextHandleErrorCode, + ProfileContextHandleServiceOptions, + ProfileContextKeyEpoch, + ProfileContextKeyringProvider, + ProfileContextKeyringSnapshot, + ProfileContextReplacementAudit, + ProfileContextRevocationStore, + ResolvedProfileContext +} from "./profiles/profile-context-handle.js"; export type { AuthenticatedRequestContext, AuthenticatedRequestContextBoundary, diff --git a/src/mcp/server/miftah-server.ts b/src/mcp/server/miftah-server.ts index 8e958215..35322129 100644 --- a/src/mcp/server/miftah-server.ts +++ b/src/mcp/server/miftah-server.ts @@ -34,6 +34,10 @@ import { type Tool } from "@modelcontextprotocol/sdk/types.js"; import type { MiftahConfig, ToolingConfig, UpstreamConfig } from "../../config/types.js"; +import { + requireAuthenticatedRequestContext, + type AuthenticatedRequestContext +} from "../../http/authenticated-request-context.js"; import type { PluginRegistry } from "../../plugins/plugin-registry.js"; import type { RemoteOAuthRuntime } from "../../oauth/remote-oauth-runtime.js"; import type { OAuthIdentityState } from "../../oauth/connection-types.js"; @@ -72,6 +76,14 @@ import { MiftahError } from "../../utils/errors.js"; import { commandInstruction } from "../../utils/shell-command.js"; import { MIFTAH_VERSION } from "../../version.js"; import { startupFailureProfile, testProfileDiagnosticCommand } from "../../upstream/startup-diagnostic.js"; +import { + PROFILE_CONTEXT_ARGUMENT, + PROFILE_CONTEXT_META_KEY, + ProfileContextHandleError, + type ModernProfileContextRuntimeOptions, + type MintedProfileContext, + type ResolvedProfileContext +} from "../../profiles/profile-context-handle.js"; import { OperationPipeline, evaluatePolicyEnforcement, @@ -187,6 +199,7 @@ interface ProfileAuditRequest { readonly operation: string; readonly name: string; readonly state?: CapturedProfileState; + readonly profileContextCorrelation?: string; }; } @@ -199,9 +212,21 @@ interface ProfileTransitionConfirmationBinding { type ProxiedRequestExtra = Pick< RequestHandlerExtra, - "_meta" | "sendNotification" | "signal" + "_meta" | "authInfo" | "sendNotification" | "signal" >; +interface ModernCallContext { + readonly authenticated: AuthenticatedRequestContext; + readonly handle?: string; + readonly resolved?: ResolvedProfileContext; +} + +interface PreparedCall { + readonly args: Record; + readonly source: ProfileStateSnapshot; + readonly modern?: ModernCallContext; +} + interface UpstreamRequestContext { readonly options: UpstreamRequestOptions; flush(): Promise; @@ -229,7 +254,9 @@ interface ResourceSubscription extends ResourceSubscriptionRoute { release?: () => void; } -type ProfileStateSnapshot = ReturnType; +type ProfileStateSnapshot = ReturnType & { + readonly profileContextCorrelation?: string; +}; const genericApprovalErrors: ApprovalErrorFactory = { required: (binding, token) => @@ -337,8 +364,18 @@ export class MiftahServer { private readonly plugins?: PluginRegistry, private readonly oauth?: RemoteOAuthRuntime, identityManager?: IdentityManager, - private readonly runtimeConfigPath?: string + private readonly runtimeConfigPath?: string, + private readonly modernProfileContext?: ModernProfileContextRuntimeOptions ) { + if ( + modernProfileContext !== undefined && + (config.audit?.enabled === false || config.audit?.path === undefined) + ) { + throw new MiftahError( + "PROFILE_CONTEXT_UNAVAILABLE", + "PROFILE_CONTEXT_UNAVAILABLE: modern profile context requires a configured audit journal" + ); + } bindProfileTransitionConfirmationVerifier(profiles, (request) => { const binding = this.profileTransitionConfirmations.get(request.proof); this.profileTransitionConfirmations.delete(request.proof); @@ -776,10 +813,174 @@ export class MiftahServer { } } + private async prepareCall( + name: string, + input: Record, + extra: ProxiedRequestExtra + ): Promise { + if (this.modernProfileContext === undefined) { + return { args: input, source: await this.captureStableProfileState() }; + } + let extracted: ReturnType; + try { + extracted = extractProfileContext(input, extra._meta); + } catch (error) { + throw this.normalizeProfileContextError(error); + } + const authenticated = await this.authenticateModernRequest(extra); + if (name === "miftah_list_profiles" || name === "miftah_validate_config") { + if (extracted.handle !== undefined) throw this.profileContextError("PROFILE_CONTEXT_INVALID"); + return { + args: extracted.args, + source: this.modernCatalogProfileState(), + modern: { authenticated } + }; + } + if (name === "miftah_use_profile" && extracted.handle === undefined) { + return { + args: extracted.args, + source: this.modernCatalogProfileState(), + modern: { authenticated } + }; + } + if (extracted.handle === undefined) throw this.profileContextError("PROFILE_CONTEXT_INVALID"); + let resolved: ResolvedProfileContext; + try { + resolved = await this.modernProfileContext.handles.resolve(extracted.handle, authenticated); + } catch (error) { + throw this.normalizeProfileContextError(error); + } + return { + args: extracted.args, + source: this.modernProfileState(resolved), + modern: { authenticated, handle: extracted.handle, resolved } + }; + } + + private async modernRequestProfileState( + extra: ProxiedRequestExtra, + forwarded?: unknown + ): Promise { + if (this.modernProfileContext === undefined) return this.captureStableProfileState(); + let extracted: ReturnType; + try { + extracted = extractProfileContext({}, extra._meta); + } catch (error) { + throw this.normalizeProfileContextError(error); + } + const authenticated = await this.authenticateModernRequest(extra); + if (extracted.handle === undefined) throw this.profileContextError("PROFILE_CONTEXT_INVALID"); + if (forwarded !== undefined && containsProfileContextBearer(forwarded, extracted.handle)) { + throw this.profileContextError("PROFILE_CONTEXT_INVALID"); + } + try { + return this.modernProfileState( + await this.modernProfileContext.handles.resolve(extracted.handle, authenticated) + ); + } catch (error) { + throw this.normalizeProfileContextError(error); + } + } + + private async authenticateModernRequest(extra: ProxiedRequestExtra): Promise { + const runtime = this.modernProfileContext; + if (runtime === undefined) throw this.profileContextError("PROFILE_CONTEXT_UNAVAILABLE"); + try { + return await requireAuthenticatedRequestContext(runtime.authenticatedRequestContext, extra.authInfo); + } catch (error) { + throw this.normalizeProfileContextError(error); + } + } + + private modernCatalogProfileState(): ProfileStateSnapshot { + const profile = this.config.security?.lockToProfile ?? this.config.defaultProfile; + this.profiles.get(profile); + return { + activeProfile: profile, + defaultProfile: this.config.defaultProfile, + revision: -1, + selectionSource: this.config.security?.lockToProfile ? "configured-lock" : "configured-default", + selectedAt: new Date(0).toISOString(), + scope: "session", + confirmation: this.config.security?.requireProfileSwitchConfirmation === true ? "not-confirmed" : "not-required", + lease: { state: "not-required" }, + lock: this.config.security?.lockToProfile + ? { state: "configured", profile: this.config.security.lockToProfile } + : { state: "none" } + }; + } + + private modernProfileState(resolved: ResolvedProfileContext): ProfileStateSnapshot { + this.profiles.get(resolved.profile); + const configuredLock = this.config.security?.lockToProfile; + if (configuredLock !== undefined && configuredLock !== resolved.profile) { + throw this.profileContextError("PROFILE_CONTEXT_INVALID"); + } + const leaseConfig = this.config.profiles[resolved.profile]?.lease; + const leaseExpiresAtMs = leaseConfig === undefined + ? undefined + : Math.min(resolved.issuedAtMs + leaseConfig.ttlMs, resolved.expiresAtMs); + return { + activeProfile: resolved.profile, + defaultProfile: this.config.defaultProfile, + revision: 0, + selectionSource: "profile-context", + selectedAt: new Date(resolved.issuedAtMs).toISOString(), + scope: "session", + confirmation: this.config.security?.requireProfileSwitchConfirmation === true ? "confirmed" : "not-required", + lease: leaseConfig === undefined || leaseExpiresAtMs === undefined + ? { state: "not-required" } + : { + state: leaseExpiresAtMs <= Date.now() ? "expired" : "active", + profile: resolved.profile, + expiresAt: new Date(leaseExpiresAtMs).toISOString(), + requiredForRisk: [...leaseConfig.requiredForRisk] + }, + lock: configuredLock === undefined + ? { state: "none" } + : { state: "configured", profile: configuredLock }, + profileContextCorrelation: resolved.auditCorrelation + }; + } + + private normalizeProfileContextError(error: unknown): MiftahError { + if (error instanceof MiftahError) return error; + let code: string | undefined; + try { + code = (error as { readonly code?: unknown } | undefined)?.code as string | undefined; + } catch { + code = undefined; + } + if (code === "AUTH_CONTEXT_UNAVAILABLE" || code === "PROFILE_CONTEXT_UNAVAILABLE") { + return this.profileContextError("PROFILE_CONTEXT_UNAVAILABLE"); + } + if (code === "AUTH_CONTEXT_EXPIRED" || code === "PROFILE_CONTEXT_EXPIRED") { + return this.profileContextError("PROFILE_CONTEXT_EXPIRED"); + } + if (code === "PROFILE_CONTEXT_REVOKED") return this.profileContextError("PROFILE_CONTEXT_REVOKED"); + return this.profileContextError("PROFILE_CONTEXT_INVALID"); + } + + private profileContextError( + code: "PROFILE_CONTEXT_UNAVAILABLE" | "PROFILE_CONTEXT_INVALID" | "PROFILE_CONTEXT_EXPIRED" | "PROFILE_CONTEXT_REVOKED" + ): MiftahError { + const message = code === "PROFILE_CONTEXT_UNAVAILABLE" + ? "Profile context is unavailable." + : code === "PROFILE_CONTEXT_EXPIRED" + ? "Profile context has expired." + : code === "PROFILE_CONTEXT_REVOKED" + ? "Profile context has been revoked." + : "Profile context is invalid."; + return new MiftahError(code, `${code}: ${message}`); + } + /** Registers the wrapper's MCP request handlers for the lifetime of this server instance. */ private registerHandlers(): void { this.server.setRequestHandler(ListToolsRequestSchema, async (_request, extra) => { - const source = await this.captureStableProfileState(); + const source = this.modernProfileContext === undefined + ? await this.captureStableProfileState() + : this.modernCatalogProfileState(); + if (this.modernProfileContext !== undefined) await this.authenticateModernRequest(extra); const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "tools/list", name: "tools", sourceProfile: source.activeProfile }, @@ -788,18 +989,30 @@ export class MiftahServer { if (upstream) audit.update({ upstream }); const { profile, snapshot } = await this.runWithUpstreamRequest( upstreamRequest, - () => this.activeToolSnapshot(upstreamRequest.options) + () => this.modernProfileContext === undefined + ? this.activeToolSnapshot(upstreamRequest.options) + : this.profileToolSnapshot(source.activeProfile, upstreamRequest.options) ); audit.update({ profile }); - return { tools: [...this.visibleManagementTools(), ...snapshot.getTools()] }; + return { + tools: [ + ...this.visibleManagementTools(), + ...snapshot.getTools().map((tool) => this.profileContextTool(tool, true)) + ] + }; } ); }); this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { const name = request.params.name; - const args = request.params.arguments ?? {}; - const source = await this.captureStableProfileState(); + let prepared: PreparedCall; + try { + prepared = await this.prepareCall(name, request.params.arguments ?? {}, extra); + } catch (error) { + return textResult(this.toSafeError(error).message, true); + } + const { args, source } = prepared; const isManagementTool = isManagementToolName(name); const isApprovalManagementTool = name === "miftah_approve" || name === "miftah_deny"; const upstreamRequest = this.upstreamRequestContext(extra); @@ -808,6 +1021,9 @@ export class MiftahServer { operation: isManagementTool ? managementOperation(name) : "tools/call", name: isManagementTool ? managementName(name, args) : name, sourceProfile: source.activeProfile, + ...(source.profileContextCorrelation === undefined + ? {} + : { profileContextCorrelation: source.profileContextCorrelation }), ...(isApprovalManagementTool ? {} : { arguments: args }) }, (audit) => @@ -821,7 +1037,8 @@ export class MiftahServer { audit, source, { requestId: extra.requestId, signal: extra.signal }, - upstreamRequest + upstreamRequest, + prepared.modern ) ) : this.handleUpstreamTool( @@ -843,14 +1060,20 @@ export class MiftahServer { if (this.resourcePromptProxy.available) { const upstreamName = this.resourcePromptProxy.upstreamName; this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async (request, extra) => { - const source = await this.captureStableProfileState(); + const params = this.modernProfileContext === undefined + ? request.params + : stripProfileContextMetadata(request.params); + const source = await this.modernRequestProfileState(extra, params); const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "resources/templates/list", name: "resource-templates", sourceProfile: source.activeProfile, - arguments: request.params ?? {} + ...(source.profileContextCorrelation === undefined + ? {} + : { profileContextCorrelation: source.profileContextCorrelation }), + arguments: params?.cursor === undefined ? {} : { cursor: params.cursor } }, async (audit) => this.runWithUpstreamRequest(upstreamRequest, async () => { const upstream = this.resourcePromptRegistry ? undefined : this.auditUpstreamName(upstreamName); @@ -859,7 +1082,7 @@ export class MiftahServer { try { return await this.resourcePromptRegistry.listResourceTemplates( source.activeProfile, - request.params?.cursor, + params?.cursor, upstreamRequest.options ); } finally { @@ -867,22 +1090,28 @@ export class MiftahServer { } } return redactDirectResourceTemplateList( - await this.discoverResourceTemplates(source.activeProfile, upstreamName, request.params, upstreamRequest.options) + await this.discoverResourceTemplates(source.activeProfile, upstreamName, params, upstreamRequest.options) ); }) ); }); this.server.setRequestHandler(SubscribeRequestSchema, async (request, extra) => { - const source = await this.captureStableProfileState(); + const params = this.modernProfileContext === undefined + ? request.params + : stripProfileContextMetadata(request.params); + const source = await this.modernRequestProfileState(extra, params); const approvalContext: ApprovalRequestContext = { requestId: extra.requestId, signal: extra.signal }; const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "resources/subscribe", - name: this.redactor.redactUri(request.params.uri), + name: this.redactor.redactUri(params.uri), sourceProfile: source.activeProfile, - arguments: { uri: this.redactor.redactUri(request.params.uri) } + ...(source.profileContextCorrelation === undefined + ? {} + : { profileContextCorrelation: source.profileContextCorrelation }), + arguments: { uri: this.redactor.redactUri(params.uri) } }, async (audit) => { if (!this.resourceSubscriptionsAvailable) { @@ -891,22 +1120,28 @@ export class MiftahServer { "RESOURCE_SUBSCRIPTION_UNSUPPORTED: no active upstream supports resource subscriptions" ); } - await this.subscribeResource(source, upstreamName, request.params, audit, approvalContext, upstreamRequest); + await this.subscribeResource(source, upstreamName, params, audit, approvalContext, upstreamRequest); return {}; } ); }); this.server.setRequestHandler(UnsubscribeRequestSchema, async (request, extra) => { - const source = await this.captureStableProfileState(); + const params = this.modernProfileContext === undefined + ? request.params + : stripProfileContextMetadata(request.params); + const source = await this.modernRequestProfileState(extra, params); const approvalContext: ApprovalRequestContext = { requestId: extra.requestId, signal: extra.signal }; const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "resources/unsubscribe", - name: this.redactor.redactUri(request.params.uri), + name: this.redactor.redactUri(params.uri), sourceProfile: source.activeProfile, - arguments: { uri: this.redactor.redactUri(request.params.uri) } + ...(source.profileContextCorrelation === undefined + ? {} + : { profileContextCorrelation: source.profileContextCorrelation }), + arguments: { uri: this.redactor.redactUri(params.uri) } }, async (audit) => { if (!this.resourceSubscriptionsAvailable) { @@ -915,21 +1150,27 @@ export class MiftahServer { "RESOURCE_SUBSCRIPTION_UNSUPPORTED: no active upstream supports resource subscriptions" ); } - await this.unsubscribeResource(source, request.params, audit, approvalContext, upstreamRequest); + await this.unsubscribeResource(source, params, audit, approvalContext, upstreamRequest); return {}; } ); }); this.server.setRequestHandler(ListResourcesRequestSchema, async (request, extra) => { - const source = await this.captureStableProfileState(); + const params = this.modernProfileContext === undefined + ? request.params + : stripProfileContextMetadata(request.params); + const source = await this.modernRequestProfileState(extra, params); const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "resources/list", name: "resources", sourceProfile: source.activeProfile, - arguments: request.params ?? {} + ...(source.profileContextCorrelation === undefined + ? {} + : { profileContextCorrelation: source.profileContextCorrelation }), + arguments: params?.cursor === undefined ? {} : { cursor: params.cursor } }, async (audit) => this.runWithUpstreamRequest(upstreamRequest, async () => { const upstream = this.resourcePromptRegistry ? undefined : this.auditUpstreamName(upstreamName); @@ -938,7 +1179,7 @@ export class MiftahServer { try { return await this.resourcePromptRegistry.listResources( source.activeProfile, - request.params?.cursor, + params?.cursor, upstreamRequest.options ); } finally { @@ -946,45 +1187,57 @@ export class MiftahServer { } } return redactDirectResourceList( - await this.discoverResources(source.activeProfile, upstreamName, request.params, upstreamRequest.options) + await this.discoverResources(source.activeProfile, upstreamName, params, upstreamRequest.options) ); }) ); }); this.server.setRequestHandler(ReadResourceRequestSchema, async (request, extra) => { - const source = await this.captureStableProfileState(); + const params = this.modernProfileContext === undefined + ? request.params + : stripProfileContextMetadata(request.params); + const source = await this.modernRequestProfileState(extra, params); const approvalContext: ApprovalRequestContext = { requestId: extra.requestId, signal: extra.signal }; const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "resources/read", - name: this.redactor.redactUri(request.params.uri), + name: this.redactor.redactUri(params.uri), sourceProfile: source.activeProfile, - arguments: { uri: this.redactor.redactUri(request.params.uri) } + ...(source.profileContextCorrelation === undefined + ? {} + : { profileContextCorrelation: source.profileContextCorrelation }), + arguments: { uri: this.redactor.redactUri(params.uri) } }, async (audit) => { if (this.resourcePromptRegistry) { try { - return await this.executeResourceRead(source, upstreamName, request.params, audit, approvalContext, upstreamRequest); + return await this.executeResourceRead(source, upstreamName, params, audit, approvalContext, upstreamRequest); } finally { await this.notifyResourceAvailabilityChange(source.activeProfile); } } - return this.executeResourceRead(source, upstreamName, request.params, audit, approvalContext, upstreamRequest); + return this.executeResourceRead(source, upstreamName, params, audit, approvalContext, upstreamRequest); } ); }); this.server.setRequestHandler(ListPromptsRequestSchema, async (request, extra) => { - const source = await this.captureStableProfileState(); + const params = this.modernProfileContext === undefined + ? request.params + : stripProfileContextMetadata(request.params); + const source = await this.modernRequestProfileState(extra, params); const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "prompts/list", name: "prompts", sourceProfile: source.activeProfile, - arguments: request.params ?? {} + ...(source.profileContextCorrelation === undefined + ? {} + : { profileContextCorrelation: source.profileContextCorrelation }), + arguments: params?.cursor === undefined ? {} : { cursor: params.cursor } }, async (audit) => this.runWithUpstreamRequest(upstreamRequest, async () => { const upstream = this.resourcePromptRegistry ? undefined : this.auditUpstreamName(upstreamName); @@ -993,7 +1246,7 @@ export class MiftahServer { try { return await this.resourcePromptRegistry.listPrompts( source.activeProfile, - request.params?.cursor, + params?.cursor, upstreamRequest.options ); } finally { @@ -1001,32 +1254,38 @@ export class MiftahServer { } } return redactDirectPromptList( - await this.discoverPrompts(source.activeProfile, upstreamName, request.params, upstreamRequest.options) + await this.discoverPrompts(source.activeProfile, upstreamName, params, upstreamRequest.options) ); }) ); }); this.server.setRequestHandler(GetPromptRequestSchema, async (request, extra) => { - const source = await this.captureStableProfileState(); + const params = this.modernProfileContext === undefined + ? request.params + : stripProfileContextMetadata(request.params); + const source = await this.modernRequestProfileState(extra, params); const approvalContext: ApprovalRequestContext = { requestId: extra.requestId, signal: extra.signal }; const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "prompts/get", - name: request.params.name, + name: params.name, sourceProfile: source.activeProfile, - arguments: { ...(request.params.arguments ?? {}), name: request.params.name } + ...(source.profileContextCorrelation === undefined + ? {} + : { profileContextCorrelation: source.profileContextCorrelation }), + arguments: { ...(params.arguments ?? {}), name: params.name } }, async (audit) => { if (this.resourcePromptRegistry) { try { - return await this.executePromptGet(source, upstreamName, request.params, audit, approvalContext, upstreamRequest); + return await this.executePromptGet(source, upstreamName, params, audit, approvalContext, upstreamRequest); } finally { await this.notifyPromptAvailabilityChange(source.activeProfile); } } - return this.executePromptGet(source, upstreamName, request.params, audit, approvalContext, upstreamRequest); + return this.executePromptGet(source, upstreamName, params, audit, approvalContext, upstreamRequest); } ); }); @@ -1168,7 +1427,8 @@ export class MiftahServer { audit: AuditScope, source: ProfileStateSnapshot, approvalContext?: ApprovalRequestContext, - upstreamRequest?: UpstreamRequestContext + upstreamRequest?: UpstreamRequestContext, + modern?: ModernCallContext ): Promise { if (name === "miftah_list_profiles") { const activeProfile = source.activeProfile; @@ -1190,6 +1450,10 @@ export class MiftahServer { } if (name === "miftah_use_profile") { const profile = requiredString(args, "profile"); + if (this.modernProfileContext !== undefined) { + if (modern === undefined) throw this.profileContextError("PROFILE_CONTEXT_INVALID"); + return this.handleModernProfileSelection("switch", profile, source, modern, audit, approvalContext); + } const transition = await this.requireProfileTransitionConfirmation( "switch", profile, @@ -1230,6 +1494,12 @@ export class MiftahServer { }); } if (name === "miftah_lock_profile") { + if (this.modernProfileContext !== undefined) { + throw new MiftahError( + "PROFILE_LOCKING_DISABLED", + "PROFILE_LOCKING_DISABLED: connection-bound profile locks are unavailable in modern stateless mode" + ); + } return this.enqueueProfileTransition(async () => { const locked = await this.profiles.mutateAudited( () => this.profiles.lock(), @@ -1246,6 +1516,12 @@ export class MiftahServer { }); } if (name === "miftah_unlock_profile") { + if (this.modernProfileContext !== undefined) { + throw new MiftahError( + "PROFILE_LOCKING_DISABLED", + "PROFILE_LOCKING_DISABLED: connection-bound profile locks are unavailable in modern stateless mode" + ); + } return this.enqueueProfileTransition(async () => { const unlocked = await this.profiles.mutateAudited( () => this.profiles.unlock(), @@ -1263,6 +1539,10 @@ export class MiftahServer { } if (name === "miftah_reset_profile") { const profile = source.defaultProfile; + if (this.modernProfileContext !== undefined) { + if (modern === undefined) throw this.profileContextError("PROFILE_CONTEXT_INVALID"); + return this.handleModernProfileSelection("reset", profile, source, modern, audit, approvalContext); + } const transition = await this.requireProfileTransitionConfirmation( "reset", profile, @@ -1507,6 +1787,105 @@ export class MiftahServer { }; } + private async handleModernProfileSelection( + action: "switch" | "reset", + profile: string, + source: ProfileStateSnapshot, + modern: ModernCallContext, + audit: AuditScope, + approvalContext?: ApprovalRequestContext + ): Promise { + const runtime = this.modernProfileContext; + if (runtime === undefined) throw this.profileContextError("PROFILE_CONTEXT_INVALID"); + this.profiles.get(profile); + if ( + this.config.security?.allowProfileSwitchingFromMcp === false || + this.config.security?.lockToProfile !== undefined + ) { + throw new MiftahError("PROFILE_SWITCH_DISABLED", "PROFILE_SWITCH_DISABLED: profile switching is disabled"); + } + await this.requireModernProfileTransitionConfirmation(action, profile, source, approvalContext); + const lifetimeMs = runtime.handleLifetimeMs ?? 15 * 60_000; + let minted: MintedProfileContext; + try { + if (modern.handle === undefined) { + minted = await runtime.handles.mint(profile, modern.authenticated, lifetimeMs); + const replacement = modernResolved(minted); + try { + await this.writeProfileAction(action, { + sourceProfile: source.activeProfile, + profile, + operation: `profiles/${action}`, + name: profile, + state: this.modernProfileState(replacement), + profileContextCorrelation: replacement.auditCorrelation + }); + } catch (error) { + try { + await runtime.handles.revoke(minted.handle, modern.authenticated); + } catch { + // The undisclosed replacement remains inaccessible to the caller. + } + throw error; + } + } else { + minted = await runtime.handles.replace( + modern.handle, + profile, + modern.authenticated, + lifetimeMs, + async ({ previous, replacement }) => { + await this.writeProfileAction(action, { + sourceProfile: previous.profile, + profile, + operation: `profiles/${action}`, + name: profile, + state: this.modernProfileState(replacement), + profileContextCorrelation: replacement.auditCorrelation + }); + } + ); + } + } catch (error) { + throw this.normalizeProfileContextError(error); + } + const replacement = modernResolved(minted); + audit.update({ + name: profile, + profile, + profileContextCorrelation: replacement.auditCorrelation + }); + return textResult(JSON.stringify({ + profileContext: { + handle: minted.handle, + profile: minted.profile, + expiresAt: new Date(minted.expiresAtMs).toISOString() + } + })); + } + + private async requireModernProfileTransitionConfirmation( + action: "switch" | "reset", + profile: string, + source: ProfileStateSnapshot, + context?: ApprovalRequestContext + ): Promise { + if (this.config.security?.requireProfileSwitchConfirmation !== true) return; + await this.requireApproval( + { + sourceProfile: source.activeProfile, + profile, + upstream: "profiles", + operation: `profiles/${action}`, + name: profile, + displayName: `profile '${profile}'`, + arguments: { profile } + }, + context, + profileSwitchApprovalErrors + ); + } + private async requireProfileTransitionConfirmation( action: "switch" | "reset", profile: string, @@ -1826,7 +2205,18 @@ export class MiftahServer { /** Returns only management tools valid for the configured approval mode. */ private visibleManagementTools() { - return managementTools({ delegatedAgentApproval: this.delegatedAgentApprovalEnabled() }); + const tools = managementTools({ delegatedAgentApproval: this.delegatedAgentApprovalEnabled() }); + if (this.modernProfileContext === undefined) return tools; + return tools + .filter((tool) => tool.name !== "miftah_lock_profile" && tool.name !== "miftah_unlock_profile") + .map((tool) => { + if (tool.name === "miftah_list_profiles" || tool.name === "miftah_validate_config") return tool; + return this.profileContextTool(tool, tool.name !== "miftah_use_profile"); + }); + } + + private profileContextTool(tool: Tool, required: boolean): Tool { + return this.modernProfileContext === undefined ? tool : withProfileContextInput(tool, required); } /** Fails closed before a direct approval-management call when delegation is not configured. */ @@ -1856,6 +2246,7 @@ export class MiftahServer { operation: string; name: string; state?: CapturedProfileState; + profileContextCorrelation?: string; } ): Promise { await this.writeProfileActions([{ action, input }]); @@ -1869,7 +2260,7 @@ export class MiftahServer { action: ProfileAuditAction, input: ProfileAuditRequest["input"] ): AuditProfileInput { - const { state, ...event } = input; + const { state, profileContextCorrelation, ...event } = input; const current = state ?? this.profiles.current(); return { profileAction: action, @@ -1878,7 +2269,10 @@ export class MiftahServer { profileConfirmation: current.confirmation, profileLeaseState: current.lease.state, ...("expiresAt" in current.lease ? { profileLeaseExpiresAt: current.lease.expiresAt } : {}), - profileLockState: current.lock.state + profileLockState: current.lock.state, + ...(profileContextCorrelation === undefined + ? {} + : { profileContextCorrelation }) }; } @@ -2652,6 +3046,20 @@ export class MiftahServer { } } + private async profileToolSnapshot( + profile: string, + options?: UpstreamRequestOptions + ): Promise<{ profile: string; snapshot: ToolSnapshot }> { + const loaded = await this.loadToolSnapshotForList(profile, options); + try { + loaded.finish(true); + return { profile, snapshot: loaded.snapshot }; + } catch (error) { + loaded.finish(false); + throw error; + } + } + private enqueueProfileTransition(operation: () => Promise): Promise { const result = this.profileTransitions.then(operation, operation); this.profileTransitions = result.then( @@ -2896,6 +3304,119 @@ export class MiftahServer { } } +function extractProfileContext( + input: Record, + meta: ProxiedRequestExtra["_meta"] +): { readonly args: Record; readonly handle?: string } { + let argumentHandle: unknown; + let metadataHandle: unknown; + try { + argumentHandle = input[PROFILE_CONTEXT_ARGUMENT]; + metadataHandle = meta?.[PROFILE_CONTEXT_META_KEY]; + } catch { + throw new ProfileContextHandleError("PROFILE_CONTEXT_INVALID"); + } + if ( + (argumentHandle !== undefined && typeof argumentHandle !== "string") || + (metadataHandle !== undefined && typeof metadataHandle !== "string") || + (argumentHandle !== undefined && metadataHandle !== undefined && argumentHandle !== metadataHandle) + ) { + throw new ProfileContextHandleError("PROFILE_CONTEXT_INVALID"); + } + const handle = (argumentHandle ?? metadataHandle) as string | undefined; + const args: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (key !== PROFILE_CONTEXT_ARGUMENT) args[key] = value; + } + if (handle !== undefined && containsProfileContextBearer(args, handle)) { + throw new ProfileContextHandleError("PROFILE_CONTEXT_INVALID"); + } + return Object.freeze({ args: Object.freeze(args), ...(handle === undefined ? {} : { handle }) }); +} + +function containsProfileContextBearer(value: unknown, expected: string, seen = new Set()): boolean { + if (typeof value === "string") return value.includes(expected); + if (typeof value !== "object" || value === null) return false; + if (seen.has(value)) return false; + seen.add(value); + if (Array.isArray(value)) return value.some((entry) => containsProfileContextBearer(entry, expected, seen)); + try { + return Object.values(value).some((entry) => containsProfileContextBearer(entry, expected, seen)); + } catch { + throw new ProfileContextHandleError("PROFILE_CONTEXT_INVALID"); + } +} + +function stripProfileContextMetadata(params: Params): Params { + try { + if (typeof params !== "object" || params === null || Array.isArray(params)) return params; + const record = params as Record; + const metadata = record._meta; + if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) return params; + const sanitizedMetadata = Object.fromEntries( + Object.entries(metadata).filter(([key]) => key !== PROFILE_CONTEXT_META_KEY) + ); + const { _meta: _discarded, ...rest } = record; + void _discarded; + return Object.freeze({ + ...rest, + ...(Object.keys(sanitizedMetadata).length === 0 + ? {} + : { _meta: Object.freeze(sanitizedMetadata) }) + }) as Params; + } catch { + throw new ProfileContextHandleError("PROFILE_CONTEXT_INVALID"); + } +} + +function withProfileContextInput(tool: Tool, required: boolean): Tool { + const cloned = structuredClone(tool); + const schema = cloned.inputSchema; + const properties = schema.properties ?? {}; + if (typeof properties !== "object" || properties === null || Array.isArray(properties)) { + throw new MiftahError("TOOL_COLLISION", `TOOL_COLLISION: tool '${tool.name}' has an invalid input schema`); + } + if (Object.hasOwn(properties, PROFILE_CONTEXT_ARGUMENT)) { + throw new MiftahError( + "TOOL_COLLISION", + `TOOL_COLLISION: tool '${tool.name}' uses Miftah's reserved profile-context input` + ); + } + const requiredInputs = Array.isArray(schema.required) + ? schema.required.filter((entry): entry is string => typeof entry === "string") + : []; + return { + ...cloned, + description: `${cloned.description ?? ""}${cloned.description ? " " : ""}Modern stateless requests ${ + required ? "must" : "may" + } include ${PROFILE_CONTEXT_ARGUMENT}.`, + inputSchema: { + ...schema, + properties: { + ...properties, + [PROFILE_CONTEXT_ARGUMENT]: { + type: "string", + description: "Opaque Miftah profile-context handle. Miftah strips this before upstream execution." + } + }, + ...(required + ? { required: [...new Set([...requiredInputs, PROFILE_CONTEXT_ARGUMENT])] } + : requiredInputs.length === 0 + ? {} + : { required: requiredInputs }) + } + }; +} + +function modernResolved(minted: MintedProfileContext): ResolvedProfileContext { + return Object.freeze({ + profile: minted.profile, + auditCorrelation: minted.auditCorrelation, + issuedAtMs: minted.issuedAtMs, + expiresAtMs: minted.expiresAtMs + }); +} + function requiredString(args: Record, key: string): string { const value = args[key]; if (typeof value !== "string" || value.length === 0) { diff --git a/src/mcp/server/operation-pipeline.ts b/src/mcp/server/operation-pipeline.ts index 844681f2..5c23f046 100644 --- a/src/mcp/server/operation-pipeline.ts +++ b/src/mcp/server/operation-pipeline.ts @@ -24,7 +24,7 @@ export type ProxiedOperationType = export type CapturedProfileState = Pick< ReturnType, "activeProfile" | "revision" | "selectionSource" | "confirmation" | "lease" | "lock" ->; +> & { readonly profileContextCorrelation?: string }; export interface ApprovalRequestContext { readonly requestId: string | number; @@ -263,6 +263,7 @@ export class OperationPipeline { private hasExplicitCurrentSessionSelection(source: CapturedProfileState, profile: string): boolean { if (source.activeProfile !== profile) return false; if (source.lock.state === "configured" && source.lock.profile === profile) return true; + if (source.selectionSource === "profile-context") return true; return ( (source.selectionSource === "mcp-switch" || source.selectionSource === "reset") && source.confirmation !== "not-confirmed" @@ -335,6 +336,9 @@ export class OperationPipeline { profileConfirmation: source.confirmation, profileLeaseState: source.lease.state, profileLockState: source.lock.state, + ...(source.profileContextCorrelation === undefined + ? {} + : { profileContextCorrelation: source.profileContextCorrelation }), ...("expiresAt" in source.lease ? { profileLeaseExpiresAt: source.lease.expiresAt } : {}) }); } diff --git a/src/profiles/profile-context-handle.ts b/src/profiles/profile-context-handle.ts new file mode 100644 index 00000000..cc8760ca --- /dev/null +++ b/src/profiles/profile-context-handle.ts @@ -0,0 +1,709 @@ +import { + createCipheriv, + createDecipheriv, + createHmac, + randomBytes as nodeRandomBytes, + timingSafeEqual +} from "node:crypto"; +import type { + AuthenticatedRequestContext, + AuthenticatedRequestContextBoundary +} from "../http/authenticated-request-context.js"; + +export const PROFILE_CONTEXT_ARGUMENT = "_miftah_profile_context"; +export const PROFILE_CONTEXT_META_KEY = "miftah/profile-context"; + +const handlePrefix = "mctx1"; +const auditCorrelationPrefix = "mctxc1."; +const maximumHandleBytes = 4_096; +const maximumTextBytes = 4_096; +const keyBytes = 32; +const initializationVectorBytes = 12; +const authenticationTagBytes = 16; +const identifierBytes = 16; +const defaultMaximumLifetimeMs = 15 * 60_000; +const defaultClockSkewMs = 30_000; +const base64UrlPattern = /^[A-Za-z0-9_-]+$/u; +const decimalPattern = /^(?:0|[1-9]\d*)$/u; + +export type ProfileContextHandleErrorCode = + | "PROFILE_CONTEXT_UNAVAILABLE" + | "PROFILE_CONTEXT_INVALID" + | "PROFILE_CONTEXT_EXPIRED" + | "PROFILE_CONTEXT_REVOKED"; + +const safeMessages: Readonly> = Object.freeze({ + PROFILE_CONTEXT_UNAVAILABLE: "Profile context is unavailable.", + PROFILE_CONTEXT_INVALID: "Profile context is invalid.", + PROFILE_CONTEXT_EXPIRED: "Profile context has expired.", + PROFILE_CONTEXT_REVOKED: "Profile context has been revoked." +}); + +/** A fixed, bearer-free failure emitted by the production profile-context boundary. */ +export class ProfileContextHandleError extends Error { + readonly code: ProfileContextHandleErrorCode; + + constructor(code: ProfileContextHandleErrorCode) { + super(safeMessages[code] ?? safeMessages.PROFILE_CONTEXT_INVALID); + Object.defineProperty(this, "name", { value: "ProfileContextHandleError", configurable: true }); + this.code = Object.hasOwn(safeMessages, code) ? code : "PROFILE_CONTEXT_INVALID"; + } +} + +/** One deployment sealing epoch. Only the snapshot active epoch may mint. */ +export interface ProfileContextKeyEpoch { + readonly epoch: number; + readonly key: Uint8Array; + readonly activatedAtMs: number; + /** Required for a retained resolution-only epoch and forbidden on the active epoch. */ + readonly resolveUntilMs?: number; +} + +/** Atomic key-manager view shared by every instance in one deployment. */ +export interface ProfileContextKeyringSnapshot { + readonly activeEpoch: number; + readonly epochs: readonly ProfileContextKeyEpoch[]; +} + +export type ProfileContextKeyringProvider = + () => ProfileContextKeyringSnapshot | Promise; + +/** Deployment-wide bounded revocation state. Backend failures must reject. */ +export interface ProfileContextRevocationStore { + isRevoked(id: string, atMs: number): boolean | Promise; + revoke(id: string, expiresAtMs: number): void | Promise; +} + +export interface ProfileContextHandleServiceOptions { + readonly deploymentId: string; + readonly profiles: readonly string[]; + readonly keyringProvider: ProfileContextKeyringProvider; + readonly auditKey: Uint8Array; + readonly revocations: ProfileContextRevocationStore; + readonly maximumLifetimeMs?: number; + readonly clockSkewMs?: number; + readonly clock?: () => number; + /** Internal deterministic-test seam. Production callers should omit it. */ + readonly randomBytes?: (size: number) => Uint8Array; +} + +/** Opt-in bridge used only by a host that supplies verified request authentication. */ +export interface ModernProfileContextRuntimeOptions { + readonly handles: ProfileContextHandleService; + readonly authenticatedRequestContext: AuthenticatedRequestContextBoundary; + readonly handleLifetimeMs?: number; +} + +export interface MintedProfileContext { + /** Model-visible bearer. Never place it in logs, diagnostics, audit, or upstream arguments. */ + readonly handle: string; + readonly profile: string; + readonly auditCorrelation: string; + readonly issuedAtMs: number; + readonly expiresAtMs: number; +} + +export interface ResolvedProfileContext { + readonly profile: string; + readonly auditCorrelation: string; + readonly issuedAtMs: number; + readonly expiresAtMs: number; +} + +export interface ProfileContextReplacementAudit { + readonly previous: ResolvedProfileContext; + readonly replacement: ResolvedProfileContext; +} + +interface ProfileContextPayload { + readonly version: 1; + readonly id: string; + readonly deploymentId: string; + readonly keyEpoch: number; + readonly profile: string; + readonly binding: string; + readonly issuedAtMs: number; + readonly expiresAtMs: number; +} + +interface ValidatedKeyring { + readonly activeEpoch: number; + readonly active: ProfileContextKeyEpoch & { readonly key: Buffer }; + readonly epochs: ReadonlyMap; +} + +interface OpenedProfileContext { + readonly payload: ProfileContextPayload; + readonly resolved: ResolvedProfileContext; +} + +/** + * Production, request-scoped profile selector for modern stateless MCP hosts. + * + * The service authenticates selection only. Callers must authenticate first and + * continue to enforce policy, approvals, identity, and operation authorization. + */ +export class ProfileContextHandleService { + private readonly deploymentId: string; + private readonly profiles: ReadonlySet; + private readonly keyringProvider: ProfileContextKeyringProvider; + private readonly auditKey: Buffer; + private readonly revocations: ProfileContextRevocationStore; + private readonly maximumLifetimeMs: number; + private readonly clockSkewMs: number; + private readonly clock: () => number; + private readonly randomBytes: (size: number) => Uint8Array; + private highestActiveEpoch = -1; + private highestActiveKey: Buffer | undefined; + + constructor(options: ProfileContextHandleServiceOptions) { + if (typeof options !== "object" || options === null) throw invalid(); + this.deploymentId = boundedText(readProperty(options, "deploymentId")); + const profiles = readProperty(options, "profiles"); + if (!Array.isArray(profiles) || profiles.length === 0) throw invalid(); + const copiedProfiles = profiles.map((profile) => boundedText(profile)); + if (new Set(copiedProfiles).size !== copiedProfiles.length) throw invalid(); + this.profiles = new Set(copiedProfiles); + + const keyringProvider = readProperty(options, "keyringProvider"); + const revocations = readProperty(options, "revocations"); + if (typeof keyringProvider !== "function" || !isRevocationStore(revocations)) throw invalid(); + this.keyringProvider = keyringProvider as ProfileContextKeyringProvider; + this.revocations = revocations; + this.auditKey = copyExactKey(readProperty(options, "auditKey")); + + this.maximumLifetimeMs = positiveSafeInteger( + readProperty(options, "maximumLifetimeMs") ?? defaultMaximumLifetimeMs + ); + this.clockSkewMs = nonNegativeSafeInteger(readProperty(options, "clockSkewMs") ?? defaultClockSkewMs); + const clock = readProperty(options, "clock") ?? Date.now; + const randomBytes = readProperty(options, "randomBytes") ?? nodeRandomBytes; + if (typeof clock !== "function" || typeof randomBytes !== "function") throw invalid(); + this.clock = clock as () => number; + this.randomBytes = randomBytes as (size: number) => Uint8Array; + } + + async mint( + profile: string, + authenticated: AuthenticatedRequestContext, + lifetimeMs: number + ): Promise { + const nowMs = this.nowMs(); + return this.mintAt(profile, authenticated, lifetimeMs, nowMs, await this.keyring(nowMs)); + } + + async resolve( + handle: string, + authenticated: AuthenticatedRequestContext + ): Promise { + return (await this.openAndValidate(handle, authenticated)).resolved; + } + + async revoke(handle: string, authenticated: AuthenticatedRequestContext): Promise { + const opened = await this.openAndValidate(handle, authenticated); + await this.revokeOpened(opened); + } + + /** + * Mints a replacement, commits a bearer-free audit transition, revokes the + * prior context, and only then discloses the replacement to the caller. + */ + async replace( + handle: string, + profile: string, + authenticated: AuthenticatedRequestContext, + lifetimeMs: number, + commitAudit: (audit: ProfileContextReplacementAudit) => void | Promise + ): Promise { + if (typeof commitAudit !== "function") throw invalid(); + const previous = await this.openAndValidate(handle, authenticated); + const nowMs = this.nowMs(); + const replacement = await this.mintAt( + profile, + authenticated, + lifetimeMs, + nowMs, + await this.keyring(nowMs) + ); + await commitAudit({ + previous: previous.resolved, + replacement: withoutHandle(replacement) + }); + await this.revokeOpened(previous); + return replacement; + } + + private async mintAt( + profileInput: string, + authenticated: AuthenticatedRequestContext, + lifetimeMsInput: number, + nowMs: number, + keyring: ValidatedKeyring + ): Promise { + const profile = boundedText(profileInput); + if (!this.profiles.has(profile)) throw invalid(); + const binding = authenticatedBinding(authenticated, nowMs); + const lifetimeMs = positiveSafeInteger(lifetimeMsInput); + if (lifetimeMs > this.maximumLifetimeMs) throw invalid(); + const expiresAtMs = Math.min(nowMs + lifetimeMs, authenticated.expiresAtMs); + if (!Number.isSafeInteger(expiresAtMs) || expiresAtMs <= nowMs) throw expired(); + const id = this.randomIdentifier(); + const payload: ProfileContextPayload = Object.freeze({ + version: 1, + id, + deploymentId: this.deploymentId, + keyEpoch: keyring.activeEpoch, + profile, + binding, + issuedAtMs: nowMs, + expiresAtMs + }); + return Object.freeze({ + handle: this.seal(payload, keyring.active), + profile, + auditCorrelation: this.auditCorrelation(id), + issuedAtMs: nowMs, + expiresAtMs + }); + } + + private async openAndValidate( + handleInput: string, + authenticated: AuthenticatedRequestContext + ): Promise { + const nowMs = this.nowMs(); + const binding = authenticatedBinding(authenticated, nowMs); + const envelope = parseEnvelope(handleInput); + const keyring = await this.keyring(nowMs); + const keyEpoch = keyring.epochs.get(envelope.epoch); + if ( + keyEpoch === undefined || + envelope.epoch > keyring.activeEpoch || + (envelope.epoch !== keyring.activeEpoch && + (keyEpoch.resolveUntilMs === undefined || keyEpoch.resolveUntilMs <= nowMs)) + ) { + throw invalid(); + } + const payload = this.open(envelope, keyEpoch); + if ( + payload.deploymentId !== this.deploymentId || + payload.keyEpoch !== envelope.epoch || + !this.profiles.has(payload.profile) || + !safeTextEqual(payload.binding, binding) || + payload.issuedAtMs > nowMs + this.clockSkewMs || + payload.expiresAtMs - payload.issuedAtMs > this.maximumLifetimeMs + ) { + throw invalid(); + } + if (payload.expiresAtMs <= nowMs) throw expired(); + let revoked: unknown; + try { + revoked = await this.revocations.isRevoked(payload.id, nowMs); + } catch { + throw unavailable(); + } + if (typeof revoked !== "boolean") throw unavailable(); + if (revoked) throw new ProfileContextHandleError("PROFILE_CONTEXT_REVOKED"); + return Object.freeze({ + payload, + resolved: Object.freeze({ + profile: payload.profile, + auditCorrelation: this.auditCorrelation(payload.id), + issuedAtMs: payload.issuedAtMs, + expiresAtMs: payload.expiresAtMs + }) + }); + } + + private async revokeOpened(opened: OpenedProfileContext): Promise { + try { + await this.revocations.revoke(opened.payload.id, opened.payload.expiresAtMs); + } catch { + throw unavailable(); + } + } + + private seal(payload: ProfileContextPayload, epoch: ProfileContextKeyEpoch & { readonly key: Buffer }): string { + try { + const initializationVector = copyRandomBytes(this.randomBytes, initializationVectorBytes); + const cipher = createCipheriv("aes-256-gcm", epoch.key, initializationVector, { + authTagLength: authenticationTagBytes + }); + cipher.setAAD(this.additionalAuthenticatedData(epoch.epoch)); + const ciphertext = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]); + return [ + handlePrefix, + String(epoch.epoch), + initializationVector.toString("base64url"), + ciphertext.toString("base64url"), + cipher.getAuthTag().toString("base64url") + ].join("."); + } catch (error) { + if (error instanceof ProfileContextHandleError) throw error; + throw unavailable(); + } + } + + private open( + envelope: ReturnType, + epoch: ProfileContextKeyEpoch & { readonly key: Buffer } + ): ProfileContextPayload { + try { + const decipher = createDecipheriv("aes-256-gcm", epoch.key, envelope.initializationVector, { + authTagLength: authenticationTagBytes + }); + decipher.setAAD(this.additionalAuthenticatedData(envelope.epoch)); + decipher.setAuthTag(envelope.authenticationTag); + const plaintext = Buffer.concat([ + decipher.update(envelope.ciphertext), + decipher.final() + ]).toString("utf8"); + return parsePayload(JSON.parse(plaintext) as unknown); + } catch (error) { + if (error instanceof ProfileContextHandleError) throw error; + throw invalid(); + } + } + + private async keyring(nowMs: number): Promise { + let snapshot: unknown; + try { + snapshot = await this.keyringProvider(); + } catch { + throw unavailable(); + } + let validated: ValidatedKeyring; + try { + validated = validateKeyring(snapshot, nowMs, this.maximumLifetimeMs, this.clockSkewMs, this.auditKey); + } catch { + throw unavailable(); + } + if (validated.activeEpoch < this.highestActiveEpoch) throw unavailable(); + if ( + validated.activeEpoch === this.highestActiveEpoch && + this.highestActiveKey !== undefined && + !safeBufferEqual(validated.active.key, this.highestActiveKey) + ) { + throw unavailable(); + } + if (validated.activeEpoch > this.highestActiveEpoch) { + this.highestActiveEpoch = validated.activeEpoch; + this.highestActiveKey = Buffer.from(validated.active.key); + } + return validated; + } + + private additionalAuthenticatedData(epoch: number): Buffer { + return lengthPrefixed(["miftah-profile-context-v1", this.deploymentId, String(epoch)]); + } + + private auditCorrelation(id: string): string { + return `${auditCorrelationPrefix}${createHmac("sha256", this.auditKey) + .update("miftah-profile-context-audit-v1\0", "utf8") + .update(id, "utf8") + .digest() + .subarray(0, 16) + .toString("base64url")}`; + } + + private randomIdentifier(): string { + return copyRandomBytes(this.randomBytes, identifierBytes).toString("base64url"); + } + + private nowMs(): number { + let value: unknown; + try { + value = this.clock(); + } catch { + throw unavailable(); + } + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw unavailable(); + return value; + } +} + +/** A bounded store suitable for tests or multiple services in one process only. */ +export class InMemoryProfileContextRevocationStore implements ProfileContextRevocationStore { + private readonly expirations = new Map(); + + constructor(private readonly maximumEntries = 1_024) { + positiveSafeInteger(maximumEntries); + } + + isRevoked(id: string, atMs: number): boolean { + const safeId = internalIdentifier(id); + const nowMs = nonNegativeSafeInteger(atMs); + this.prune(nowMs); + const expiresAtMs = this.expirations.get(safeId); + return expiresAtMs !== undefined && expiresAtMs > nowMs; + } + + revoke(id: string, expiresAtMs: number): void { + const safeId = internalIdentifier(id); + const expiry = positiveSafeInteger(expiresAtMs); + if (!this.expirations.has(safeId) && this.expirations.size >= this.maximumEntries) { + throw new Error("Profile context revocation capacity is unavailable."); + } + this.expirations.set(safeId, expiry); + } + + private prune(atMs: number): void { + for (const [id, expiresAtMs] of this.expirations) { + if (expiresAtMs <= atMs) this.expirations.delete(id); + } + } +} + +function validateKeyring( + value: unknown, + nowMs: number, + maximumLifetimeMs: number, + clockSkewMs: number, + auditKey: Buffer +): ValidatedKeyring { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid(); + const snapshot = value as Partial; + const activeEpoch = positiveSafeInteger(snapshot.activeEpoch); + if (!Array.isArray(snapshot.epochs) || snapshot.epochs.length === 0) throw invalid(); + const epochs = new Map(); + for (const input of snapshot.epochs) { + if (typeof input !== "object" || input === null || Array.isArray(input)) throw invalid(); + const epoch = positiveSafeInteger(input.epoch); + if (epochs.has(epoch)) throw invalid(); + const key = copyExactKey(input.key); + if (safeBufferEqual(key, auditKey)) throw invalid(); + const activatedAtMs = nonNegativeSafeInteger(input.activatedAtMs); + const resolveUntilMs = input.resolveUntilMs; + if (epoch === activeEpoch) { + if (resolveUntilMs !== undefined || activatedAtMs > nowMs + clockSkewMs) throw invalid(); + } else { + if (epoch > activeEpoch || resolveUntilMs === undefined) throw invalid(); + const resolveUntil = positiveSafeInteger(resolveUntilMs); + if (resolveUntil <= activatedAtMs) throw invalid(); + } + epochs.set(epoch, Object.freeze({ + epoch, + key, + activatedAtMs, + ...(resolveUntilMs === undefined ? {} : { resolveUntilMs }) + })); + } + const active = epochs.get(activeEpoch); + if (active === undefined) throw invalid(); + for (const epoch of epochs.values()) { + if ( + epoch.epoch !== activeEpoch && + (epoch.resolveUntilMs === undefined || + epoch.resolveUntilMs <= active.activatedAtMs || + epoch.resolveUntilMs > active.activatedAtMs + maximumLifetimeMs + clockSkewMs) + ) { + throw invalid(); + } + } + return Object.freeze({ activeEpoch, active, epochs }); +} + +function parseEnvelope(handle: unknown): { + readonly epoch: number; + readonly initializationVector: Buffer; + readonly ciphertext: Buffer; + readonly authenticationTag: Buffer; +} { + try { + if (typeof handle !== "string" || Buffer.byteLength(handle, "utf8") > maximumHandleBytes) throw invalid(); + const parts = handle.split("."); + if (parts.length !== 5 || parts[0] !== handlePrefix || !decimalPattern.test(parts[1]!)) throw invalid(); + const epoch = positiveSafeInteger(Number(parts[1])); + if (String(epoch) !== parts[1]) throw invalid(); + return Object.freeze({ + epoch, + initializationVector: decodePart(parts[2], initializationVectorBytes), + ciphertext: decodePart(parts[3]), + authenticationTag: decodePart(parts[4], authenticationTagBytes) + }); + } catch (error) { + if (error instanceof ProfileContextHandleError) throw error; + throw invalid(); + } +} + +function parsePayload(value: unknown): ProfileContextPayload { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid(); + const payload = value as Record; + const expectedKeys = [ + "binding", + "deploymentId", + "expiresAtMs", + "id", + "issuedAtMs", + "keyEpoch", + "profile", + "version" + ]; + const keys = Object.keys(payload).sort(); + if (keys.length !== expectedKeys.length || keys.some((key, index) => key !== expectedKeys[index])) throw invalid(); + if ( + payload.version !== 1 || + typeof payload.id !== "string" || + !/^[A-Za-z0-9_-]{22}$/u.test(payload.id) || + typeof payload.deploymentId !== "string" || + typeof payload.profile !== "string" || + typeof payload.binding !== "string" || + typeof payload.keyEpoch !== "number" || + typeof payload.issuedAtMs !== "number" || + typeof payload.expiresAtMs !== "number" || + !Number.isSafeInteger(payload.keyEpoch) || + !Number.isSafeInteger(payload.issuedAtMs) || + !Number.isSafeInteger(payload.expiresAtMs) || + payload.keyEpoch <= 0 || + payload.issuedAtMs < 0 || + payload.expiresAtMs <= payload.issuedAtMs + ) { + throw invalid(); + } + boundedText(payload.deploymentId); + boundedText(payload.profile); + decodeAuthenticatedBinding(payload.binding); + return Object.freeze(payload as unknown as ProfileContextPayload); +} + +function authenticatedBinding(value: unknown, nowMs: number): string { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid(); + let binding: unknown; + let expiresAtMs: unknown; + try { + binding = (value as Partial).binding; + expiresAtMs = (value as Partial).expiresAtMs; + } catch { + throw invalid(); + } + decodeAuthenticatedBinding(binding); + if (typeof expiresAtMs !== "number" || !Number.isSafeInteger(expiresAtMs)) throw invalid(); + if (expiresAtMs <= nowMs) throw expired(); + return binding as string; +} + +function decodeAuthenticatedBinding(value: unknown): Buffer { + if (typeof value !== "string" || !/^mab1\.[A-Za-z0-9_-]{43}$/u.test(value)) throw invalid(); + const encoded = value.slice("mab1.".length); + const decoded = Buffer.from(encoded, "base64url"); + if (decoded.length !== 32 || decoded.toString("base64url") !== encoded) throw invalid(); + return decoded; +} + +function withoutHandle(value: MintedProfileContext): ResolvedProfileContext { + return Object.freeze({ + profile: value.profile, + auditCorrelation: value.auditCorrelation, + issuedAtMs: value.issuedAtMs, + expiresAtMs: value.expiresAtMs + }); +} + +function isRevocationStore(value: unknown): value is ProfileContextRevocationStore { + if (typeof value !== "object" || value === null) return false; + try { + const store = value as Partial; + return typeof store.isRevoked === "function" && typeof store.revoke === "function"; + } catch { + return false; + } +} + +function readProperty(value: object, key: string): unknown { + try { + return (value as Record)[key]; + } catch { + throw invalid(); + } +} + +function boundedText(value: unknown): string { + if (typeof value !== "string" || value.length === 0) throw invalid(); + const bytes = Buffer.byteLength(value, "utf8"); + if (bytes === 0 || bytes > maximumTextBytes || Buffer.from(value, "utf8").toString("utf8") !== value) throw invalid(); + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint === undefined || codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) throw invalid(); + } + return value; +} + +function copyExactKey(value: unknown): Buffer { + try { + if (!(value instanceof Uint8Array) || value.byteLength !== keyBytes) throw invalid(); + return Buffer.from(value); + } catch { + throw invalid(); + } +} + +function copyRandomBytes(randomBytes: (size: number) => Uint8Array, size: number): Buffer { + try { + const value = randomBytes(size); + if (!(value instanceof Uint8Array) || value.byteLength !== size) throw unavailable(); + return Buffer.from(value); + } catch (error) { + if (error instanceof ProfileContextHandleError) throw error; + throw unavailable(); + } +} + +function positiveSafeInteger(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw invalid(); + return value; +} + +function nonNegativeSafeInteger(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw invalid(); + return value; +} + +function internalIdentifier(value: unknown): string { + if (typeof value !== "string" || !/^[A-Za-z0-9_-]{22}$/u.test(value)) throw invalid(); + return value; +} + +function decodePart(value: unknown, exactBytes?: number): Buffer { + if (typeof value !== "string" || value.length === 0 || !base64UrlPattern.test(value)) throw invalid(); + const decoded = Buffer.from(value, "base64url"); + if ( + decoded.length === 0 || + decoded.toString("base64url") !== value || + (exactBytes !== undefined && decoded.length !== exactBytes) + ) { + throw invalid(); + } + return decoded; +} + +function safeBufferEqual(left: Buffer, right: Buffer): boolean { + return left.length === right.length && timingSafeEqual(left, right); +} + +function safeTextEqual(left: string, right: string): boolean { + return safeBufferEqual(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); +} + +function lengthPrefixed(values: readonly string[]): Buffer { + const parts: Buffer[] = []; + for (const value of values) { + const encoded = Buffer.from(value, "utf8"); + const length = Buffer.allocUnsafe(4); + length.writeUInt32BE(encoded.length); + parts.push(length, encoded); + } + return Buffer.concat(parts); +} + +function invalid(): ProfileContextHandleError { + return new ProfileContextHandleError("PROFILE_CONTEXT_INVALID"); +} + +function expired(): ProfileContextHandleError { + return new ProfileContextHandleError("PROFILE_CONTEXT_EXPIRED"); +} + +function unavailable(): ProfileContextHandleError { + return new ProfileContextHandleError("PROFILE_CONTEXT_UNAVAILABLE"); +} diff --git a/src/profiles/profile-manager.ts b/src/profiles/profile-manager.ts index da4f53d5..fb1c652b 100644 --- a/src/profiles/profile-manager.ts +++ b/src/profiles/profile-manager.ts @@ -51,7 +51,8 @@ export interface ProfileSelection { | "persisted-global" | "prior-session" | "mcp-switch" - | "reset"; + | "reset" + | "profile-context"; readonly selectedAt: string; readonly scope: ProfileStateScope; readonly confirmation: "not-required" | "not-confirmed" | "confirmed"; diff --git a/src/runtime/create-miftah-runtime.ts b/src/runtime/create-miftah-runtime.ts index 47d0611f..d6d20545 100644 --- a/src/runtime/create-miftah-runtime.ts +++ b/src/runtime/create-miftah-runtime.ts @@ -2,6 +2,7 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { resolvePath } from "../config/path-resolve.js"; import type { MiftahConfig } from "../config/types.js"; import { MiftahServer } from "../mcp/server/miftah-server.js"; +import type { ModernProfileContextRuntimeOptions } from "../profiles/profile-context-handle.js"; import { collectRoutingContext } from "../routing/context-collector.js"; import { createRuntime } from "./create-runtime.js"; @@ -15,7 +16,12 @@ export interface MiftahRuntime { close(): Promise; } -interface MiftahRuntimeFactoryOptions { +export interface MiftahRuntimeOptions { + /** Enables explicit request-scoped profile handles for a trusted modern host. */ + readonly modernProfileContext?: ModernProfileContextRuntimeOptions; +} + +interface MiftahRuntimeFactoryOptions extends MiftahRuntimeOptions { readonly profileState?: { readonly persistActiveProfile?: false; readonly scope?: "process" | "session" }; } @@ -41,7 +47,8 @@ async function createConfiguredMiftahRuntime( runtime.plugins, runtime.oauth, runtime.identities, - runtimeConfigPath + runtimeConfigPath, + options.modernProfileContext ); return { @@ -52,8 +59,11 @@ async function createConfiguredMiftahRuntime( } /** Creates an MCP wrapper runtime without exposing its internal manager or server classes. */ -export async function createMiftahRuntime(configPath: string): Promise { - return createConfiguredMiftahRuntime(configPath); +export async function createMiftahRuntime( + configPath: string, + options: MiftahRuntimeOptions = {} +): Promise { + return createConfiguredMiftahRuntime(configPath, options); } /** Creates a fresh MCP runtime whose profile state cannot escape its HTTP client session. */ diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 27cb14b8..8dab0b76 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -38,6 +38,10 @@ export type MiftahErrorCode = | "PROFILE_SELECTION_REQUIRED" | "PROFILE_IDENTITY_SELECTION_REQUIRED" | "PROFILE_IDENTITY_CONFIRMATION_REQUIRED" + | "PROFILE_CONTEXT_UNAVAILABLE" + | "PROFILE_CONTEXT_INVALID" + | "PROFILE_CONTEXT_EXPIRED" + | "PROFILE_CONTEXT_REVOKED" | "PROFILE_STATE_WRITE_FAILED" | "SECRET_ENV_MISSING" | "SECRET_PROVIDER_FAILED" diff --git a/tests/cli-exit-codes.test.ts b/tests/cli-exit-codes.test.ts index 87cf1308..530c84b8 100644 --- a/tests/cli-exit-codes.test.ts +++ b/tests/cli-exit-codes.test.ts @@ -45,6 +45,10 @@ const expectedErrorExitCodes: Record = { PROFILE_SELECTION_REQUIRED: CLI_EXIT_CODES.policy, PROFILE_IDENTITY_SELECTION_REQUIRED: CLI_EXIT_CODES.policy, PROFILE_IDENTITY_CONFIRMATION_REQUIRED: CLI_EXIT_CODES.policy, + PROFILE_CONTEXT_UNAVAILABLE: CLI_EXIT_CODES.operation, + PROFILE_CONTEXT_INVALID: CLI_EXIT_CODES.policy, + PROFILE_CONTEXT_EXPIRED: CLI_EXIT_CODES.policy, + PROFILE_CONTEXT_REVOKED: CLI_EXIT_CODES.policy, PROFILE_STATE_WRITE_FAILED: CLI_EXIT_CODES.config, SECRET_ENV_MISSING: CLI_EXIT_CODES.secret, SECRET_PROVIDER_FAILED: CLI_EXIT_CODES.secret, diff --git a/tests/package-contract.test.ts b/tests/package-contract.test.ts index b8305299..60418358 100644 --- a/tests/package-contract.test.ts +++ b/tests/package-contract.test.ts @@ -56,8 +56,13 @@ const fakeStdioUpstreamFixture = fileURLToPath(new URL("./fixtures/fake-upstream const publicRuntimeExports = [ "AuthenticatedRequestContextError", "CURRENT_CONFIG_VERSION", + "InMemoryProfileContextRevocationStore", "MIFTAH_VERSION", "MiftahError", + "PROFILE_CONTEXT_ARGUMENT", + "PROFILE_CONTEXT_META_KEY", + "ProfileContextHandleError", + "ProfileContextHandleService", "createAuthenticatedRequestContextBoundary", "createMiftahRuntime", "generateConfigSchema", @@ -1076,18 +1081,20 @@ describe("packed artifact contract", () => { const consumerPath = join(directory, "consumer.mjs"); const configPath = join(directory, "miftah.json"); + const packedAuditPath = join(directory, "packed-audit.jsonl"); await writeFile( configPath, JSON.stringify({ version: "1", name: "packed-public-api", defaultProfile: "work", - upstream: { transport: "stdio", command: process.execPath }, - profiles: { work: {} }, - // This package-entrypoint smoke deliberately uses an inert process - // instead of an external MCP fixture. Resource-subscription - // capability probing must therefore fail quickly and safely. - process: { startupTimeoutMs: 250 } + upstream: { transport: "stdio", command: process.execPath, args: [fakeStdioUpstreamFixture] }, + profiles: { + personal: { env: { TEST_ACCOUNT_NAME: "personal" } }, + work: { env: { TEST_ACCOUNT_NAME: "work" } } + }, + audit: { path: packedAuditPath, includeArguments: true }, + process: { startupTimeoutMs: 5_000 } }) ); await writeFile( @@ -1097,11 +1104,12 @@ describe("packed artifact contract", () => { 'import * as pluginApi from "@lubab/miftah/plugin-api";', 'import { Client } from "@modelcontextprotocol/sdk/client/index.js";', 'import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";', + 'import { readFile } from "node:fs/promises";', "", "const runtime = await api.createMiftahRuntime(process.argv[2]);", "const authClock = Date.UTC(2026, 7, 11, 10, 0, 0);", 'const verifiedClaims = { issuer: "https://issuer.example.test", subject: "packed-subject", audience: "packed-audience", chatContext: "packed-chat", issuedAtMs: authClock - 1_000, expiresAtMs: authClock + 60_000 };', - 'const authOptions = { deploymentId: "packed-deployment", bindingKey: Uint8Array.from({ length: 32 }, () => 1), auditKey: Uint8Array.from({ length: 32 }, () => 2), clock: () => authClock, verifiedClaimsProvider: (request) => request.claims };', + 'const authOptions = { deploymentId: "packed-deployment", bindingKey: Uint8Array.from({ length: 32 }, () => 1), auditKey: Uint8Array.from({ length: 32 }, () => 2), clock: () => authClock, verifiedClaimsProvider: (request) => request?.claims ?? request?.extra?.verifiedClaims };', "const firstAuthBoundary = api.createAuthenticatedRequestContextBoundary(authOptions);", "const secondAuthBoundary = api.createAuthenticatedRequestContextBoundary(authOptions);", "const firstAuthContext = await api.requireAuthenticatedRequestContext(firstAuthBoundary, { claims: verifiedClaims });", @@ -1113,24 +1121,78 @@ describe("packed artifact contract", () => { "try { await api.createAuthenticatedRequestContextBoundary({ ...authOptions, clock: () => verifiedClaims.expiresAtMs }).resolve({ claims: verifiedClaims }); } catch (error) { expiryCode = error.code; }", "let unavailableCode;", "try { await api.requireAuthenticatedRequestContext(undefined, { clientInfo: { name: \"untrusted\" }, headers: { \"x-chat-id\": \"untrusted\" } }); } catch (error) { unavailableCode = error.code; }", + "class AuthenticatedClientTransport {", + " constructor(delegate, authInfo) { this.delegate = delegate; this.authInfo = authInfo; }", + " get onclose() { return this.delegate.onclose; }", + " set onclose(handler) { this.delegate.onclose = handler; }", + " get onerror() { return this.delegate.onerror; }", + " set onerror(handler) { this.delegate.onerror = handler; }", + " get onmessage() { return this.delegate.onmessage; }", + " set onmessage(handler) { this.delegate.onmessage = handler; }", + " get sessionId() { return this.delegate.sessionId; }", + " set sessionId(value) { this.delegate.sessionId = value; }", + " start() { return this.delegate.start(); }", + " close() { return this.delegate.close(); }", + " send(message, options) { return this.delegate.send(message, { ...options, authInfo: this.authInfo() }); }", + "}", + "const revocations = new api.InMemoryProfileContextRevocationStore();", + "const sealingKey = Uint8Array.from({ length: 32 }, () => 3);", + "const profileAuditKey = Uint8Array.from({ length: 32 }, () => 4);", + "const keyringProvider = () => ({ activeEpoch: 1, epochs: [{ epoch: 1, key: sealingKey, activatedAtMs: authClock - 1_000 }] });", + "const modernOptions = (authenticatedRequestContext) => ({ modernProfileContext: {", + ' handles: new api.ProfileContextHandleService({ deploymentId: "packed-deployment", profiles: ["personal", "work"], keyringProvider, auditKey: profileAuditKey, revocations, clock: () => authClock }),', + " authenticatedRequestContext,", + " handleLifetimeMs: 60_000", + "} });", + "const firstModernRuntime = await api.createMiftahRuntime(process.argv[2], modernOptions(firstAuthBoundary));", + "const secondModernRuntime = await api.createMiftahRuntime(process.argv[2], modernOptions(secondAuthBoundary));", + "let firstClaims = verifiedClaims;", + 'const requestAuthInfo = (claims) => ({ token: "validated-token", clientId: "packed-host", scopes: ["mcp"], extra: { verifiedClaims: claims } });', "const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();", + "const [firstModernClientTransport, firstModernServerTransport] = InMemoryTransport.createLinkedPair();", + "const [secondModernClientTransport, secondModernServerTransport] = InMemoryTransport.createLinkedPair();", 'const client = new Client({ name: "packed-artifact-test", version: "1.0.0" });', + 'const firstModernClient = new Client({ name: "packed-modern-a", version: "1.0.0" });', + 'const secondModernClient = new Client({ name: "packed-modern-b", version: "1.0.0" });', "try {", - " await Promise.all([runtime.connect(serverTransport), client.connect(clientTransport)]);", + " await Promise.all([", + " runtime.connect(serverTransport),", + " client.connect(clientTransport),", + " firstModernRuntime.connect(firstModernServerTransport),", + " firstModernClient.connect(new AuthenticatedClientTransport(firstModernClientTransport, () => requestAuthInfo(firstClaims))),", + " secondModernRuntime.connect(secondModernServerTransport),", + " secondModernClient.connect(new AuthenticatedClientTransport(secondModernClientTransport, () => requestAuthInfo(verifiedClaims)))", + " ]);", + " const modernToolsBefore = await firstModernClient.listTools();", + ' const selection = await firstModernClient.callTool({ name: "miftah_use_profile", arguments: { profile: "personal" } });', + " const personalHandle = JSON.parse(selection.content[0].text).profileContext.handle;", + ' const currentPersonal = await secondModernClient.callTool({ name: "miftah_current_profile", arguments: { [api.PROFILE_CONTEXT_ARGUMENT]: personalHandle } });', + ' firstClaims = { ...verifiedClaims, chatContext: "packed-other-chat" };', + ' const crossChat = await firstModernClient.callTool({ name: "miftah_current_profile", arguments: { [api.PROFILE_CONTEXT_ARGUMENT]: personalHandle } });', + " firstClaims = verifiedClaims;", + ' const replacement = await secondModernClient.callTool({ name: "miftah_use_profile", arguments: { profile: "work", [api.PROFILE_CONTEXT_ARGUMENT]: personalHandle } });', + " const workHandle = JSON.parse(replacement.content[0].text).profileContext.handle;", + ' const revoked = await firstModernClient.callTool({ name: "miftah_current_profile", arguments: { [api.PROFILE_CONTEXT_ARGUMENT]: personalHandle } });', + ' const echo = await secondModernClient.callTool({ name: "echo", arguments: { message: "packed-safe", [api.PROFILE_CONTEXT_ARGUMENT]: workHandle } });', + ' const resource = await secondModernClient.readResource({ uri: "account://current", _meta: { [api.PROFILE_CONTEXT_META_KEY]: workHandle } });', + " let promptSmugglingRejected = false;", + ' try { await secondModernClient.getPrompt({ name: "account_prompt", arguments: { message: `embedded ${workHandle}` }, _meta: { [api.PROFILE_CONTEXT_META_KEY]: workHandle } }); } catch (error) { promptSmugglingRejected = String(error).includes("PROFILE_CONTEXT_INVALID"); }', + " const modernToolsAfter = await secondModernClient.listTools();", + ' const packedAudit = await readFile(process.argv[3], "utf8");', " process.stdout.write(JSON.stringify({", " exports: Object.keys(api).sort(),", " auth: { crossInstanceReplay: firstAuthContext.binding === replayedAuthContext.binding, crossChatIsolated: firstAuthContext.binding !== otherChatContext.binding, mismatchCode, expiryCode, unavailableCode, safe: !JSON.stringify(firstAuthContext).includes(verifiedClaims.subject) && !JSON.stringify(firstAuthContext).includes(verifiedClaims.chatContext) },", + ' profileContext: { crossInstance: JSON.stringify(currentPersonal).includes("personal"), crossChatRejected: crossChat.isError === true && JSON.stringify(crossChat).includes("PROFILE_CONTEXT_INVALID"), replaced: workHandle !== personalHandle, priorRevoked: revoked.isError === true && JSON.stringify(revoked).includes("PROFILE_CONTEXT_REVOKED"), resourceCrossInstance: JSON.stringify(resource).includes("work"), promptSmugglingRejected, catalogStable: JSON.stringify(modernToolsBefore.tools) === JSON.stringify(modernToolsAfter.tools), bearerAbsent: !packedAudit.includes(personalHandle) && !packedAudit.includes(workHandle) && !JSON.stringify(currentPersonal).includes(personalHandle) && !JSON.stringify(echo).includes(workHandle) && !JSON.stringify(resource).includes(workHandle) },', " version: api.MIFTAH_VERSION,", " pluginApiVersion: pluginApi.MIFTAH_PLUGIN_API_VERSION,", " server: client.getServerVersion()", " }));", "} finally {", - " await client.close();", - " await runtime.close();", + " await Promise.allSettled([client.close(), firstModernClient.close(), secondModernClient.close(), runtime.close(), firstModernRuntime.close(), secondModernRuntime.close()]);", "}" ].join("\n") ); - const entryPoint = spawnSync(process.execPath, [consumerPath, configPath], { + const entryPoint = spawnSync(process.execPath, [consumerPath, configPath, packedAuditPath], { cwd: directory, encoding: "utf8", timeout: npmCommandTimeoutMs @@ -1146,6 +1208,16 @@ describe("packed artifact contract", () => { unavailableCode: "AUTH_CONTEXT_UNAVAILABLE", safe: true }, + profileContext: { + crossInstance: true, + crossChatRejected: true, + replaced: true, + priorRevoked: true, + resourceCrossInstance: true, + promptSmugglingRejected: true, + catalogStable: true, + bearerAbsent: true + }, version: readPackageManifest().version, pluginApiVersion: "1", server: { @@ -1158,13 +1230,13 @@ describe("packed artifact contract", () => { await writeFile( typeConsumerPath, [ - 'import { AuthenticatedRequestContextError, createAuthenticatedRequestContextBoundary, createMiftahRuntime, requireAuthenticatedRequestContext, CURRENT_CONFIG_VERSION, MIFTAH_VERSION, type ActiveProfileStateScope, type AuthenticatedRequestContext, type AuthenticatedRequestContextBoundary, type AuthenticatedRequestContextBoundaryOptions, type AuthenticatedRequestContextErrorCode, type AuditConfig, type AuditIntegrityConfig, type AuditRotationConfig, type ConfigDiagnostic, type GitHubProfileRoutingMatch, type IdentityConfig, type IdentityFingerprint, type IdentityProbeConfig, type JiraProfileRoutingMatch, type LinearProfileRoutingMatch, type MiftahConfig, type MiftahConfigVersion, type MiftahErrorCode, type MiftahErrorDetails, type MiftahRuntime, type PluginConfig, type PluginKind, type PluginsConfig, type PolicyConfig, type PostHogProfileRoutingMatch, type ProcessConfig, type ProfileConfig, type ProfileIsolationConfig, type ProfileIsolationContainerVolume, type ProfileIsolationFile, type ProfileLeaseConfig, type ProfileRoutingConfig, type ProfileRoutingMatchConfig, type ProfileUpstreamOverride, type RiskLevel, type RoutingConfig, type RoutingMatcherPluginConfig, type RoutingRule, type SecurityConfig, type SentryProfileRoutingMatch, type SecretProviderPluginConfig, type StateConfig, type ToolDiscoveryMode, type ToolingConfig, type TransportType, type UnknownToolRisk, type UpstreamConfig, type ValidatedRoutingConfig, type VerifiedHttpRequestClaims, type VerifiedHttpRequestClaimsProvider } from "@lubab/miftah";', + 'import { AuthenticatedRequestContextError, InMemoryProfileContextRevocationStore, PROFILE_CONTEXT_ARGUMENT, PROFILE_CONTEXT_META_KEY, ProfileContextHandleError, ProfileContextHandleService, createAuthenticatedRequestContextBoundary, createMiftahRuntime, requireAuthenticatedRequestContext, CURRENT_CONFIG_VERSION, MIFTAH_VERSION, type ActiveProfileStateScope, type AuthenticatedRequestContext, type AuthenticatedRequestContextBoundary, type AuthenticatedRequestContextBoundaryOptions, type AuthenticatedRequestContextErrorCode, type AuditConfig, type AuditIntegrityConfig, type AuditRotationConfig, type ConfigDiagnostic, type GitHubProfileRoutingMatch, type IdentityConfig, type IdentityFingerprint, type IdentityProbeConfig, type JiraProfileRoutingMatch, type LinearProfileRoutingMatch, type MiftahConfig, type MiftahConfigVersion, type MiftahErrorCode, type MiftahErrorDetails, type MiftahRuntime, type MiftahRuntimeOptions, type MintedProfileContext, type ModernProfileContextRuntimeOptions, type PluginConfig, type PluginKind, type PluginsConfig, type PolicyConfig, type PostHogProfileRoutingMatch, type ProcessConfig, type ProfileConfig, type ProfileContextHandleErrorCode, type ProfileContextHandleServiceOptions, type ProfileContextKeyEpoch, type ProfileContextKeyringProvider, type ProfileContextKeyringSnapshot, type ProfileContextReplacementAudit, type ProfileContextRevocationStore, type ProfileIsolationConfig, type ProfileIsolationContainerVolume, type ProfileIsolationFile, type ProfileLeaseConfig, type ProfileRoutingConfig, type ProfileRoutingMatchConfig, type ProfileUpstreamOverride, type ResolvedProfileContext, type RiskLevel, type RoutingConfig, type RoutingMatcherPluginConfig, type RoutingRule, type SecurityConfig, type SentryProfileRoutingMatch, type SecretProviderPluginConfig, type StateConfig, type ToolDiscoveryMode, type ToolingConfig, type TransportType, type UnknownToolRisk, type UpstreamConfig, type ValidatedRoutingConfig, type VerifiedHttpRequestClaims, type VerifiedHttpRequestClaimsProvider } from "@lubab/miftah";', 'import { MIFTAH_PLUGIN_API_VERSION, type MiftahPlugin, type RoutingMatcherPlugin, type RoutingMatcherPluginRequest, type RoutingMatcherPluginResult, type RoutingMatcherPluginSignal, type SecretProviderPlugin, type SecretProviderPluginRequest, type SecretProviderPluginResult } from "@lubab/miftah/plugin-api";', "", "type SupportedTypes = [", " ActiveProfileStateScope, AuthenticatedRequestContext, AuthenticatedRequestContextBoundary, AuthenticatedRequestContextBoundaryOptions, AuthenticatedRequestContextErrorCode, AuditConfig, AuditIntegrityConfig, AuditRotationConfig, ConfigDiagnostic, GitHubProfileRoutingMatch, IdentityConfig, IdentityFingerprint, IdentityProbeConfig, JiraProfileRoutingMatch, LinearProfileRoutingMatch, MiftahConfig, MiftahConfigVersion,", - " MiftahErrorCode, MiftahErrorDetails, MiftahRuntime,", - " PluginConfig, PluginKind, PluginsConfig, PolicyConfig, PostHogProfileRoutingMatch, ProcessConfig, ProfileConfig, ProfileIsolationConfig, ProfileIsolationContainerVolume, ProfileIsolationFile, ProfileLeaseConfig, ProfileRoutingConfig, ProfileRoutingMatchConfig, ProfileUpstreamOverride, RiskLevel, RoutingConfig, RoutingMatcherPluginConfig,", + " MiftahErrorCode, MiftahErrorDetails, MiftahRuntime, MiftahRuntimeOptions, MintedProfileContext, ModernProfileContextRuntimeOptions,", + " PluginConfig, PluginKind, PluginsConfig, PolicyConfig, PostHogProfileRoutingMatch, ProcessConfig, ProfileConfig, ProfileContextHandleErrorCode, ProfileContextHandleServiceOptions, ProfileContextKeyEpoch, ProfileContextKeyringProvider, ProfileContextKeyringSnapshot, ProfileContextReplacementAudit, ProfileContextRevocationStore, ProfileIsolationConfig, ProfileIsolationContainerVolume, ProfileIsolationFile, ProfileLeaseConfig, ProfileRoutingConfig, ProfileRoutingMatchConfig, ProfileUpstreamOverride, ResolvedProfileContext, RiskLevel, RoutingConfig, RoutingMatcherPluginConfig,", " RoutingRule, SecurityConfig, SentryProfileRoutingMatch, SecretProviderPluginConfig, StateConfig, ToolDiscoveryMode, ToolingConfig, TransportType, UnknownToolRisk, UpstreamConfig,", " ValidatedRoutingConfig, VerifiedHttpRequestClaims, VerifiedHttpRequestClaimsProvider, MiftahPlugin, RoutingMatcherPlugin, RoutingMatcherPluginRequest, RoutingMatcherPluginResult, RoutingMatcherPluginSignal, SecretProviderPlugin, SecretProviderPluginRequest, SecretProviderPluginResult", "];", @@ -1174,6 +1246,11 @@ describe("packed artifact contract", () => { 'const pluginApiVersion: "1" = MIFTAH_PLUGIN_API_VERSION;', 'const runtime: Promise = createMiftahRuntime("./miftah.json");', 'const authError: AuthenticatedRequestContextError = new AuthenticatedRequestContextError("AUTH_CONTEXT_UNAVAILABLE");', + 'const profileContextError: ProfileContextHandleError = new ProfileContextHandleError("PROFILE_CONTEXT_UNAVAILABLE");', + "const profileContextArgument: string = PROFILE_CONTEXT_ARGUMENT;", + "const profileContextMetaKey: string = PROFILE_CONTEXT_META_KEY;", + "const profileContextRevocations: ProfileContextRevocationStore = new InMemoryProfileContextRevocationStore();", + "const profileContextServiceConstructor: typeof ProfileContextHandleService = ProfileContextHandleService;", 'const authBoundary = createAuthenticatedRequestContextBoundary<{ readonly claims?: VerifiedHttpRequestClaims }>({ deploymentId: "consumer", bindingKey: new Uint8Array(32), auditKey: Uint8Array.from({ length: 32 }, () => 1), verifiedClaimsProvider: (request) => request.claims });', 'const authContext: Promise = requireAuthenticatedRequestContext(authBoundary, {});', 'const secretPluginRequest: SecretProviderPluginRequest = { reference: "secretref:consumer-secret://account" };', @@ -1259,7 +1336,7 @@ describe("packed artifact contract", () => { ' tool: "identity", resultFormat: "json",', ' provider: "github"', "};", - "void [types, version, pluginApiVersion, runtime, authError, authContext, secretPluginRequest, secretPluginResult, secretPlugin, routingSignal, routingPluginRequest, routingPluginResult, routingPlugin, plugin, pluginKind, secretPluginConfig, routingPluginConfig, pluginConfig, pluginsConfig, globalScope, validState, auditRotation, auditIntegrity, validSessionState, validProfileLease, isolatedFile, isolatedVolume, isolation, invalidDuplicateProfileLease, unknownRisk, invalidState, validTextIdentity, mismatchedTextProviderIdentity, validDestructiveIdentity, validWriteThenDestructiveIdentity, validDestructiveThenWriteIdentity, invalidDuplicateRiskIdentity, invalidTextIdentity, invalidTextOrganization, invalidTextProviderWithoutProbeProvider, invalidJsonStaticProvider, invalidJsonEmptyExpected, invalidJsonProbe];" + "void [types, version, pluginApiVersion, runtime, authError, profileContextError, profileContextArgument, profileContextMetaKey, profileContextRevocations, profileContextServiceConstructor, authContext, secretPluginRequest, secretPluginResult, secretPlugin, routingSignal, routingPluginRequest, routingPluginResult, routingPlugin, plugin, pluginKind, secretPluginConfig, routingPluginConfig, pluginConfig, pluginsConfig, globalScope, validState, auditRotation, auditIntegrity, validSessionState, validProfileLease, isolatedFile, isolatedVolume, isolation, invalidDuplicateProfileLease, unknownRisk, invalidState, validTextIdentity, mismatchedTextProviderIdentity, validDestructiveIdentity, validWriteThenDestructiveIdentity, validDestructiveThenWriteIdentity, invalidDuplicateRiskIdentity, invalidTextIdentity, invalidTextOrganization, invalidTextProviderWithoutProbeProvider, invalidJsonStaticProvider, invalidJsonEmptyExpected, invalidJsonProbe];" ].join("\n") ); const typecheck = spawnSync( diff --git a/tests/profile-context-handle-docs-contract.test.ts b/tests/profile-context-handle-docs-contract.test.ts new file mode 100644 index 00000000..26221834 --- /dev/null +++ b/tests/profile-context-handle-docs-contract.test.ts @@ -0,0 +1,31 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const libraryApiPath = fileURLToPath(new URL("../docs/library-api.md", import.meta.url)); +const changelogPath = fileURLToPath(new URL("../CHANGELOG.md", import.meta.url)); + +describe("profile-context handle documentation contract", () => { + it("documents the trusted modern host and deployment-wide fail-closed boundary", async () => { + const documentation = await readFile(libraryApiPath, "utf8"); + + expect(documentation).toContain("ProfileContextHandleService"); + expect(documentation).toContain("verified request result through the MCP SDK `authInfo`"); + expect(documentation).toContain("must enable a concrete audit journal"); + expect(documentation).toContain("same atomic `ProfileContextKeyringSnapshot`"); + expect(documentation).toContain("provide shared revocation storage"); + expect(documentation).toContain("strips either form before audit argument capture"); + expect(documentation).toContain("is not operation authorization or idempotency"); + expect(documentation).toContain("does not enable this option"); + }); + + it("records the production boundary under the next release without claiming transport negotiation", async () => { + const changelog = await readFile(changelogPath, "utf8"); + const [, afterUnreleased = ""] = changelog.split(/^## \[Unreleased\]\s*$/mu); + const unreleased = afterUnreleased.split(/^## \[/mu, 1)[0] ?? ""; + + expect(unreleased).toContain("[#377]"); + expect(unreleased).toContain("opt-in production profile-context boundary"); + expect(unreleased).toContain("protocol-era negotiation is enabled separately"); + }); +}); diff --git a/tests/profile-context-handle.test.ts b/tests/profile-context-handle.test.ts new file mode 100644 index 00000000..3bd2301a --- /dev/null +++ b/tests/profile-context-handle.test.ts @@ -0,0 +1,560 @@ +import { createCipheriv, randomBytes } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +import { + createAuthenticatedRequestContextBoundary, + type AuthenticatedRequestContext +} from "../src/http/authenticated-request-context.js"; +import { + InMemoryProfileContextRevocationStore, + ProfileContextHandleError, + ProfileContextHandleService, + type ProfileContextHandleServiceOptions, + type ProfileContextKeyringSnapshot, + type ProfileContextRevocationStore +} from "../src/profiles/profile-context-handle.js"; + +const deploymentId = "miftah.example/deployment-a"; +const profiles = ["personal", "work"] as const; +const nowMs = 2_000_000_000_000; + +function keyring(epoch = 1, key = randomBytes(32), activatedAtMs = nowMs - 1_000): ProfileContextKeyringSnapshot { + return { activeEpoch: epoch, epochs: [{ epoch, key, activatedAtMs }] }; +} + +function profileContextOptions( + overrides: Partial = {} +): ProfileContextHandleServiceOptions { + const sharedKeyring = keyring(); + return { + deploymentId, + profiles, + keyringProvider: () => sharedKeyring, + auditKey: Buffer.alloc(32, 0x44), + revocations: new InMemoryProfileContextRevocationStore(), + clock: () => nowMs, + ...overrides + }; +} + +function sealPlaintext(plaintext: string, key: Uint8Array, epoch = 1): string { + const initializationVector = Buffer.alloc(12, 0x55); + const cipher = createCipheriv("aes-256-gcm", key, initializationVector, { authTagLength: 16 }); + const aadParts = ["miftah-profile-context-v1", deploymentId, String(epoch)].flatMap((value) => { + const encoded = Buffer.from(value, "utf8"); + const length = Buffer.alloc(4); + length.writeUInt32BE(encoded.length); + return [length, encoded]; + }); + cipher.setAAD(Buffer.concat(aadParts)); + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return [ + "mctx1", + String(epoch), + initializationVector.toString("base64url"), + ciphertext.toString("base64url"), + cipher.getAuthTag().toString("base64url") + ].join("."); +} + +function sealPayload(payload: unknown, key: Uint8Array, epoch = 1): string { + return sealPlaintext(JSON.stringify(payload), key, epoch); +} + +async function authenticated(chatContext: string, subject = "user-123", expiresAtMs = nowMs + 60 * 60_000) { + return createAuthenticatedRequestContextBoundary({ + deploymentId, + bindingKey: Buffer.alloc(32, 0x31), + auditKey: Buffer.alloc(32, 0x32), + clock: () => nowMs, + verifiedClaimsProvider: () => ({ + issuer: "https://issuer.example", + subject, + audience: "https://miftah.example", + chatContext, + issuedAtMs: nowMs - 1_000, + expiresAtMs + }) + }).resolve("request"); +} + +function service(input: { + now?: () => number; + keyringProvider?: () => ProfileContextKeyringSnapshot | Promise; + revocations?: ProfileContextRevocationStore; + auditKey?: Uint8Array; + randomBytes?: (size: number) => Uint8Array; +} = {}) { + const sharedKeyring = keyring(); + return new ProfileContextHandleService({ + deploymentId, + profiles, + keyringProvider: input.keyringProvider ?? (() => sharedKeyring), + auditKey: input.auditKey ?? Buffer.alloc(32, 0x44), + revocations: input.revocations ?? new InMemoryProfileContextRevocationStore(), + clock: input.now ?? (() => nowMs), + ...(input.randomBytes === undefined ? {} : { randomBytes: input.randomBytes }) + }); +} + +describe("production profile-context handles", () => { + it("rejects malformed construction without leaking property access failures", () => { + const invalidInputs: unknown[] = [ + null, + { ...profileContextOptions(), profiles: [] }, + { ...profileContextOptions(), profiles: ["work", "work"] }, + { ...profileContextOptions(), keyringProvider: "not-a-provider" }, + { ...profileContextOptions(), revocations: {} }, + { ...profileContextOptions(), auditKey: Buffer.alloc(31) }, + { ...profileContextOptions(), clock: 1 }, + { ...profileContextOptions(), randomBytes: 1 }, + { ...profileContextOptions(), maximumLifetimeMs: 0 }, + { ...profileContextOptions(), clockSkewMs: -1 }, + new Proxy({}, { get: () => { throw new Error("private construction detail"); } }) + ]; + + for (const input of invalidInputs) { + expect(() => new ProfileContextHandleService(input as ProfileContextHandleServiceOptions)).toThrowError( + expect.objectContaining({ code: "PROFILE_CONTEXT_INVALID", message: "Profile context is invalid." }) + ); + } + const inaccessibleStore = new Proxy({}, { get: () => { throw new Error("private store detail"); } }); + expect(() => new ProfileContextHandleService(profileContextOptions({ + revocations: inaccessibleStore as ProfileContextRevocationStore + }))).toThrowError(expect.objectContaining({ code: "PROFILE_CONTEXT_INVALID" })); + }); + + it("alternates one handle across instances while isolating chats for one subject", async () => { + const sharedKeyring = keyring(); + const revocations = new InMemoryProfileContextRevocationStore(); + const options = { + deploymentId, + profiles, + keyringProvider: () => sharedKeyring, + auditKey: Buffer.alloc(32, 0x44), + revocations, + clock: () => nowMs + }; + const first = new ProfileContextHandleService(options); + const second = new ProfileContextHandleService(options); + const workChat = await authenticated("chat-work"); + const personalChat = await authenticated("chat-personal"); + const work = await first.mint("work", workChat, 60_000); + const personal = await second.mint("personal", personalChat, 60_000); + + await expect(second.resolve(work.handle, workChat)).resolves.toMatchObject({ profile: "work" }); + await expect(first.resolve(work.handle, workChat)).resolves.toMatchObject({ profile: "work" }); + await expect(first.resolve(personal.handle, personalChat)).resolves.toMatchObject({ profile: "personal" }); + await expect(second.resolve(work.handle, personalChat)).rejects.toMatchObject({ code: "PROFILE_CONTEXT_INVALID" }); + await expect(first.resolve(personal.handle, workChat)).rejects.toMatchObject({ code: "PROFILE_CONTEXT_INVALID" }); + }); + + it("binds issuer, subject, audience, chat, deployment, and profile existence", async () => { + const sharedKeyring = keyring(); + const owner = await authenticated("chat-work"); + const first = new ProfileContextHandleService({ + deploymentId, + profiles, + keyringProvider: () => sharedKeyring, + auditKey: Buffer.alloc(32, 0x44), + revocations: new InMemoryProfileContextRevocationStore(), + clock: () => nowMs + }); + const handle = await first.mint("work", owner, 60_000); + + await expect(first.resolve(handle.handle, await authenticated("chat-work", "other-user"))).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_INVALID" + }); + await expect(first.resolve(handle.handle, await authenticated("other-chat"))).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_INVALID" + }); + await expect(first.mint("unknown", owner, 60_000)).rejects.toMatchObject({ code: "PROFILE_CONTEXT_INVALID" }); + + const otherDeployment = new ProfileContextHandleService({ + deploymentId: "miftah.example/deployment-b", + profiles, + keyringProvider: () => sharedKeyring, + auditKey: Buffer.alloc(32, 0x44), + revocations: new InMemoryProfileContextRevocationStore(), + clock: () => nowMs + }); + await expect(otherDeployment.resolve(handle.handle, owner)).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_INVALID" + }); + + const removedProfile = new ProfileContextHandleService({ + deploymentId, + profiles: ["personal"], + keyringProvider: () => sharedKeyring, + auditKey: Buffer.alloc(32, 0x44), + revocations: new InMemoryProfileContextRevocationStore(), + clock: () => nowMs + }); + await expect(removedProfile.resolve(handle.handle, owner)).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_INVALID" + }); + }); + + it("caps lifetime to the host assertion and expires at the exact boundary", async () => { + let current = nowMs; + const instance = service({ now: () => current }); + const context = await authenticated("chat-work", "user-123", nowMs + 10_000); + const minted = await instance.mint("work", context, 60_000); + expect(minted.expiresAtMs).toBe(nowMs + 10_000); + + current = nowMs + 9_999; + await expect(instance.resolve(minted.handle, { ...context, expiresAtMs: nowMs + 60_000 })).resolves.toMatchObject({ + profile: "work" + }); + current = nowMs + 10_000; + await expect(instance.resolve(minted.handle, { ...context, expiresAtMs: nowMs + 60_000 })).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_EXPIRED" + }); + }); + + it("revokes deployment-wide and prevents another chat from revoking", async () => { + const sharedKeyring = keyring(); + const revocations = new InMemoryProfileContextRevocationStore(); + const first = new ProfileContextHandleService({ + deploymentId, + profiles, + keyringProvider: () => sharedKeyring, + auditKey: Buffer.alloc(32, 0x44), + revocations, + clock: () => nowMs + }); + const second = new ProfileContextHandleService({ + deploymentId, + profiles, + keyringProvider: () => sharedKeyring, + auditKey: Buffer.alloc(32, 0x44), + revocations, + clock: () => nowMs + }); + const owner = await authenticated("chat-work"); + const other = await authenticated("chat-personal"); + const minted = await first.mint("work", owner, 60_000); + + await expect(second.revoke(minted.handle, other)).rejects.toMatchObject({ code: "PROFILE_CONTEXT_INVALID" }); + await expect(first.resolve(minted.handle, owner)).resolves.toMatchObject({ profile: "work" }); + await second.revoke(minted.handle, owner); + await expect(first.resolve(minted.handle, owner)).rejects.toMatchObject({ code: "PROFILE_CONTEXT_REVOKED" }); + }); + + it("commits a bearer-free switch audit before revoking and disclosing the replacement", async () => { + const instance = service(); + const context = await authenticated("chat-work"); + const current = await instance.mint("personal", context, 60_000); + const observed: string[] = []; + + const replacement = await instance.replace(current.handle, "work", context, 60_000, async (audit) => { + observed.push("audit"); + expect(JSON.stringify(audit)).not.toContain(current.handle); + expect(audit).toMatchObject({ previous: { profile: "personal" }, replacement: { profile: "work" } }); + await expect(instance.resolve(current.handle, context)).resolves.toMatchObject({ profile: "personal" }); + }); + observed.push("returned"); + + expect(observed).toEqual(["audit", "returned"]); + await expect(instance.resolve(current.handle, context)).rejects.toMatchObject({ code: "PROFILE_CONTEXT_REVOKED" }); + await expect(instance.resolve(replacement.handle, context)).resolves.toMatchObject({ profile: "work" }); + }); + + it("keeps the prior handle valid and never discloses a replacement when audit commit fails", async () => { + const instance = service({ randomBytes: (size) => Buffer.alloc(size, 0x51) }); + const context = await authenticated("chat-work"); + const current = await instance.mint("personal", context, 60_000); + const auditFailure = new Error("required audit failed"); + + await expect(instance.replace(current.handle, "work", context, 60_000, () => { + throw auditFailure; + })).rejects.toBe(auditFailure); + await expect(instance.resolve(current.handle, context)).resolves.toMatchObject({ profile: "personal" }); + expect(String(auditFailure)).not.toContain(current.handle); + }); + + it("supports bounded rotation overlap and rejects unknown, future, expired, and rollback epochs", async () => { + let current = nowMs; + const firstKey = Buffer.alloc(32, 0x61); + const secondKey = Buffer.alloc(32, 0x62); + let snapshot = keyring(1, firstKey, nowMs - 60_000); + const instance = service({ now: () => current, keyringProvider: () => snapshot }); + const context = await authenticated("chat-work"); + const old = await instance.mint("work", context, 10 * 60_000); + + snapshot = { + activeEpoch: 2, + epochs: [ + { epoch: 1, key: firstKey, activatedAtMs: nowMs - 60_000, resolveUntilMs: nowMs + 10 * 60_000 }, + { epoch: 2, key: secondKey, activatedAtMs: nowMs } + ] + }; + const fresh = await instance.mint("personal", context, 10 * 60_000); + expect(fresh.handle).toMatch(/^mctx1\.2\./u); + await expect(instance.resolve(old.handle, context)).resolves.toMatchObject({ profile: "work" }); + + const unknownEpoch = old.handle.replace(/^mctx1\.1\./u, "mctx1.3."); + await expect(instance.resolve(unknownEpoch, context)).rejects.toMatchObject({ code: "PROFILE_CONTEXT_INVALID" }); + + current = nowMs + 10 * 60_000; + await expect(instance.resolve(old.handle, { ...context, expiresAtMs: current + 60_000 })).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_INVALID" + }); + + snapshot = keyring(1, firstKey, nowMs - 60_000); + await expect(instance.mint("work", { ...context, expiresAtMs: current + 60_000 }, 10_000)).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_UNAVAILABLE" + }); + }); + + it("fails closed when key, revocation, clock, or randomness state is unavailable", async () => { + const context = await authenticated("chat-work"); + const stableKeyring = keyring(); + const stable = service({ keyringProvider: () => stableKeyring }); + const minted = await stable.mint("work", context, 60_000); + + await expect(service({ keyringProvider: () => { throw new Error("kms details"); } }).mint("work", context, 60_000)) + .rejects.toMatchObject({ code: "PROFILE_CONTEXT_UNAVAILABLE", message: "Profile context is unavailable." }); + await expect(service({ keyringProvider: () => ({ activeEpoch: 1, epochs: [] }) }).mint("work", context, 60_000)) + .rejects.toMatchObject({ code: "PROFILE_CONTEXT_UNAVAILABLE" }); + await expect(service({ now: () => { throw new Error("clock details"); } }).mint("work", context, 60_000)) + .rejects.toMatchObject({ code: "PROFILE_CONTEXT_UNAVAILABLE" }); + await expect(service({ randomBytes: () => { throw new Error("rng details"); } }).mint("work", context, 60_000)) + .rejects.toMatchObject({ code: "PROFILE_CONTEXT_UNAVAILABLE" }); + await expect(service({ randomBytes: (size) => Buffer.alloc(size - 1) }).mint("work", context, 60_000)) + .rejects.toMatchObject({ code: "PROFILE_CONTEXT_UNAVAILABLE" }); + await expect(service({ now: () => -1 }).mint("work", context, 60_000)) + .rejects.toMatchObject({ code: "PROFILE_CONTEXT_UNAVAILABLE" }); + await expect(service({ now: () => Number.MAX_SAFE_INTEGER + 1 }).mint("work", context, 60_000)) + .rejects.toMatchObject({ code: "PROFILE_CONTEXT_UNAVAILABLE" }); + + const unavailableRead = service({ + keyringProvider: () => stableKeyring, + revocations: { isRevoked: () => { throw new Error("store details"); }, revoke: () => undefined } + }); + await expect(unavailableRead.resolve(minted.handle, context)).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_UNAVAILABLE", + message: "Profile context is unavailable." + }); + const nonBooleanRead = service({ + keyringProvider: () => stableKeyring, + revocations: { isRevoked: () => "no" as unknown as boolean, revoke: () => undefined } + }); + await expect(nonBooleanRead.resolve(minted.handle, context)).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_UNAVAILABLE" + }); + const unavailableWrite = service({ + keyringProvider: () => stableKeyring, + revocations: { isRevoked: () => false, revoke: () => { throw new Error("store details"); } } + }); + await expect(unavailableWrite.revoke(minted.handle, context)).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_UNAVAILABLE" + }); + }); + + it("rejects malformed key-manager snapshots and same-epoch key replacement", async () => { + const context = await authenticated("chat-work"); + const firstKey = Buffer.alloc(32, 0x21); + const secondKey = Buffer.alloc(32, 0x22); + const invalidSnapshots: unknown[] = [ + null, + { activeEpoch: 1, epochs: [null] }, + { activeEpoch: 1, epochs: [ + { epoch: 1, key: firstKey, activatedAtMs: nowMs - 1_000 }, + { epoch: 1, key: firstKey, activatedAtMs: nowMs - 1_000 } + ] }, + { activeEpoch: 1, epochs: [{ epoch: 1, key: firstKey, activatedAtMs: nowMs - 1_000, resolveUntilMs: nowMs + 1_000 }] }, + { activeEpoch: 1, epochs: [{ epoch: 1, key: firstKey, activatedAtMs: nowMs + 31_000 }] }, + { activeEpoch: 1, epochs: [ + { epoch: 1, key: firstKey, activatedAtMs: nowMs - 1_000 }, + { epoch: 2, key: secondKey, activatedAtMs: nowMs - 1_000, resolveUntilMs: nowMs + 1_000 } + ] }, + { activeEpoch: 2, epochs: [ + { epoch: 1, key: firstKey, activatedAtMs: nowMs - 2_000 }, + { epoch: 2, key: secondKey, activatedAtMs: nowMs - 1_000 } + ] }, + { activeEpoch: 2, epochs: [ + { epoch: 1, key: firstKey, activatedAtMs: nowMs - 2_000, resolveUntilMs: nowMs - 2_000 }, + { epoch: 2, key: secondKey, activatedAtMs: nowMs - 1_000 } + ] }, + { activeEpoch: 2, epochs: [{ epoch: 1, key: firstKey, activatedAtMs: nowMs - 2_000, resolveUntilMs: nowMs + 1_000 }] }, + { activeEpoch: 2, epochs: [ + { epoch: 1, key: firstKey, activatedAtMs: nowMs - 2_000, resolveUntilMs: nowMs + 16 * 60_000 }, + { epoch: 2, key: secondKey, activatedAtMs: nowMs } + ] } + ]; + + for (const snapshot of invalidSnapshots) { + await expect(new ProfileContextHandleService(profileContextOptions({ + keyringProvider: () => snapshot as ProfileContextKeyringSnapshot + })).mint("work", context, 60_000)).rejects.toMatchObject({ code: "PROFILE_CONTEXT_UNAVAILABLE" }); + } + + let snapshot = keyring(1, firstKey); + const instance = service({ keyringProvider: () => snapshot }); + await instance.mint("work", context, 60_000); + snapshot = keyring(1, secondKey); + await expect(instance.mint("work", context, 60_000)).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_UNAVAILABLE" + }); + }); + + it("rejects malformed envelopes, authenticated contexts, and replacement inputs", async () => { + const instance = service(); + const context = await authenticated("chat-work"); + const minted = await instance.mint("work", context, 60_000); + const parts = minted.handle.split("."); + const malformed: unknown[] = [ + undefined, + "x".repeat(4_097), + "mctx1.1.bad", + minted.handle.replace(/^mctx1/u, "wrong"), + minted.handle.replace(/^mctx1\.1/u, "mctx1.01"), + [parts[0], parts[1], "!", parts[3], parts[4]].join("."), + [parts[0], parts[1], parts[2], "!", parts[4]].join(".") + ]; + for (const handle of malformed) { + await expect(instance.resolve(handle as string, context)).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_INVALID", + message: "Profile context is invalid." + }); + } + + const invalidContexts: unknown[] = [ + null, + [], + { binding: "bad", expiresAtMs: context.expiresAtMs }, + { binding: context.binding, expiresAtMs: "later" }, + { binding: context.binding, expiresAtMs: nowMs }, + new Proxy({}, { get: () => { throw new Error("private request detail"); } }) + ]; + for (const invalidContext of invalidContexts) { + await expect(instance.resolve(minted.handle, invalidContext as AuthenticatedRequestContext)).rejects.toBeInstanceOf( + ProfileContextHandleError + ); + } + await expect(instance.mint("work", context, 16 * 60_000)).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_INVALID" + }); + await expect(instance.replace( + minted.handle, + "personal", + context, + 60_000, + undefined as unknown as () => void + )).rejects.toMatchObject({ code: "PROFILE_CONTEXT_INVALID" }); + }); + + it("rejects authenticated ciphertext with malformed or inconsistent payload fields", async () => { + const sealingKey = Buffer.alloc(32, 0x31); + const instance = service({ keyringProvider: () => keyring(1, sealingKey) }); + const context = await authenticated("chat-work"); + const base = { + version: 1, + id: Buffer.alloc(16, 0x41).toString("base64url"), + deploymentId, + keyEpoch: 1, + profile: "work", + binding: context.binding, + issuedAtMs: nowMs, + expiresAtMs: nowMs + 60_000 + }; + const invalidPayloads: unknown[] = [ + null, + [], + { ...base, extra: true }, + { ...base, version: 2 }, + { ...base, id: "bad" }, + { ...base, deploymentId: 1 }, + { ...base, profile: 1 }, + { ...base, binding: "bad" }, + { ...base, keyEpoch: "1" }, + { ...base, issuedAtMs: "now" }, + { ...base, expiresAtMs: "later" }, + { ...base, keyEpoch: 0 }, + { ...base, issuedAtMs: -1 }, + { ...base, expiresAtMs: nowMs } + ]; + for (const payload of invalidPayloads) { + await expect(instance.resolve(sealPayload(payload, sealingKey), context)).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_INVALID" + }); + } + await expect(instance.resolve(sealPlaintext("not-json", sealingKey), context)).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_INVALID" + }); + await expect(instance.resolve(sealPayload({ ...base, deploymentId: "other" }, sealingKey), context)) + .rejects.toMatchObject({ code: "PROFILE_CONTEXT_INVALID" }); + await expect(instance.resolve(sealPayload({ ...base, issuedAtMs: nowMs + 31_000, expiresAtMs: nowMs + 60_000 }, sealingKey), context)) + .rejects.toMatchObject({ code: "PROFILE_CONTEXT_INVALID" }); + await expect(instance.resolve(sealPayload({ ...base, issuedAtMs: nowMs - 1, expiresAtMs: nowMs + 16 * 60_000 }, sealingKey), context)) + .rejects.toMatchObject({ code: "PROFILE_CONTEXT_INVALID" }); + }); + + it("normalizes tampering and never reports the bearer or plaintext account data", async () => { + const instance = service(); + const context = await authenticated("chat-work"); + const minted = await instance.mint("work", context, 60_000); + const resolved = await instance.resolve(minted.handle, context); + + expect(minted.handle).not.toContain("work"); + expect(minted.handle).not.toContain(context.binding); + expect(JSON.stringify(resolved)).not.toContain(minted.handle); + expect(resolved.auditCorrelation).toMatch(/^mctxc1\.[A-Za-z0-9_-]{22}$/u); + + let failure: unknown; + try { + await instance.resolve(`${minted.handle}tampered`, context); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(ProfileContextHandleError); + expect(failure).toMatchObject({ code: "PROFILE_CONTEXT_INVALID", message: "Profile context is invalid." }); + expect(String(failure)).not.toContain(minted.handle); + }); + + it("copies audit material and rejects reused sealing/audit keys", async () => { + const sealingKey = Buffer.alloc(32, 0x71); + const auditKey = Buffer.alloc(32, 0x72); + const snapshot = keyring(1, sealingKey); + const instance = service({ keyringProvider: () => snapshot, auditKey }); + auditKey.fill(0); + await expect(instance.mint("work", await authenticated("chat-work"), 60_000)).resolves.toMatchObject({ + profile: "work" + }); + + const reused = Buffer.alloc(32, 0x73); + await expect(service({ keyringProvider: () => keyring(1, reused), auditKey: reused }).mint( + "work", + await authenticated("chat-work"), + 60_000 + )).rejects.toMatchObject({ code: "PROFILE_CONTEXT_UNAVAILABLE" }); + }); + + it("bounds the in-memory revocation store instead of silently evicting live entries", () => { + const store = new InMemoryProfileContextRevocationStore(1); + const first = Buffer.alloc(16, 0x01).toString("base64url"); + const second = Buffer.alloc(16, 0x02).toString("base64url"); + store.revoke(first, Date.now() + 60_000); + expect(() => store.revoke(second, Date.now() + 60_000)).toThrow("capacity is unavailable"); + expect(store.isRevoked(first, Date.now())).toBe(true); + + const pruningStore = new InMemoryProfileContextRevocationStore(1); + pruningStore.revoke(first, Date.now() - 1); + expect(pruningStore.isRevoked(first, Date.now())).toBe(false); + pruningStore.revoke(second, Date.now() + 60_000); + expect(() => pruningStore.revoke("bad", Date.now() + 60_000)).toThrowError( + expect.objectContaining({ code: "PROFILE_CONTEXT_INVALID" }) + ); + }); + + it("does not expose a handle to the replacement audit callback", async () => { + const instance = service(); + const context = await authenticated("chat-work"); + const current = await instance.mint("personal", context, 60_000); + const audit = vi.fn(); + await instance.replace(current.handle, "work", context, 60_000, audit); + expect(audit).toHaveBeenCalledOnce(); + expect(audit.mock.calls[0]?.[0]).not.toHaveProperty("handle"); + expect(audit.mock.calls[0]?.[0]?.replacement).not.toHaveProperty("handle"); + }); +}); diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts index a28990f8..32fe76cf 100644 --- a/tests/public-api.test.ts +++ b/tests/public-api.test.ts @@ -29,12 +29,22 @@ import type { MiftahErrorCode, MiftahErrorDetails, MiftahRuntime, + MiftahRuntimeOptions, + MintedProfileContext, + ModernProfileContextRuntimeOptions, OAuthConfig, OAuthConnectionConfig, OAuthConnectionRef, PolicyConfig, ProcessConfig, ProfileConfig, + ProfileContextHandleErrorCode, + ProfileContextHandleServiceOptions, + ProfileContextKeyEpoch, + ProfileContextKeyringProvider, + ProfileContextKeyringSnapshot, + ProfileContextReplacementAudit, + ProfileContextRevocationStore, ProfileIsolationConfig, ProfileIsolationContainerVolume, ProfileIsolationFile, @@ -44,6 +54,7 @@ import type { ProfileUpstreamOverride, PostHogProfileRoutingMatch, RiskLevel, + ResolvedProfileContext, RoutingConfig, RoutingRule, SecurityConfig, @@ -65,8 +76,13 @@ const fixture = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "fake- const supportedRuntimeExports = [ "AuthenticatedRequestContextError", "CURRENT_CONFIG_VERSION", + "InMemoryProfileContextRevocationStore", "MIFTAH_VERSION", "MiftahError", + "PROFILE_CONTEXT_ARGUMENT", + "PROFILE_CONTEXT_META_KEY", + "ProfileContextHandleError", + "ProfileContextHandleService", "createAuthenticatedRequestContextBoundary", "createMiftahRuntime", "generateConfigSchema", @@ -111,12 +127,22 @@ const supportedTypeExports = [ "MiftahErrorCode", "MiftahErrorDetails", "MiftahRuntime", + "MiftahRuntimeOptions", + "MintedProfileContext", + "ModernProfileContextRuntimeOptions", "OAuthConfig", "OAuthConnectionConfig", "OAuthConnectionRef", "PolicyConfig", "ProcessConfig", "ProfileConfig", + "ProfileContextHandleErrorCode", + "ProfileContextHandleServiceOptions", + "ProfileContextKeyEpoch", + "ProfileContextKeyringProvider", + "ProfileContextKeyringSnapshot", + "ProfileContextReplacementAudit", + "ProfileContextRevocationStore", "ProfileIsolationConfig", "ProfileIsolationContainerVolume", "ProfileIsolationFile", @@ -126,6 +152,7 @@ const supportedTypeExports = [ "ProfileUpstreamOverride", "PostHogProfileRoutingMatch", "RiskLevel", + "ResolvedProfileContext", "RoutingConfig", "RoutingRule", "SecurityConfig", @@ -164,9 +191,19 @@ type PublicTypeImportCoverage = [ MiftahErrorCode, MiftahErrorDetails, MiftahRuntime, + MiftahRuntimeOptions, + MintedProfileContext, + ModernProfileContextRuntimeOptions, PolicyConfig, ProcessConfig, ProfileConfig, + ProfileContextHandleErrorCode, + ProfileContextHandleServiceOptions, + ProfileContextKeyEpoch, + ProfileContextKeyringProvider, + ProfileContextKeyringSnapshot, + ProfileContextReplacementAudit, + ProfileContextRevocationStore, ProfileIsolationConfig, ProfileIsolationContainerVolume, ProfileIsolationFile, @@ -176,6 +213,7 @@ type PublicTypeImportCoverage = [ ProfileUpstreamOverride, PostHogProfileRoutingMatch, RiskLevel, + ResolvedProfileContext, RoutingConfig, RoutingRule, SecurityConfig, diff --git a/tests/stateless-profile-context-runtime.test.ts b/tests/stateless-profile-context-runtime.test.ts new file mode 100644 index 00000000..9eca40ae --- /dev/null +++ b/tests/stateless-profile-context-runtime.test.ts @@ -0,0 +1,347 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import type { Transport, TransportSendOptions } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { CallToolResultSchema, type JSONRPCMessage, type Tool } from "@modelcontextprotocol/sdk/types.js"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { createAuthenticatedRequestContextBoundary } from "../src/http/authenticated-request-context.js"; +import { MiftahServer } from "../src/mcp/server/miftah-server.js"; +import { ProfileManager } from "../src/profiles/profile-manager.js"; +import { + InMemoryProfileContextRevocationStore, + PROFILE_CONTEXT_ARGUMENT, + PROFILE_CONTEXT_META_KEY, + ProfileContextHandleService, + type ModernProfileContextRuntimeOptions +} from "../src/profiles/profile-context-handle.js"; +import { validateConfig } from "../src/config/validate-config.js"; +import { UpstreamProcessManager } from "../src/upstream/upstream-process-manager.js"; + +const fixture = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "fake-upstream.mjs"); +const nowMs = 2_000_000_000_000; + +class AuthenticatedClientTransport implements Transport { + constructor( + private readonly delegate: InMemoryTransport, + private readonly authInfo: () => AuthInfo | undefined + ) {} + + get onclose(): Transport["onclose"] { + return this.delegate.onclose; + } + + set onclose(handler: Transport["onclose"]) { + this.delegate.onclose = handler; + } + + get onerror(): Transport["onerror"] { + return this.delegate.onerror; + } + + set onerror(handler: Transport["onerror"]) { + this.delegate.onerror = handler; + } + + get onmessage(): Transport["onmessage"] { + return this.delegate.onmessage; + } + + set onmessage(handler: Transport["onmessage"]) { + this.delegate.onmessage = handler; + } + + get sessionId(): string | undefined { + return this.delegate.sessionId; + } + + set sessionId(value: string | undefined) { + this.delegate.sessionId = value; + } + + async start(): Promise { + await this.delegate.start(); + } + + async close(): Promise { + await this.delegate.close(); + } + + async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + await this.delegate.send(message, { + ...(options?.relatedRequestId === undefined ? {} : { relatedRequestId: options.relatedRequestId }), + authInfo: this.authInfo() + }); + } +} + +function claims(chatContext: string) { + return { + issuer: "https://issuer.example", + subject: "user-123", + audience: "https://miftah.example", + chatContext, + issuedAtMs: nowMs - 1_000, + expiresAtMs: nowMs + 60 * 60_000 + }; +} + +function authInfo(chatContext: string): AuthInfo { + return { + token: "validated-access-token", + clientId: "trusted-host", + scopes: ["mcp"], + expiresAt: Math.floor((nowMs + 60 * 60_000) / 1_000), + extra: { verifiedClaims: claims(chatContext) } + }; +} + +function parseText(result: unknown): string { + const parsed = CallToolResultSchema.parse(result); + const content = parsed.content[0]; + if (content?.type !== "text") throw new Error("Expected a text tool result."); + return content.text; +} + +function profileHandle(result: unknown): string { + const parsed: unknown = JSON.parse(parseText(result)); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("Expected object."); + const context = (parsed as Record).profileContext; + if (typeof context !== "object" || context === null || Array.isArray(context)) throw new Error("Expected context."); + const handle = (context as Record).handle; + if (typeof handle !== "string") throw new Error("Expected handle."); + return handle; +} + +function inputProperties(tool: Tool | undefined): Record { + const properties = tool?.inputSchema.properties; + if (typeof properties !== "object" || properties === null || Array.isArray(properties)) return {}; + return properties; +} + +describe("modern stateless profile-context runtime", () => { + it("threads explicit handles across instances without changing legacy active-profile state", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-stateless-profile-context-")); + const firstAudit = join(directory, "first-audit.jsonl"); + const secondAudit = join(directory, "second-audit.jsonl"); + await mkdir(directory, { recursive: true }); + const baseConfig = { + version: "1" as const, + name: "accounts", + defaultProfile: "personal", + upstream: { transport: "stdio" as const, command: process.execPath, args: [fixture] }, + profiles: { + personal: { env: { TEST_ACCOUNT_NAME: "personal" } }, + work: { env: { TEST_ACCOUNT_NAME: "work" } } + } + }; + const firstConfig = validateConfig({ ...baseConfig, audit: { path: firstAudit, includeArguments: true } }); + const secondConfig = validateConfig({ ...baseConfig, audit: { path: secondAudit, includeArguments: true } }); + const sealingKey = Buffer.alloc(32, 0x61); + const auditKey = Buffer.alloc(32, 0x62); + const revocations = new InMemoryProfileContextRevocationStore(); + const keyringProvider = () => ({ + activeEpoch: 1, + epochs: [{ epoch: 1, key: sealingKey, activatedAtMs: nowMs - 1_000 }] + }); + const requestBoundary = createAuthenticatedRequestContextBoundary({ + deploymentId: "miftah.example/deployment-a", + bindingKey: Buffer.alloc(32, 0x41), + auditKey: Buffer.alloc(32, 0x42), + clock: () => nowMs, + verifiedClaimsProvider: (request) => { + const extra = (request as AuthInfo | undefined)?.extra; + return extra?.verifiedClaims as ReturnType | undefined; + } + }); + const modern = (): ModernProfileContextRuntimeOptions => ({ + handles: new ProfileContextHandleService({ + deploymentId: "miftah.example/deployment-a", + profiles: ["personal", "work"], + keyringProvider, + auditKey, + revocations, + clock: () => nowMs + }), + authenticatedRequestContext: requestBoundary, + handleLifetimeMs: 10 * 60_000 + }); + const noAuditConfig = validateConfig(baseConfig); + const noAuditManager = new UpstreamProcessManager(noAuditConfig.upstream!, noAuditConfig.profiles, { + startupTimeoutMs: 5_000 + }); + try { + expect(() => new MiftahServer( + noAuditConfig, + new ProfileManager(noAuditConfig), + noAuditManager, + undefined, + undefined, + undefined, + undefined, + undefined, + modern() + )).toThrow(/modern profile context requires a configured audit journal/u); + } finally { + await noAuditManager.close(); + } + const firstManager = new UpstreamProcessManager(firstConfig.upstream!, firstConfig.profiles, { + startupTimeoutMs: 5_000 + }); + const secondManager = new UpstreamProcessManager(secondConfig.upstream!, secondConfig.profiles, { + startupTimeoutMs: 5_000 + }); + const firstProfiles = new ProfileManager(firstConfig); + const secondProfiles = new ProfileManager(secondConfig); + const first = new MiftahServer( + firstConfig, + firstProfiles, + firstManager, + undefined, + undefined, + undefined, + undefined, + undefined, + modern() + ); + const second = new MiftahServer( + secondConfig, + secondProfiles, + secondManager, + undefined, + undefined, + undefined, + undefined, + undefined, + modern() + ); + const [firstClientTransport, firstServerTransport] = InMemoryTransport.createLinkedPair(); + const [secondClientTransport, secondServerTransport] = InMemoryTransport.createLinkedPair(); + let firstAuthentication: AuthInfo | undefined = authInfo("chat-work"); + const secondChat = "chat-work"; + const firstClient = new Client({ name: "modern-client-a", version: "1.0.0" }); + const secondClient = new Client({ name: "modern-client-b", version: "1.0.0" }); + + try { + await Promise.all([ + first.connect(firstServerTransport), + second.connect(secondServerTransport), + firstClient.connect(new AuthenticatedClientTransport(firstClientTransport, () => firstAuthentication)), + secondClient.connect(new AuthenticatedClientTransport(secondClientTransport, () => authInfo(secondChat))) + ]); + + const toolsBefore = await firstClient.listTools(); + const useProfile = toolsBefore.tools.find((tool) => tool.name === "miftah_use_profile"); + const currentProfile = toolsBefore.tools.find((tool) => tool.name === "miftah_current_profile"); + const listProfiles = toolsBefore.tools.find((tool) => tool.name === "miftah_list_profiles"); + const whoami = toolsBefore.tools.find((tool) => tool.name === "whoami"); + expect(inputProperties(useProfile)).toHaveProperty(PROFILE_CONTEXT_ARGUMENT); + expect(useProfile?.inputSchema.required).not.toContain(PROFILE_CONTEXT_ARGUMENT); + expect(inputProperties(currentProfile)).toHaveProperty(PROFILE_CONTEXT_ARGUMENT); + expect(currentProfile?.inputSchema.required).toContain(PROFILE_CONTEXT_ARGUMENT); + expect(inputProperties(listProfiles)).not.toHaveProperty(PROFILE_CONTEXT_ARGUMENT); + expect(inputProperties(whoami)).toHaveProperty(PROFILE_CONTEXT_ARGUMENT); + expect(whoami?.inputSchema.required).toContain(PROFILE_CONTEXT_ARGUMENT); + + firstAuthentication = undefined; + await expect(firstClient.listTools()).rejects.toThrow(/PROFILE_CONTEXT_UNAVAILABLE/u); + await expect(firstClient.callTool({ name: "miftah_list_profiles" })).resolves.toMatchObject({ + isError: true, + content: [{ type: "text", text: "PROFILE_CONTEXT_UNAVAILABLE: Profile context is unavailable." }] + }); + firstAuthentication = authInfo("chat-work"); + + const workHandle = profileHandle(await firstClient.callTool({ + name: "miftah_use_profile", + arguments: { profile: "work" } + })); + expect(firstProfiles.current().activeProfile).toBe("personal"); + + const secondIdentity = await secondClient.callTool({ + name: "whoami", + arguments: { [PROFILE_CONTEXT_ARGUMENT]: workHandle } + }); + expect(parseText(secondIdentity)).toContain("work"); + expect(JSON.stringify(secondIdentity)).not.toContain(workHandle); + expect(secondProfiles.current().activeProfile).toBe("personal"); + + const echo = await firstClient.callTool({ + name: "echo", + arguments: { message: "safe", [PROFILE_CONTEXT_ARGUMENT]: workHandle } + }); + expect(parseText(echo)).toContain("safe"); + expect(JSON.stringify(echo)).not.toContain(workHandle); + await expect(firstClient.callTool({ + name: "echo", + arguments: { + message: `embedded ${workHandle}`, + [PROFILE_CONTEXT_ARGUMENT]: workHandle + } + })).resolves.toMatchObject({ + isError: true, + content: [{ type: "text", text: "PROFILE_CONTEXT_INVALID: Profile context is invalid." }] + }); + + const resource = await secondClient.readResource({ + uri: "account://current", + _meta: { [PROFILE_CONTEXT_META_KEY]: workHandle } + }); + expect(resource).toMatchObject({ contents: [{ text: "work" }] }); + await expect(secondClient.getPrompt({ + name: "account_prompt", + arguments: { message: `embedded ${workHandle}` }, + _meta: { [PROFILE_CONTEXT_META_KEY]: workHandle } + })).rejects.toThrow(/PROFILE_CONTEXT_INVALID/u); + + firstAuthentication = authInfo("chat-personal"); + await expect(firstClient.callTool({ + name: "whoami", + arguments: { [PROFILE_CONTEXT_ARGUMENT]: workHandle } + })).resolves.toMatchObject({ + isError: true, + content: [{ type: "text", text: "PROFILE_CONTEXT_INVALID: Profile context is invalid." }] + }); + firstAuthentication = authInfo("chat-work"); + + const personalHandle = profileHandle(await secondClient.callTool({ + name: "miftah_use_profile", + arguments: { profile: "personal", [PROFILE_CONTEXT_ARGUMENT]: workHandle } + })); + expect(personalHandle).not.toBe(workHandle); + await expect(firstClient.callTool({ + name: "whoami", + arguments: { [PROFILE_CONTEXT_ARGUMENT]: workHandle } + })).resolves.toMatchObject({ + isError: true, + content: [{ type: "text", text: "PROFILE_CONTEXT_REVOKED: Profile context has been revoked." }] + }); + expect(parseText(await firstClient.callTool({ + name: "whoami", + arguments: { [PROFILE_CONTEXT_ARGUMENT]: personalHandle } + }))).toContain("personal"); + + const toolsAfter = await secondClient.listTools(); + expect(toolsAfter.tools).toEqual(toolsBefore.tools); + expect(firstProfiles.current().activeProfile).toBe("personal"); + expect(secondProfiles.current().activeProfile).toBe("personal"); + + const auditText = `${await readFile(firstAudit, "utf8")}\n${await readFile(secondAudit, "utf8")}`; + expect(auditText).not.toContain(workHandle); + expect(auditText).not.toContain(personalHandle); + expect(auditText).toMatch(/"profileContextCorrelation":"mctxc1\.[A-Za-z0-9_-]{22}"/u); + } finally { + await Promise.allSettled([ + firstClient.close(), + secondClient.close(), + first.close(), + second.close(), + firstManager.close(), + secondManager.close() + ]); + await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 25 }); + } + }, 30_000); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 04f0836d..3d37c8c7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ include: [ "src/config/**/*.ts", "src/http/authenticated-request-context.ts", + "src/profiles/profile-context-handle.ts", "src/secrets/**/*.ts", "src/mcp/server/operation-pipeline.ts", "src/mcp/server/tool-registry.ts", @@ -33,6 +34,7 @@ export default defineConfig({ thresholds: { "src/config/**/*.ts": { lines: 95, functions: 95, branches: 85 }, "src/http/authenticated-request-context.ts": { lines: 95, functions: 100, branches: 90 }, + "src/profiles/profile-context-handle.ts": { lines: 95, functions: 100, branches: 90 }, "src/secrets/**/*.ts": { lines: 93, functions: 95, branches: 90 }, "src/mcp/server/operation-pipeline.ts": { lines: 85, functions: 95, branches: 75 }, "src/mcp/server/tool-registry.ts": { lines: 93, functions: 95, branches: 90 }, From 9446f6c2537e31fddafa2c5af9b82d2e78663fbf Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Tue, 11 Aug 2026 15:55:55 +0400 Subject: [PATCH 2/3] fix(profiles): address stateless context review (#377) --- CHANGELOG.md | 2 +- docs/library-api.md | 6 +- ...8-11-stateless-profile-context-decision.md | 2 +- src/audit/audit-trail.ts | 2 + src/mcp/server/miftah-server.ts | 324 ++++++++++-------- src/profiles/profile-context-handle.ts | 16 +- tests/profile-context-handle.test.ts | 76 ++-- .../stateless-profile-context-runtime.test.ts | 134 +++++++- 8 files changed, 389 insertions(+), 173 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8321a975..92e85246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. The format ### Added -- [#377](https://github.com/mohanagy/miftah/issues/377) Added the opt-in production profile-context boundary for trusted modern stateless hosts. Short-lived authenticated-encrypted handles bind a named profile to the verified issuer, subject, audience, chat, deployment, and monotonic sealing-key epoch; deployment-wide revocation, exact expiry, removed-profile checks, bearer-free keyed audit correlation, audited replacement-before-revocation ordering, and fixed fail-closed errors apply on every request. Reserved tool or request metadata is stripped before audit argument capture and upstream forwarding, modern discovery is independent of prior selection calls, and existing stdio plus CLI-owned session-aware HTTP behavior remains unchanged until protocol-era negotiation is enabled separately. +- [#377](https://github.com/mohanagy/miftah/issues/377) Added the opt-in production profile-context boundary for trusted modern stateless hosts. Short-lived authenticated-encrypted handles bind a named profile to the verified issuer, subject, audience, chat, deployment, and monotonic sealing-key epoch; deployment-wide revocation, exact expiry, removed-profile checks, bearer-free keyed audit correlation, audited rejection and replacement-before-revocation ordering, and fixed fail-closed errors apply on every request. Reserved tool or request metadata is stripped before audit argument capture and upstream forwarding, transition approvals are bound to the authenticated chat correlation, modern discovery enforces identical client-visible tools across profiles and is independent of prior selection calls, and existing stdio plus CLI-owned session-aware HTTP behavior remains unchanged until protocol-era negotiation is enabled separately. - [#376](https://github.com/mohanagy/miftah/issues/376) Added a public authenticated request-context boundary for future modern stateless handling. Trusted embedding hosts can provide verified issuer, subject, audience, per-chat, issuance, and expiry claims; Miftah derives only opaque deployment-bound and separately keyed audit correlations, fails closed on missing, malformed, expired, or mismatched context, and never falls back to MCP `clientInfo`, arbitrary headers, request metadata, tool arguments, or mutable profile state. The existing CLI-owned Streamable HTTP server remains the legacy session-aware path until a supported host supplies the trusted per-chat claim and modern protocol integration is enabled. ## [1.0.0] - 2026-08-11 diff --git a/docs/library-api.md b/docs/library-api.md index b95e2844..b4b77004 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -47,11 +47,11 @@ The current CLI-owned Streamable HTTP server remains the documented legacy sessi `ProfileContextHandleService` is the production account-selection primitive for an embedding host that has already authenticated each modern request. Pass it, together with the host's `AuthenticatedRequestContextBoundary`, through `createMiftahRuntime(configPath, { modernProfileContext })`. The configuration must enable a concrete audit journal so profile transitions cannot silently bypass their required audit commit. The host must provide its verified request result through the MCP SDK `authInfo` request field. Miftah authenticates first and derives the binding only through the configured boundary; it does not trust `clientInfo`, arbitrary headers, raw request `_meta`, tool arguments, or a model-created conversation identifier as identity. -Each handle is a short-lived AES-256-GCM bearer bound to the deployment, sealing-key epoch, existing profile, verified issuer, subject, audience, and trusted per-chat claim. All instances in a deployment must use the same atomic `ProfileContextKeyringSnapshot` and deployment-wide `ProfileContextRevocationStore`. A retained `ProfileContextKeyEpoch` may resolve old handles only during its declared overlap, and an instance rejects minting-key rollback. Key-manager, clock, randomness, and revocation failures fail closed. The audit correlation is produced with a separate key. Never reuse the sealing key, authenticated-context binding key, or either audit key. +Each handle is a short-lived AES-256-GCM bearer bound to the deployment, sealing-key epoch, existing profile, verified issuer, subject, audience, and trusted per-chat claim. All instances in a deployment must use the same atomic `ProfileContextKeyringSnapshot` and deployment-wide `ProfileContextRevocationStore`. A retained `ProfileContextKeyEpoch` may resolve old handles only during its declared overlap. The service rejects minting-key rollback and same-epoch key replacement within one process, but that memory resets on restart; the deployment key manager must enforce those rules at the source and alert on every epoch regression. Key-manager, clock, randomness, and revocation failures fail closed. The audit correlation is produced with a separate key. Never reuse the sealing key, authenticated-context binding key, or either audit key. -`InMemoryProfileContextRevocationStore` is bounded and appropriate only for tests or multiple services in one process. A round-robin or multi-process deployment must provide shared revocation storage whose successful writes are visible to every instance before the API reports completion. The keyring provider and revocation store are trusted deployment infrastructure; the handle contains no OAuth token or upstream credential. +`InMemoryProfileContextRevocationStore` is bounded and appropriate only for tests or multiple services in one process. A round-robin or multi-process deployment must provide shared revocation storage whose successful writes are visible to every instance before the API reports completion. `ProfileContextRevocationStore.revoke` receives Miftah's validated operation time so bounded stores can prune consistently with an injected clock. The keyring provider and revocation store are trusted deployment infrastructure; the handle contains no OAuth token or upstream credential. `ProfileContextKeyringProvider` is invoked once per mint, resolve, and revoke and twice during replace, so hosts must serve a short-TTL cached atomic snapshot and enforce their own key-manager timeout. -In modern mode, account-sensitive tool schemas include the reserved model-visible `PROFILE_CONTEXT_ARGUMENT`. The first `miftah_use_profile` call may omit it and receives a new handle. A switch requires the current handle, commits a bearer-free audit transition, then revokes the old handle before returning its replacement. Tools that do not carry ordinary arguments use `PROFILE_CONTEXT_META_KEY`. Miftah strips either form before audit argument capture and before every upstream call, rejects duplicate or nested bearer placement, and records only the separately keyed correlation. Tool discovery is derived from configuration and does not change because a prior request selected another profile. +In modern mode, account-sensitive tool schemas include the reserved model-visible `PROFILE_CONTEXT_ARGUMENT`. The first `miftah_use_profile` call may omit it and receives a new handle. A switch requires the current handle, commits a bearer-free audit transition, then revokes the old handle before returning its replacement. Tools that do not carry ordinary arguments use `PROFILE_CONTEXT_META_KEY`. Miftah strips either form before audit argument capture and before every upstream call, rejects duplicate or nested bearer placement, and records only the separately keyed correlation. Modern mode always applies strict cross-profile tool discovery, so every profile must expose identical client-visible tool names and schemas; discovery remains configuration-derived and cannot change because a prior request selected another profile. A valid handle selects a profile; it is not operation authorization or idempotency. Policy, approval, identity, lease, OAuth, and upstream checks still run for every request. Missing, malformed, tampered, expired, revoked, cross-principal, cross-chat, cross-deployment, and removed-profile handles return fixed `ProfileContextHandleErrorCode` failures. The modern runtime never reads or mutates `ProfileManager`'s legacy active profile. diff --git a/docs/plans/2026-08-11-stateless-profile-context-decision.md b/docs/plans/2026-08-11-stateless-profile-context-decision.md index 5e45cdbb..d82d0c04 100644 --- a/docs/plans/2026-08-11-stateless-profile-context-decision.md +++ b/docs/plans/2026-08-11-stateless-profile-context-decision.md @@ -22,7 +22,7 @@ The selected handle format is a short-lived, authenticated-encrypted token conta Every production instance for one deployment must share the active sealing-key epoch and a bounded revocation backend. Resolution fails closed if required key or revocation state is unavailable. Audit records use a separate keyed correlation derived from the internal identifier; the bearer itself is never logged, exported, diagnosed, or forwarded upstream. -The non-secret envelope carries the version and key-epoch identifier so an instance can select the candidate key before opening the authenticated ciphertext. Both values, plus the deployment identifier, are authenticated as additional data. A deployment keyring has exactly one active epoch for minting and may retain explicitly configured previous epochs for resolution only. The overlap lasts no longer than the maximum handle lifetime plus bounded clock skew; after that window the previous key is removed and its remaining handles fail closed. Unknown, disabled, future, or malformed epochs are invalid. Rotation changes the active epoch atomically across instances: new handles use only the new epoch, while unexpired old handles resolve only during the declared overlap. Rollback to an older minting epoch is forbidden. +The non-secret envelope carries the version and key-epoch identifier so an instance can select the candidate key before opening the authenticated ciphertext. Both values, plus the deployment identifier, are authenticated as additional data. A deployment keyring has exactly one active epoch for minting and may retain explicitly configured previous epochs for resolution only. The overlap lasts no longer than the maximum handle lifetime plus bounded clock skew; after that window the previous key is removed and its remaining handles fail closed. Unknown, disabled, future, or malformed epochs are invalid. Rotation changes the active epoch atomically across instances: new handles use only the new epoch, while unexpired old handles resolve only during the declared overlap. `ProfileContextHandleService` remembers the highest active epoch and its key only within one process, so restarts reset that defense. The deployment key manager must reject epoch rollback and same-epoch key replacement at the source, and operators must alert on any observed epoch regression. This keeps the one-connector, named-account experience for hosts that can provide the trusted chat binding. Until a host can do so, modern stateless mode must use an operator-locked/profile-scoped endpoint or require an explicit profile for each call. It must not claim chat-scoped switching. Legacy stdio and session-aware HTTP retain their current connection-bound behavior during the documented compatibility window. diff --git a/src/audit/audit-trail.ts b/src/audit/audit-trail.ts index bcc25037..b812855a 100644 --- a/src/audit/audit-trail.ts +++ b/src/audit/audit-trail.ts @@ -21,8 +21,10 @@ export interface AuditOperationInput { } export interface AuditScopeUpdate { + sourceProfile?: string; name?: string; profile?: string; + arguments?: Record; upstream?: string; routingReason?: string; routingSource?: AuditRoutingSource; diff --git a/src/mcp/server/miftah-server.ts b/src/mcp/server/miftah-server.ts index 35322129..ac9f92fc 100644 --- a/src/mcp/server/miftah-server.ts +++ b/src/mcp/server/miftah-server.ts @@ -844,6 +844,7 @@ export class MiftahServer { }; } if (extracted.handle === undefined) throw this.profileContextError("PROFILE_CONTEXT_INVALID"); + if (name.includes(extracted.handle)) throw this.profileContextError("PROFILE_CONTEXT_INVALID"); let resolved: ResolvedProfileContext; try { resolved = await this.modernProfileContext.handles.resolve(extracted.handle, authenticated); @@ -943,6 +944,31 @@ export class MiftahServer { }; } + private requestAuditFallbackProfileState(): Promise | ProfileStateSnapshot { + return this.modernProfileContext === undefined + ? this.captureStableProfileState() + : this.modernCatalogProfileState(); + } + + private async prepareResourcePromptRequest( + rawParams: Params, + extra: ProxiedRequestExtra, + audit: AuditScope + ): Promise<{ readonly params: Params; readonly source: ProfileStateSnapshot }> { + const params = this.modernProfileContext === undefined + ? rawParams + : stripProfileContextMetadata(rawParams); + const source = await this.modernRequestProfileState(extra, params); + audit.update({ + sourceProfile: source.activeProfile, + profile: source.activeProfile, + ...(source.profileContextCorrelation === undefined + ? {} + : { profileContextCorrelation: source.profileContextCorrelation }) + }); + return { params, source }; + } + private normalizeProfileContextError(error: unknown): MiftahError { if (error instanceof MiftahError) return error; let code: string | undefined; @@ -958,7 +984,15 @@ export class MiftahServer { return this.profileContextError("PROFILE_CONTEXT_EXPIRED"); } if (code === "PROFILE_CONTEXT_REVOKED") return this.profileContextError("PROFILE_CONTEXT_REVOKED"); - return this.profileContextError("PROFILE_CONTEXT_INVALID"); + if ( + code === "AUTH_CONTEXT_INVALID" || + code === "AUTH_CONTEXT_MISMATCH" || + code === "PROFILE_CONTEXT_INVALID" + ) { + return this.profileContextError("PROFILE_CONTEXT_INVALID"); + } + if (error instanceof ProfileContextHandleError) return this.profileContextError("PROFILE_CONTEXT_INVALID"); + return this.profileContextError("PROFILE_CONTEXT_UNAVAILABLE"); } private profileContextError( @@ -980,11 +1014,11 @@ export class MiftahServer { const source = this.modernProfileContext === undefined ? await this.captureStableProfileState() : this.modernCatalogProfileState(); - if (this.modernProfileContext !== undefined) await this.authenticateModernRequest(extra); const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "tools/list", name: "tools", sourceProfile: source.activeProfile }, async (audit) => { + if (this.modernProfileContext !== undefined) await this.authenticateModernRequest(extra); const upstream = this.auditUpstreamName(); if (upstream) audit.update({ upstream }); const { profile, snapshot } = await this.runWithUpstreamRequest( @@ -1006,28 +1040,31 @@ export class MiftahServer { this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { const name = request.params.name; - let prepared: PreparedCall; - try { - prepared = await this.prepareCall(name, request.params.arguments ?? {}, extra); - } catch (error) { - return textResult(this.toSafeError(error).message, true); - } - const { args, source } = prepared; const isManagementTool = isManagementToolName(name); const isApprovalManagementTool = name === "miftah_approve" || name === "miftah_deny"; + const auditSource = this.modernProfileContext === undefined + ? await this.captureStableProfileState() + : this.modernCatalogProfileState(); const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: isManagementTool ? managementOperation(name) : "tools/call", - name: isManagementTool ? managementName(name, args) : name, - sourceProfile: source.activeProfile, - ...(source.profileContextCorrelation === undefined - ? {} - : { profileContextCorrelation: source.profileContextCorrelation }), - ...(isApprovalManagementTool ? {} : { arguments: args }) + name: isManagementTool ? "management" : "tool", + sourceProfile: auditSource.activeProfile }, - (audit) => - isManagementTool + async (audit) => { + const prepared = await this.prepareCall(name, request.params.arguments ?? {}, extra); + const { args, source } = prepared; + audit.update({ + sourceProfile: source.activeProfile, + profile: source.activeProfile, + name: isManagementTool ? managementName(name, args) : name, + ...(source.profileContextCorrelation === undefined + ? {} + : { profileContextCorrelation: source.profileContextCorrelation }), + ...(isApprovalManagementTool ? {} : { arguments: args }) + }); + return isManagementTool ? this.runWithUpstreamRequest( upstreamRequest, () => @@ -1048,7 +1085,8 @@ export class MiftahServer { source, { requestId: extra.requestId, signal: extra.signal }, upstreamRequest - ), + ); + }, (error) => textResult(error.message, true), (result) => result.isError @@ -1060,60 +1098,55 @@ export class MiftahServer { if (this.resourcePromptProxy.available) { const upstreamName = this.resourcePromptProxy.upstreamName; this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async (request, extra) => { - const params = this.modernProfileContext === undefined - ? request.params - : stripProfileContextMetadata(request.params); - const source = await this.modernRequestProfileState(extra, params); + const auditSource = await this.requestAuditFallbackProfileState(); const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "resources/templates/list", name: "resource-templates", - sourceProfile: source.activeProfile, - ...(source.profileContextCorrelation === undefined - ? {} - : { profileContextCorrelation: source.profileContextCorrelation }), - arguments: params?.cursor === undefined ? {} : { cursor: params.cursor } + sourceProfile: auditSource.activeProfile }, - async (audit) => this.runWithUpstreamRequest(upstreamRequest, async () => { - const upstream = this.resourcePromptRegistry ? undefined : this.auditUpstreamName(upstreamName); - if (upstream) audit.update({ upstream }); - if (this.resourcePromptRegistry) { - try { - return await this.resourcePromptRegistry.listResourceTemplates( - source.activeProfile, - params?.cursor, - upstreamRequest.options - ); - } finally { - await this.notifyResourceAvailabilityChange(source.activeProfile); + async (audit) => { + const { params, source } = await this.prepareResourcePromptRequest(request.params, extra, audit); + audit.update({ arguments: params?.cursor === undefined ? {} : { cursor: params.cursor } }); + return this.runWithUpstreamRequest(upstreamRequest, async () => { + const upstream = this.resourcePromptRegistry ? undefined : this.auditUpstreamName(upstreamName); + if (upstream) audit.update({ upstream }); + if (this.resourcePromptRegistry) { + try { + return await this.resourcePromptRegistry.listResourceTemplates( + source.activeProfile, + params?.cursor, + upstreamRequest.options + ); + } finally { + await this.notifyResourceAvailabilityChange(source.activeProfile); + } } - } - return redactDirectResourceTemplateList( - await this.discoverResourceTemplates(source.activeProfile, upstreamName, params, upstreamRequest.options) - ); - }) + return redactDirectResourceTemplateList( + await this.discoverResourceTemplates(source.activeProfile, upstreamName, params, upstreamRequest.options) + ); + }); + } ); }); this.server.setRequestHandler(SubscribeRequestSchema, async (request, extra) => { - const params = this.modernProfileContext === undefined - ? request.params - : stripProfileContextMetadata(request.params); - const source = await this.modernRequestProfileState(extra, params); + const auditSource = await this.requestAuditFallbackProfileState(); const approvalContext: ApprovalRequestContext = { requestId: extra.requestId, signal: extra.signal }; const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "resources/subscribe", - name: this.redactor.redactUri(params.uri), - sourceProfile: source.activeProfile, - ...(source.profileContextCorrelation === undefined - ? {} - : { profileContextCorrelation: source.profileContextCorrelation }), - arguments: { uri: this.redactor.redactUri(params.uri) } + name: "resource", + sourceProfile: auditSource.activeProfile }, async (audit) => { + const { params, source } = await this.prepareResourcePromptRequest(request.params, extra, audit); + audit.update({ + name: this.redactor.redactUri(params.uri), + arguments: { uri: this.redactor.redactUri(params.uri) } + }); if (!this.resourceSubscriptionsAvailable) { throw new MiftahError( "RESOURCE_SUBSCRIPTION_UNSUPPORTED", @@ -1127,23 +1160,21 @@ export class MiftahServer { }); this.server.setRequestHandler(UnsubscribeRequestSchema, async (request, extra) => { - const params = this.modernProfileContext === undefined - ? request.params - : stripProfileContextMetadata(request.params); - const source = await this.modernRequestProfileState(extra, params); + const auditSource = await this.requestAuditFallbackProfileState(); const approvalContext: ApprovalRequestContext = { requestId: extra.requestId, signal: extra.signal }; const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "resources/unsubscribe", - name: this.redactor.redactUri(params.uri), - sourceProfile: source.activeProfile, - ...(source.profileContextCorrelation === undefined - ? {} - : { profileContextCorrelation: source.profileContextCorrelation }), - arguments: { uri: this.redactor.redactUri(params.uri) } + name: "resource", + sourceProfile: auditSource.activeProfile }, async (audit) => { + const { params, source } = await this.prepareResourcePromptRequest(request.params, extra, audit); + audit.update({ + name: this.redactor.redactUri(params.uri), + arguments: { uri: this.redactor.redactUri(params.uri) } + }); if (!this.resourceSubscriptionsAvailable) { throw new MiftahError( "RESOURCE_SUBSCRIPTION_UNSUPPORTED", @@ -1157,60 +1188,55 @@ export class MiftahServer { }); this.server.setRequestHandler(ListResourcesRequestSchema, async (request, extra) => { - const params = this.modernProfileContext === undefined - ? request.params - : stripProfileContextMetadata(request.params); - const source = await this.modernRequestProfileState(extra, params); + const auditSource = await this.requestAuditFallbackProfileState(); const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "resources/list", name: "resources", - sourceProfile: source.activeProfile, - ...(source.profileContextCorrelation === undefined - ? {} - : { profileContextCorrelation: source.profileContextCorrelation }), - arguments: params?.cursor === undefined ? {} : { cursor: params.cursor } + sourceProfile: auditSource.activeProfile }, - async (audit) => this.runWithUpstreamRequest(upstreamRequest, async () => { - const upstream = this.resourcePromptRegistry ? undefined : this.auditUpstreamName(upstreamName); - if (upstream) audit.update({ upstream }); - if (this.resourcePromptRegistry) { - try { - return await this.resourcePromptRegistry.listResources( - source.activeProfile, - params?.cursor, - upstreamRequest.options - ); - } finally { - await this.notifyResourceAvailabilityChange(source.activeProfile); + async (audit) => { + const { params, source } = await this.prepareResourcePromptRequest(request.params, extra, audit); + audit.update({ arguments: params?.cursor === undefined ? {} : { cursor: params.cursor } }); + return this.runWithUpstreamRequest(upstreamRequest, async () => { + const upstream = this.resourcePromptRegistry ? undefined : this.auditUpstreamName(upstreamName); + if (upstream) audit.update({ upstream }); + if (this.resourcePromptRegistry) { + try { + return await this.resourcePromptRegistry.listResources( + source.activeProfile, + params?.cursor, + upstreamRequest.options + ); + } finally { + await this.notifyResourceAvailabilityChange(source.activeProfile); + } } - } - return redactDirectResourceList( - await this.discoverResources(source.activeProfile, upstreamName, params, upstreamRequest.options) - ); - }) + return redactDirectResourceList( + await this.discoverResources(source.activeProfile, upstreamName, params, upstreamRequest.options) + ); + }); + } ); }); this.server.setRequestHandler(ReadResourceRequestSchema, async (request, extra) => { - const params = this.modernProfileContext === undefined - ? request.params - : stripProfileContextMetadata(request.params); - const source = await this.modernRequestProfileState(extra, params); + const auditSource = await this.requestAuditFallbackProfileState(); const approvalContext: ApprovalRequestContext = { requestId: extra.requestId, signal: extra.signal }; const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "resources/read", - name: this.redactor.redactUri(params.uri), - sourceProfile: source.activeProfile, - ...(source.profileContextCorrelation === undefined - ? {} - : { profileContextCorrelation: source.profileContextCorrelation }), - arguments: { uri: this.redactor.redactUri(params.uri) } + name: "resource", + sourceProfile: auditSource.activeProfile }, async (audit) => { + const { params, source } = await this.prepareResourcePromptRequest(request.params, extra, audit); + audit.update({ + name: this.redactor.redactUri(params.uri), + arguments: { uri: this.redactor.redactUri(params.uri) } + }); if (this.resourcePromptRegistry) { try { return await this.executeResourceRead(source, upstreamName, params, audit, approvalContext, upstreamRequest); @@ -1224,60 +1250,52 @@ export class MiftahServer { }); this.server.setRequestHandler(ListPromptsRequestSchema, async (request, extra) => { - const params = this.modernProfileContext === undefined - ? request.params - : stripProfileContextMetadata(request.params); - const source = await this.modernRequestProfileState(extra, params); + const auditSource = await this.requestAuditFallbackProfileState(); const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "prompts/list", name: "prompts", - sourceProfile: source.activeProfile, - ...(source.profileContextCorrelation === undefined - ? {} - : { profileContextCorrelation: source.profileContextCorrelation }), - arguments: params?.cursor === undefined ? {} : { cursor: params.cursor } + sourceProfile: auditSource.activeProfile }, - async (audit) => this.runWithUpstreamRequest(upstreamRequest, async () => { - const upstream = this.resourcePromptRegistry ? undefined : this.auditUpstreamName(upstreamName); - if (upstream) audit.update({ upstream }); - if (this.resourcePromptRegistry) { - try { - return await this.resourcePromptRegistry.listPrompts( - source.activeProfile, - params?.cursor, - upstreamRequest.options - ); - } finally { - await this.notifyPromptAvailabilityChange(source.activeProfile); + async (audit) => { + const { params, source } = await this.prepareResourcePromptRequest(request.params, extra, audit); + audit.update({ arguments: params?.cursor === undefined ? {} : { cursor: params.cursor } }); + return this.runWithUpstreamRequest(upstreamRequest, async () => { + const upstream = this.resourcePromptRegistry ? undefined : this.auditUpstreamName(upstreamName); + if (upstream) audit.update({ upstream }); + if (this.resourcePromptRegistry) { + try { + return await this.resourcePromptRegistry.listPrompts( + source.activeProfile, + params?.cursor, + upstreamRequest.options + ); + } finally { + await this.notifyPromptAvailabilityChange(source.activeProfile); + } } - } - return redactDirectPromptList( - await this.discoverPrompts(source.activeProfile, upstreamName, params, upstreamRequest.options) - ); - }) + return redactDirectPromptList( + await this.discoverPrompts(source.activeProfile, upstreamName, params, upstreamRequest.options) + ); + }); + } ); }); this.server.setRequestHandler(GetPromptRequestSchema, async (request, extra) => { - const params = this.modernProfileContext === undefined - ? request.params - : stripProfileContextMetadata(request.params); - const source = await this.modernRequestProfileState(extra, params); + const auditSource = await this.requestAuditFallbackProfileState(); const approvalContext: ApprovalRequestContext = { requestId: extra.requestId, signal: extra.signal }; const upstreamRequest = this.upstreamRequestContext(extra); return this.runAudited( { operation: "prompts/get", - name: params.name, - sourceProfile: source.activeProfile, - ...(source.profileContextCorrelation === undefined - ? {} - : { profileContextCorrelation: source.profileContextCorrelation }), - arguments: { ...(params.arguments ?? {}), name: params.name } + name: "prompt", + sourceProfile: auditSource.activeProfile }, async (audit) => { + const { params, source } = await this.prepareResourcePromptRequest(request.params, extra, audit); + audit.update({ name: params.name, arguments: { ...(params.arguments ?? {}), name: params.name } }); if (this.resourcePromptRegistry) { try { return await this.executePromptGet(source, upstreamName, params, audit, approvalContext, upstreamRequest); @@ -1804,7 +1822,13 @@ export class MiftahServer { ) { throw new MiftahError("PROFILE_SWITCH_DISABLED", "PROFILE_SWITCH_DISABLED: profile switching is disabled"); } - await this.requireModernProfileTransitionConfirmation(action, profile, source, approvalContext); + await this.requireModernProfileTransitionConfirmation( + action, + profile, + source, + modern.authenticated, + approvalContext + ); const lifetimeMs = runtime.handleLifetimeMs ?? 15 * 60_000; let minted: MintedProfileContext; try { @@ -1868,6 +1892,7 @@ export class MiftahServer { action: "switch" | "reset", profile: string, source: ProfileStateSnapshot, + authenticated: AuthenticatedRequestContext, context?: ApprovalRequestContext ): Promise { if (this.config.security?.requireProfileSwitchConfirmation !== true) return; @@ -1879,7 +1904,7 @@ export class MiftahServer { operation: `profiles/${action}`, name: profile, displayName: `profile '${profile}'`, - arguments: { profile } + arguments: { profile, requestCorrelation: authenticated.auditCorrelation } }, context, profileSwitchApprovalErrors @@ -2313,8 +2338,8 @@ export class MiftahServer { } private async discoverTools(profile: string, options?: UpstreamRequestOptions): Promise { - const profiles = - this.config.tooling?.toolDiscoveryMode === "strict" ? Object.keys(this.config.profiles).sort() : [profile]; + const strictDiscovery = this.strictToolDiscoveryEnabled(); + const profiles = strictDiscovery ? Object.keys(this.config.profiles).sort() : [profile]; const upstreamNames = this.upstreamNames(); const optionsForUpstream = aggregateProgressOptions( options, @@ -2334,7 +2359,7 @@ export class MiftahServer { const failures = outcomes.flatMap(([profileName, outcome]) => outcome.failures.map((failure) => ({ profile: profileName, ...failure })) ); - if (failures.length > 0 && this.config.tooling?.toolDiscoveryMode === "strict") { + if (failures.length > 0 && strictDiscovery) { const activeFailures = failures .filter((failure) => failure.profile === profile) .map((failure) => `upstream '${failure.upstreamName}' (${failure.code}: ${failure.message})`); @@ -2349,7 +2374,7 @@ export class MiftahServer { ].join("; ")}` ); } - if (this.config.tooling?.toolDiscoveryMode === "strict") { + if (strictDiscovery) { this.assertStrictToolSchemas(outcomes); } const selected = outcomes.find(([profileName]) => profileName === profile); @@ -2368,6 +2393,10 @@ export class MiftahServer { }; } + private strictToolDiscoveryEnabled(): boolean { + return this.modernProfileContext !== undefined || this.config.tooling?.toolDiscoveryMode === "strict"; + } + private async discoverToolsForProfile( profile: string, upstreamNames: readonly (string | undefined)[], @@ -3105,7 +3134,7 @@ export class MiftahServer { private handleUpstreamHealthChange(health: UpstreamHealth): void { if (health.processState !== "failed" || this.restartingProfiles.has(health.profile)) return; - if (this.config.tooling?.toolDiscoveryMode === "strict") { + if (this.strictToolDiscoveryEnabled()) { const snapshots = Object.keys(this.config.profiles).map((profile) => [profile, this.toolRegistry.peek(profile)] as const); if (!snapshots.some(([, snapshot]) => snapshot?.isComplete())) return; for (const [profile, snapshot] of snapshots) { @@ -3326,7 +3355,14 @@ function extractProfileContext( const handle = (argumentHandle ?? metadataHandle) as string | undefined; const args: Record = {}; for (const [key, value] of Object.entries(input)) { - if (key !== PROFILE_CONTEXT_ARGUMENT) args[key] = value; + if (key !== PROFILE_CONTEXT_ARGUMENT) { + Object.defineProperty(args, key, { + value, + enumerable: true, + writable: true, + configurable: true + }); + } } if (handle !== undefined && containsProfileContextBearer(args, handle)) { throw new ProfileContextHandleError("PROFILE_CONTEXT_INVALID"); diff --git a/src/profiles/profile-context-handle.ts b/src/profiles/profile-context-handle.ts index cc8760ca..41f8ca98 100644 --- a/src/profiles/profile-context-handle.ts +++ b/src/profiles/profile-context-handle.ts @@ -65,13 +65,17 @@ export interface ProfileContextKeyringSnapshot { readonly epochs: readonly ProfileContextKeyEpoch[]; } +/** + * Returns a short-TTL cached atomic snapshot with a host-enforced timeout. + * The service calls it once per mint, resolve, or revoke and twice per replace. + */ export type ProfileContextKeyringProvider = () => ProfileContextKeyringSnapshot | Promise; /** Deployment-wide bounded revocation state. Backend failures must reject. */ export interface ProfileContextRevocationStore { isRevoked(id: string, atMs: number): boolean | Promise; - revoke(id: string, expiresAtMs: number): void | Promise; + revoke(id: string, expiresAtMs: number, atMs: number): void | Promise; } export interface ProfileContextHandleServiceOptions { @@ -317,7 +321,7 @@ export class ProfileContextHandleService { private async revokeOpened(opened: OpenedProfileContext): Promise { try { - await this.revocations.revoke(opened.payload.id, opened.payload.expiresAtMs); + await this.revocations.revoke(opened.payload.id, opened.payload.expiresAtMs, this.nowMs()); } catch { throw unavailable(); } @@ -438,9 +442,15 @@ export class InMemoryProfileContextRevocationStore implements ProfileContextRevo return expiresAtMs !== undefined && expiresAtMs > nowMs; } - revoke(id: string, expiresAtMs: number): void { + revoke(id: string, expiresAtMs: number, atMs: number): void { const safeId = internalIdentifier(id); const expiry = positiveSafeInteger(expiresAtMs); + const nowMs = nonNegativeSafeInteger(atMs); + this.prune(nowMs); + if (expiry <= nowMs) { + this.expirations.delete(safeId); + return; + } if (!this.expirations.has(safeId) && this.expirations.size >= this.maximumEntries) { throw new Error("Profile context revocation capacity is unavailable."); } diff --git a/tests/profile-context-handle.test.ts b/tests/profile-context-handle.test.ts index 3bd2301a..cd2639cb 100644 --- a/tests/profile-context-handle.test.ts +++ b/tests/profile-context-handle.test.ts @@ -112,8 +112,11 @@ describe("production profile-context handles", () => { new Proxy({}, { get: () => { throw new Error("private construction detail"); } }) ]; - for (const input of invalidInputs) { - expect(() => new ProfileContextHandleService(input as ProfileContextHandleServiceOptions)).toThrowError( + for (const [index, input] of invalidInputs.entries()) { + expect( + () => new ProfileContextHandleService(input as ProfileContextHandleServiceOptions), + `invalid construction input ${index}` + ).toThrowError( expect.objectContaining({ code: "PROFILE_CONTEXT_INVALID", message: "Profile context is invalid." }) ); } @@ -272,6 +275,25 @@ describe("production profile-context handles", () => { expect(String(auditFailure)).not.toContain(current.handle); }); + it("keeps the prior handle valid when replacement revocation fails after audit commit", async () => { + const audited = vi.fn(); + const instance = service({ + revocations: { + isRevoked: () => false, + revoke: () => { throw new Error("store details"); } + } + }); + const context = await authenticated("chat-work"); + const current = await instance.mint("personal", context, 60_000); + + await expect(instance.replace(current.handle, "work", context, 60_000, audited)).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_UNAVAILABLE", + message: "Profile context is unavailable." + }); + expect(audited).toHaveBeenCalledOnce(); + await expect(instance.resolve(current.handle, context)).resolves.toMatchObject({ profile: "personal" }); + }); + it("supports bounded rotation overlap and rejects unknown, future, expired, and rollback epochs", async () => { let current = nowMs; const firstKey = Buffer.alloc(32, 0x61); @@ -383,10 +405,12 @@ describe("production profile-context handles", () => { ] } ]; - for (const snapshot of invalidSnapshots) { + for (const [index, snapshot] of invalidSnapshots.entries()) { await expect(new ProfileContextHandleService(profileContextOptions({ keyringProvider: () => snapshot as ProfileContextKeyringSnapshot - })).mint("work", context, 60_000)).rejects.toMatchObject({ code: "PROFILE_CONTEXT_UNAVAILABLE" }); + })).mint("work", context, 60_000), `invalid keyring snapshot ${index}`).rejects.toMatchObject({ + code: "PROFILE_CONTEXT_UNAVAILABLE" + }); } let snapshot = keyring(1, firstKey); @@ -410,10 +434,12 @@ describe("production profile-context handles", () => { minted.handle.replace(/^mctx1/u, "wrong"), minted.handle.replace(/^mctx1\.1/u, "mctx1.01"), [parts[0], parts[1], "!", parts[3], parts[4]].join("."), - [parts[0], parts[1], parts[2], "!", parts[4]].join(".") + [parts[0], parts[1], parts[2], "!", parts[4]].join("."), + [parts[0], parts[1], Buffer.alloc(11, 0x55).toString("base64url"), parts[3], parts[4]].join("."), + [parts[0], parts[1], parts[2], parts[3], Buffer.alloc(15, 0x55).toString("base64url")].join(".") ]; - for (const handle of malformed) { - await expect(instance.resolve(handle as string, context)).rejects.toMatchObject({ + for (const [index, handle] of malformed.entries()) { + await expect(instance.resolve(handle as string, context), `malformed handle ${index}`).rejects.toMatchObject({ code: "PROFILE_CONTEXT_INVALID", message: "Profile context is invalid." }); @@ -427,8 +453,11 @@ describe("production profile-context handles", () => { { binding: context.binding, expiresAtMs: nowMs }, new Proxy({}, { get: () => { throw new Error("private request detail"); } }) ]; - for (const invalidContext of invalidContexts) { - await expect(instance.resolve(minted.handle, invalidContext as AuthenticatedRequestContext)).rejects.toBeInstanceOf( + for (const [index, invalidContext] of invalidContexts.entries()) { + await expect( + instance.resolve(minted.handle, invalidContext as AuthenticatedRequestContext), + `invalid authenticated context ${index}` + ).rejects.toBeInstanceOf( ProfileContextHandleError ); } @@ -474,8 +503,11 @@ describe("production profile-context handles", () => { { ...base, issuedAtMs: -1 }, { ...base, expiresAtMs: nowMs } ]; - for (const payload of invalidPayloads) { - await expect(instance.resolve(sealPayload(payload, sealingKey), context)).rejects.toMatchObject({ + for (const [index, payload] of invalidPayloads.entries()) { + await expect( + instance.resolve(sealPayload(payload, sealingKey), context), + `invalid sealed payload ${index}` + ).rejects.toMatchObject({ code: "PROFILE_CONTEXT_INVALID" }); } @@ -491,12 +523,13 @@ describe("production profile-context handles", () => { }); it("normalizes tampering and never reports the bearer or plaintext account data", async () => { - const instance = service(); + const instance = service({ randomBytes: (size) => Buffer.alloc(size, 0x5a) }); const context = await authenticated("chat-work"); const minted = await instance.mint("work", context, 60_000); const resolved = await instance.resolve(minted.handle, context); + const ciphertext = Buffer.from(minted.handle.split(".")[3]!, "base64url"); - expect(minted.handle).not.toContain("work"); + expect(ciphertext.includes(Buffer.from('"profile":"work"', "utf8"))).toBe(false); expect(minted.handle).not.toContain(context.binding); expect(JSON.stringify(resolved)).not.toContain(minted.handle); expect(resolved.auditCorrelation).toMatch(/^mctxc1\.[A-Za-z0-9_-]{22}$/u); @@ -534,15 +567,18 @@ describe("production profile-context handles", () => { const store = new InMemoryProfileContextRevocationStore(1); const first = Buffer.alloc(16, 0x01).toString("base64url"); const second = Buffer.alloc(16, 0x02).toString("base64url"); - store.revoke(first, Date.now() + 60_000); - expect(() => store.revoke(second, Date.now() + 60_000)).toThrow("capacity is unavailable"); - expect(store.isRevoked(first, Date.now())).toBe(true); + store.revoke(first, nowMs + 60_000, nowMs); + expect(() => store.revoke(second, nowMs + 60_000, nowMs)).toThrow("capacity is unavailable"); + expect(store.isRevoked(first, nowMs)).toBe(true); const pruningStore = new InMemoryProfileContextRevocationStore(1); - pruningStore.revoke(first, Date.now() - 1); - expect(pruningStore.isRevoked(first, Date.now())).toBe(false); - pruningStore.revoke(second, Date.now() + 60_000); - expect(() => pruningStore.revoke("bad", Date.now() + 60_000)).toThrowError( + pruningStore.revoke(first, nowMs + 1, nowMs); + pruningStore.revoke(second, nowMs + 60_000, nowMs + 1); + expect(pruningStore.isRevoked(first, nowMs + 1)).toBe(false); + expect(pruningStore.isRevoked(second, nowMs + 1)).toBe(true); + pruningStore.revoke(second, nowMs + 1, nowMs + 1); + expect(pruningStore.isRevoked(second, nowMs + 1)).toBe(false); + expect(() => pruningStore.revoke("bad", nowMs + 60_000, nowMs)).toThrowError( expect.objectContaining({ code: "PROFILE_CONTEXT_INVALID" }) ); }); diff --git a/tests/stateless-profile-context-runtime.test.ts b/tests/stateless-profile-context-runtime.test.ts index 9eca40ae..12329195 100644 --- a/tests/stateless-profile-context-runtime.test.ts +++ b/tests/stateless-profile-context-runtime.test.ts @@ -153,7 +153,9 @@ describe("modern stateless profile-context runtime", () => { auditKey: Buffer.alloc(32, 0x42), clock: () => nowMs, verifiedClaimsProvider: (request) => { - const extra = (request as AuthInfo | undefined)?.extra; + const authenticatedRequest = request as AuthInfo | undefined; + if (authenticatedRequest?.clientId === "failing-host") throw new Error("private verifier failure"); + const extra = authenticatedRequest?.extra; return extra?.verifiedClaims as ReturnType | undefined; } }); @@ -252,6 +254,13 @@ describe("modern stateless profile-context runtime", () => { isError: true, content: [{ type: "text", text: "PROFILE_CONTEXT_UNAVAILABLE: Profile context is unavailable." }] }); + firstAuthentication = { + ...authInfo("chat-work"), + extra: { verifiedClaims: { ...claims("chat-work"), issuedAtMs: "invalid" } } + }; + await expect(firstClient.listTools()).rejects.toThrow(/PROFILE_CONTEXT_INVALID/u); + firstAuthentication = { ...authInfo("chat-work"), clientId: "failing-host" }; + await expect(firstClient.listTools()).rejects.toThrow(/PROFILE_CONTEXT_UNAVAILABLE/u); firstAuthentication = authInfo("chat-work"); const workHandle = profileHandle(await firstClient.callTool({ @@ -274,6 +283,20 @@ describe("modern stateless profile-context runtime", () => { }); expect(parseText(echo)).toContain("safe"); expect(JSON.stringify(echo)).not.toContain(workHandle); + const prototypeArguments = JSON.parse( + '{"message":"prototype-safe","__proto__":{"polluted":true}}' + ) as Record; + prototypeArguments[PROFILE_CONTEXT_ARGUMENT] = workHandle; + expect(parseText(await firstClient.callTool({ name: "echo", arguments: prototypeArguments }))).toContain( + "prototype-safe" + ); + await expect(firstClient.callTool({ + name: `echo-${workHandle}`, + arguments: { [PROFILE_CONTEXT_ARGUMENT]: workHandle } + })).resolves.toMatchObject({ + isError: true, + content: [{ type: "text", text: "PROFILE_CONTEXT_INVALID: Profile context is invalid." }] + }); await expect(firstClient.callTool({ name: "echo", arguments: { @@ -332,6 +355,41 @@ describe("modern stateless profile-context runtime", () => { expect(auditText).not.toContain(workHandle); expect(auditText).not.toContain(personalHandle); expect(auditText).toMatch(/"profileContextCorrelation":"mctxc1\.[A-Za-z0-9_-]{22}"/u); + const auditEvents = auditText + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); + expect(auditEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "operation", + operation: "tools/list", + sourceProfile: "personal", + status: "failure", + errorCode: "PROFILE_CONTEXT_INVALID" + }), + expect.objectContaining({ + kind: "operation", + operation: "tools/list", + sourceProfile: "personal", + status: "failure", + errorCode: "PROFILE_CONTEXT_UNAVAILABLE" + }), + expect.objectContaining({ + kind: "operation", + operation: "management/list-profiles", + sourceProfile: "personal", + status: "failure", + errorCode: "PROFILE_CONTEXT_UNAVAILABLE" + }), + expect.objectContaining({ + kind: "operation", + operation: "prompts/get", + sourceProfile: "personal", + status: "failure", + errorCode: "PROFILE_CONTEXT_INVALID" + }) + ])); } finally { await Promise.allSettled([ firstClient.close(), @@ -344,4 +402,78 @@ describe("modern stateless profile-context runtime", () => { await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 25 }); } }, 30_000); + + it("enforces strict cross-profile tool discovery whenever modern mode is enabled", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-stateless-strict-discovery-")); + const auditPath = join(directory, "audit.jsonl"); + const config = validateConfig({ + version: "1", + name: "accounts", + defaultProfile: "personal", + upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, + profiles: { + personal: { env: { TEST_ACCOUNT_NAME: "personal" } }, + work: { env: { TEST_ACCOUNT_NAME: "work", TEST_WHOAMI_SCHEMA: "account" } } + }, + audit: { path: auditPath } + }); + const requestBoundary = createAuthenticatedRequestContextBoundary({ + deploymentId: "miftah.example/strict-discovery", + bindingKey: Buffer.alloc(32, 0x31), + auditKey: Buffer.alloc(32, 0x32), + clock: () => nowMs, + verifiedClaimsProvider: (request) => { + const extra = (request as AuthInfo | undefined)?.extra; + return extra?.verifiedClaims as ReturnType | undefined; + } + }); + const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer( + config, + new ProfileManager(config), + manager, + undefined, + undefined, + undefined, + undefined, + undefined, + { + handles: new ProfileContextHandleService({ + deploymentId: "miftah.example/strict-discovery", + profiles: ["personal", "work"], + keyringProvider: () => ({ + activeEpoch: 1, + epochs: [{ epoch: 1, key: Buffer.alloc(32, 0x33), activatedAtMs: nowMs - 1_000 }] + }), + auditKey: Buffer.alloc(32, 0x34), + revocations: new InMemoryProfileContextRevocationStore(), + clock: () => nowMs + }), + authenticatedRequestContext: requestBoundary + } + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "modern-strict-client", version: "1.0.0" }); + + try { + await Promise.all([ + wrapper.connect(serverTransport), + client.connect(new AuthenticatedClientTransport(clientTransport, () => authInfo("strict-chat"))) + ]); + await expect(client.listTools()).rejects.toThrow(/TOOL_SCHEMA_MISMATCH: strict tools discovery/u); + const events = (await readFile(auditPath, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect(events).toContainEqual(expect.objectContaining({ + kind: "operation", + operation: "tools/list", + status: "failure", + errorCode: "TOOL_SCHEMA_MISMATCH" + })); + } finally { + await Promise.allSettled([client.close(), wrapper.close(), manager.close()]); + await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 25 }); + } + }, 20_000); }); From 751ebb6e791953ee782c3aaf2305171227be7749 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Tue, 11 Aug 2026 16:04:59 +0400 Subject: [PATCH 3/3] test(profiles): assert prototype remains clean (#377) --- tests/stateless-profile-context-runtime.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/stateless-profile-context-runtime.test.ts b/tests/stateless-profile-context-runtime.test.ts index 12329195..e34b9ae8 100644 --- a/tests/stateless-profile-context-runtime.test.ts +++ b/tests/stateless-profile-context-runtime.test.ts @@ -290,6 +290,7 @@ describe("modern stateless profile-context runtime", () => { expect(parseText(await firstClient.callTool({ name: "echo", arguments: prototypeArguments }))).toContain( "prototype-safe" ); + expect((Object.prototype as Record).polluted).toBeUndefined(); await expect(firstClient.callTool({ name: `echo-${workHandle}`, arguments: { [PROFILE_CONTEXT_ARGUMENT]: workHandle }