From f00f37520a463ea0f1aefe8af80f3cd377f4ebed Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Thu, 2 Jul 2026 20:17:14 +0200 Subject: [PATCH 1/2] feat(git-token-service): add Kilo session capability issuance and redemption Introduces opaque, encrypted capability tokens (kka1.*) that let outbound containers call Kilo backend, provider, and session-ingest routes without exposing the underlying user/provider tokens. - kilo-session-capability.ts: encodes/decodes signed capability claims (user/provider tokens, targets, container binding, expiry) - kilo-capability-policy.ts: classifies a redeeming request URL against the capability's allow-listed targets (provider model, org models, backend API, session ingest) and rejects path traversal/encoding tricks or container/session mismatches - index.ts: adds issueKiloSessionCapability and redeemKiloSessionCapability RPCs on GitTokenRPCEntrypoint --- services/git-token-service/src/index.test.ts | 165 ++++++++++++++++++ services/git-token-service/src/index.ts | 87 +++++++++ .../src/kilo-capability-policy.test.ts | 96 ++++++++++ .../src/kilo-capability-policy.ts | 165 ++++++++++++++++++ .../src/kilo-session-capability.test.ts | 156 +++++++++++++++++ .../src/kilo-session-capability.ts | 121 +++++++++++++ 6 files changed, 790 insertions(+) create mode 100644 services/git-token-service/src/kilo-capability-policy.test.ts create mode 100644 services/git-token-service/src/kilo-capability-policy.ts create mode 100644 services/git-token-service/src/kilo-session-capability.test.ts create mode 100644 services/git-token-service/src/kilo-session-capability.ts diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index f979ec7f6d..930ac68fa4 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -1302,6 +1302,171 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { }); }); +describe('GitTokenRPCEntrypoint Kilo session capability RPCs', () => { + const kiloTargets = { + backendBaseUrl: 'https://api.kilo.ai', + providerBaseUrl: 'https://api.kilo.ai', + sessionIngestBaseUrl: 'https://ingest.kilosessions.ai', + }; + const kiloSubject = { + userId: 'user_1', + cloudAgentSessionId: 'cloud-agent-session-1', + kiloSessionId: 'kilo-session-1', + outboundContainerId, + userToken: 'raw-user-token', + providerToken: 'raw-provider-token', + targets: kiloTargets, + }; + + it('issues an opaque Kilo capability that does not leak the enclosed tokens', async () => { + const result = await createService().issueKiloSessionCapability(kiloSubject); + + expect(result).toMatchObject({ success: true }); + if (!result.success) throw new Error('Expected successful issuance'); + expect(result.capability).toMatch(/^kka1\./); + expect(result.capability).not.toContain(kiloSubject.userToken); + expect(result.capability).not.toContain(kiloSubject.providerToken); + }); + + it('rejects issuance for malformed targets', async () => { + const result = await createService().issueKiloSessionCapability({ + ...kiloSubject, + targets: { ...kiloTargets, backendBaseUrl: 'https://user@api.kilo.ai' }, + }); + + expect(result).toEqual({ success: false, reason: 'invalid_targets' }); + }); + + it('returns a sanitized declared failure when capability key configuration is invalid', async () => { + const service = new GitTokenRPCEntrypoint( + {} as ExecutionContext, + { SCM_SESSION_CAPABILITY_ENCRYPTION_KEY: 'not-a-valid-key' } as unknown as CloudflareEnv + ); + + await expect(service.issueKiloSessionCapability(kiloSubject)).resolves.toEqual({ + success: false, + reason: 'capability_configuration_error', + }); + }); + + it('redeems the provider token for provider model routes', async () => { + const service = createService(); + const issued = await service.issueKiloSessionCapability(kiloSubject); + if (!issued.success) throw new Error('Expected successful issuance'); + + await expect( + service.redeemKiloSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestUrl: 'https://api.kilo.ai/api/openrouter/v1/chat/completions', + }) + ).resolves.toEqual({ + success: true, + authorization: 'Bearer raw-provider-token', + routeClass: 'provider_model', + }); + }); + + it('redeems the user token for backend API routes', async () => { + const service = createService(); + const issued = await service.issueKiloSessionCapability(kiloSubject); + if (!issued.success) throw new Error('Expected successful issuance'); + + await expect( + service.redeemKiloSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestUrl: 'https://api.kilo.ai/api/users/me', + }) + ).resolves.toEqual({ + success: true, + authorization: 'Bearer raw-user-token', + routeClass: 'backend_api', + }); + }); + + it('falls back to the user token for provider routes when no provider token was issued', async () => { + const service = createService(); + const issued = await service.issueKiloSessionCapability({ + ...kiloSubject, + providerToken: undefined, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + + await expect( + service.redeemKiloSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestUrl: 'https://api.kilo.ai/api/openrouter/v1/chat/completions', + }) + ).resolves.toEqual({ + success: true, + authorization: 'Bearer raw-user-token', + routeClass: 'provider_model', + }); + }); + + it('redeems the user token for session export/import routes', async () => { + const service = createService(); + const issued = await service.issueKiloSessionCapability(kiloSubject); + if (!issued.success) throw new Error('Expected successful issuance'); + + await expect( + service.redeemKiloSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestUrl: 'https://ingest.kilosessions.ai/api/session/kilo-session-1/export', + }) + ).resolves.toEqual({ + success: true, + authorization: 'Bearer raw-user-token', + routeClass: 'session_ingest', + }); + }); + + it('does not redeem a session ingest route for another Kilo session', async () => { + const service = createService(); + const issued = await service.issueKiloSessionCapability(kiloSubject); + if (!issued.success) throw new Error('Expected successful issuance'); + + await expect( + service.redeemKiloSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestUrl: 'https://ingest.kilosessions.ai/api/session/another-session/export', + }) + ).resolves.toEqual({ success: false, reason: 'upstream_not_allowed' }); + }); + + it('does not redeem a capability from another outbound container', async () => { + const service = createService(); + const issued = await service.issueKiloSessionCapability(kiloSubject); + if (!issued.success) throw new Error('Expected successful issuance'); + + await expect( + service.redeemKiloSessionCapability({ + capability: issued.capability, + outboundContainerId: 'another-outbound-container', + requestUrl: 'https://api.kilo.ai/api/users/me', + }) + ).resolves.toEqual({ success: false, reason: 'container_mismatch' }); + }); + + it('rejects redemption against a disallowed upstream', async () => { + const service = createService(); + const issued = await service.issueKiloSessionCapability(kiloSubject); + if (!issued.success) throw new Error('Expected successful issuance'); + + await expect( + service.redeemKiloSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestUrl: 'https://evil.example.com/api/users/me', + }) + ).resolves.toEqual({ success: false, reason: 'upstream_not_allowed' }); + }); +}); + describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index 4fbc0eb98b..b95d3f4f93 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -61,6 +61,17 @@ import { BitbucketEnsureWebhookRequestSchema, BitbucketPullRequestRequestSchema, } from './bitbucket-code-review-service.js'; +import { + KiloSessionCapabilityCodec, + KiloSessionCapabilityError, + type KiloSessionCapabilityFailureReason, + type KiloSessionCapabilitySubject, +} from './kilo-session-capability.js'; +import { + areValidKiloCapabilityTargets, + classifyKiloCapabilityRequest, + type KiloCapabilityRouteClass, +} from './kilo-capability-policy.js'; export type GetTokenForRepoParams = { githubRepo: string; @@ -208,6 +219,25 @@ export type RedeemGitLabSessionCapabilityResult = } | { success: false; reason: RedeemGitLabSessionCapabilityFailureReason }; +export type IssueKiloSessionCapabilityParams = KiloSessionCapabilitySubject; +export type IssueKiloSessionCapabilityResult = + | { success: true; capability: string } + | { success: false; reason: KiloSessionCapabilityFailureReason | 'invalid_targets' }; + +export type RedeemKiloSessionCapabilityParams = { + capability: string; + outboundContainerId: string; + requestUrl: string; +}; +export type RedeemKiloSessionCapabilityFailureReason = + | KiloSessionCapabilityFailureReason + | 'container_mismatch' + | 'invalid_upstream_url' + | 'upstream_not_allowed'; +export type RedeemKiloSessionCapabilityResult = + | { success: true; authorization: string; routeClass: KiloCapabilityRouteClass } + | { success: false; reason: RedeemKiloSessionCapabilityFailureReason }; + const DISCONNECT_PATH = '/internal/github-user-authorizations/disconnect'; const BITBUCKET_REPOSITORIES_PATH = '/internal/bitbucket/repositories'; const BITBUCKET_CODE_REVIEW_PULL_REQUEST_PATH = '/internal/bitbucket/code-review/pull-request'; @@ -1043,6 +1073,63 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { return { success: true, headers: { authorization: `Bearer ${token}` } }; } + async issueKiloSessionCapability( + params: IssueKiloSessionCapabilityParams + ): Promise { + if (!areValidKiloCapabilityTargets(params.targets)) { + return { success: false, reason: 'invalid_targets' }; + } + + try { + const encryptionKey = await resolveSecret(this.env.SCM_SESSION_CAPABILITY_ENCRYPTION_KEY); + const capability = new KiloSessionCapabilityCodec(encryptionKey).issue(params); + return { success: true, capability }; + } catch (error) { + if (error instanceof KiloSessionCapabilityError) { + return { success: false, reason: error.reason }; + } + return { success: false, reason: 'capability_configuration_error' }; + } + } + + async redeemKiloSessionCapability( + params: RedeemKiloSessionCapabilityParams + ): Promise { + let claims; + try { + const encryptionKey = await resolveSecret(this.env.SCM_SESSION_CAPABILITY_ENCRYPTION_KEY); + claims = new KiloSessionCapabilityCodec(encryptionKey).decode(params.capability); + } catch (error) { + if (error instanceof KiloSessionCapabilityError) { + return { success: false, reason: error.reason }; + } + return { success: false, reason: 'capability_configuration_error' }; + } + + if (claims.outboundContainerId !== params.outboundContainerId) { + return { success: false, reason: 'container_mismatch' }; + } + + const classification = classifyKiloCapabilityRequest( + params.requestUrl, + claims.targets, + claims.kiloSessionId + ); + if (!classification.success) { + return { success: false, reason: classification.reason }; + } + + const token = + classification.credential === 'provider' + ? (claims.providerToken ?? claims.userToken) + : claims.userToken; + return { + success: true, + authorization: `Bearer ${token}`, + routeClass: classification.routeClass, + }; + } + private getGitLabAuthType(integration: GitLabLookupSuccess): GitLabAuthType | null { if (integration.metadata.auth_type) return integration.metadata.auth_type; if (integration.integrationType === 'oauth' || integration.integrationType === 'pat') { diff --git a/services/git-token-service/src/kilo-capability-policy.test.ts b/services/git-token-service/src/kilo-capability-policy.test.ts new file mode 100644 index 0000000000..f2f17fcc80 --- /dev/null +++ b/services/git-token-service/src/kilo-capability-policy.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { + areValidKiloCapabilityTargets, + classifyKiloCapabilityRequest, +} from './kilo-capability-policy.js'; + +const targets = { + backendBaseUrl: 'https://api.kilo.ai', + providerBaseUrl: 'https://api.kilo.ai', + sessionIngestBaseUrl: 'https://ingest.kilosessions.ai', +}; + +describe('classifyKiloCapabilityRequest', () => { + const kiloSessionId = 'kilo-session-1'; + + it.each([ + [ + 'provider model', + 'https://api.kilo.ai/api/openrouter/v1/chat/completions', + 'provider_model', + 'provider', + ], + [ + 'organization models', + 'https://api.kilo.ai/api/organizations/org_1/models', + 'organization_models', + 'provider', + ], + ['backend api', 'https://api.kilo.ai/api/users/me', 'backend_api', 'user'], + [ + 'session ingest', + 'https://ingest.kilosessions.ai/api/session/kilo-session-1/export', + 'session_ingest', + 'user', + ], + ] as const)( + 'routes %s with the right credential', + (_description, requestUrl, routeClass, credential) => { + expect(classifyKiloCapabilityRequest(requestUrl, targets, kiloSessionId)).toEqual({ + success: true, + routeClass, + credential, + }); + } + ); + + it('allows percent-encoded characters in the query string', () => { + expect( + classifyKiloCapabilityRequest( + 'https://api.kilo.ai/api/openrouter/v1/chat?redirect=%2Ffoo&ref=a%2Fb', + targets, + kiloSessionId + ) + ).toMatchObject({ success: true, routeClass: 'provider_model' }); + }); + + it.each([ + ['encoded slash in path', 'https://api.kilo.ai/api/openrouter%2fsecret'], + ['encoded traversal in path', 'https://api.kilo.ai/api/openrouter/%2e%2e/secret'], + ['userinfo', 'https://user@api.kilo.ai/api/users/me'], + ['disallowed origin', 'https://evil.example.com/api/users/me'], + ['plain http production host', 'http://api.kilo.ai/api/users/me'], + ['different session ingest route', 'https://ingest.kilosessions.ai/api/session/other/export'], + ['unscoped session ingest route', 'https://ingest.kilosessions.ai/sessions/s1/logs'], + ] as const)('rejects %s', (_description, requestUrl) => { + expect(classifyKiloCapabilityRequest(requestUrl, targets, kiloSessionId).success).toBe(false); + }); + + it('refuses to serve provider routes with the user credential when the provider lives elsewhere', () => { + expect( + classifyKiloCapabilityRequest( + 'https://api.kilo.ai/api/openrouter/v1/chat', + { + ...targets, + providerBaseUrl: 'https://provider.kilo.ai', + }, + kiloSessionId + ) + ).toEqual({ success: false, reason: 'upstream_not_allowed' }); + }); +}); + +describe('areValidKiloCapabilityTargets', () => { + it('accepts well-formed https targets', () => { + expect(areValidKiloCapabilityTargets(targets)).toBe(true); + }); + + it('rejects a target carrying userinfo', () => { + expect( + areValidKiloCapabilityTargets({ + ...targets, + backendBaseUrl: 'https://user@api.kilo.ai', + }) + ).toBe(false); + }); +}); diff --git a/services/git-token-service/src/kilo-capability-policy.ts b/services/git-token-service/src/kilo-capability-policy.ts new file mode 100644 index 0000000000..a96a5245e2 --- /dev/null +++ b/services/git-token-service/src/kilo-capability-policy.ts @@ -0,0 +1,165 @@ +import type { KiloSessionCapabilityTargets } from './kilo-session-capability.js'; + +export type KiloCapabilityRouteClass = + | 'provider_model' + | 'organization_models' + | 'backend_api' + | 'session_ingest'; + +export type KiloCapabilityRouteClassification = + | { + success: true; + routeClass: KiloCapabilityRouteClass; + credential: 'user' | 'provider'; + } + | { success: false; reason: 'invalid_upstream_url' | 'upstream_not_allowed' }; + +type ParsedTarget = { + origin: string; + basePath: string; +}; + +function hasUnsafeEncodedPath(value: string): boolean { + let decoded = value; + for (let depth = 0; depth < 4; depth++) { + if (/%(?:2f|5c)/i.test(decoded) || /\/(?:\.|%2e){1,2}(?:\/|$)/i.test(decoded)) { + return true; + } + let next: string; + try { + next = decodeURIComponent(decoded); + } catch { + return true; + } + if (next === decoded) break; + decoded = next; + } + return decoded.includes('\\') || /(?:^|\/)\.{1,2}(?:\/|$)/.test(decoded); +} + +function isAllowedProtocol(url: URL): boolean { + if (url.protocol === 'https:') return true; + return ( + url.protocol === 'http:' && + ['localhost', '127.0.0.1', 'host.docker.internal'].includes(url.hostname) + ); +} + +function parseTarget(value: string): ParsedTarget | null { + if (hasUnsafeEncodedPath(value)) return null; + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + if (!isAllowedProtocol(url) || url.username || url.password || url.hash || url.search) { + return null; + } + const basePath = url.pathname === '/' ? '' : url.pathname.replace(/\/+$/, ''); + return { origin: url.origin, basePath }; +} + +export function areValidKiloCapabilityTargets(targets: KiloSessionCapabilityTargets): boolean { + return Object.values(targets).every(target => parseTarget(target) !== null); +} + +function isWithinTarget(url: URL, target: ParsedTarget): boolean { + return ( + url.origin === target.origin && + (target.basePath === '' || + url.pathname === target.basePath || + url.pathname.startsWith(`${target.basePath}/`)) + ); +} + +function appendPath(basePath: string, suffix: string): string { + return `${basePath}${suffix}` || '/'; +} + +function providerPrefixes(basePath: string): string[] { + if (/\/api\/(?:openrouter|gateway)$/.test(basePath)) return [basePath]; + if (basePath.endsWith('/api')) { + return [appendPath(basePath, '/openrouter'), appendPath(basePath, '/gateway')]; + } + return [appendPath(basePath, '/api/openrouter'), appendPath(basePath, '/api/gateway')]; +} + +function matchesPrefix(pathname: string, prefix: string): boolean { + return pathname === prefix || pathname.startsWith(`${prefix}/`); +} + +function isProviderRoute(pathname: string, basePath: string): boolean { + return providerPrefixes(basePath).some(prefix => matchesPrefix(pathname, prefix)); +} + +function isOrganizationModelsRoute(pathname: string, basePath: string): boolean { + const organizationsPrefix = basePath.endsWith('/api') + ? `${basePath}/organizations/` + : `${basePath}/api/organizations/`; + if (!pathname.startsWith(organizationsPrefix)) return false; + const relativePath = pathname.slice(organizationsPrefix.length); + return /^[^/]+\/models(?:\/|$)/.test(relativePath); +} + +function isSessionIngestRoute(pathname: string, basePath: string, kiloSessionId: string): boolean { + const sessionPrefix = appendPath(basePath, `/api/session/${encodeURIComponent(kiloSessionId)}`); + return pathname === `${sessionPrefix}/export` || pathname === `${sessionPrefix}/import`; +} + +export function classifyKiloCapabilityRequest( + requestUrl: string, + targets: KiloSessionCapabilityTargets, + kiloSessionId: string +): KiloCapabilityRouteClassification { + // Only the path is subject to the traversal/encoding guard; query strings may + // legitimately carry percent-encoded slashes and dots. + if (hasUnsafeEncodedPath(requestUrl.split(/[?#]/, 1)[0])) { + return { success: false, reason: 'invalid_upstream_url' }; + } + + let url: URL; + try { + url = new URL(requestUrl); + } catch { + return { success: false, reason: 'invalid_upstream_url' }; + } + if (!isAllowedProtocol(url) || url.username || url.password || url.hash) { + return { success: false, reason: 'invalid_upstream_url' }; + } + + const backend = parseTarget(targets.backendBaseUrl); + const provider = parseTarget(targets.providerBaseUrl); + const sessionIngest = parseTarget(targets.sessionIngestBaseUrl); + if (!backend || !provider || !sessionIngest) { + return { success: false, reason: 'invalid_upstream_url' }; + } + + if (isWithinTarget(url, provider) && isProviderRoute(url.pathname, provider.basePath)) { + return { + success: true, + routeClass: 'provider_model', + credential: 'provider', + }; + } + if (isWithinTarget(url, backend) && isOrganizationModelsRoute(url.pathname, backend.basePath)) { + return { + success: true, + routeClass: 'organization_models', + credential: 'provider', + }; + } + if (isWithinTarget(url, backend)) { + if (isProviderRoute(url.pathname, backend.basePath)) { + return { success: false, reason: 'upstream_not_allowed' }; + } + return { success: true, routeClass: 'backend_api', credential: 'user' }; + } + if ( + isWithinTarget(url, sessionIngest) && + isSessionIngestRoute(url.pathname, sessionIngest.basePath, kiloSessionId) + ) { + return { success: true, routeClass: 'session_ingest', credential: 'user' }; + } + return { success: false, reason: 'upstream_not_allowed' }; +} diff --git a/services/git-token-service/src/kilo-session-capability.test.ts b/services/git-token-service/src/kilo-session-capability.test.ts new file mode 100644 index 0000000000..d6b43eaabc --- /dev/null +++ b/services/git-token-service/src/kilo-session-capability.test.ts @@ -0,0 +1,156 @@ +import { encryptWithSymmetricKey } from '@kilocode/encryption'; +import { describe, expect, it, vi } from 'vitest'; +import { GitHubSessionCapabilityCodec } from './github-session-capability.js'; +import { + KiloSessionCapabilityCodec, + KiloSessionCapabilityError, +} from './kilo-session-capability.js'; + +const encryptionKey = Buffer.alloc(32, 7).toString('base64'); +const claims = { + userId: 'user_1', + cloudAgentSessionId: 'cloud-agent-session-1', + kiloSessionId: 'kilo-session-1', + outboundContainerId: 'outbound-container-1', + userToken: 'raw-user-token-sentinel', + providerToken: 'raw-provider-token-sentinel', + targets: { + backendBaseUrl: 'https://api.kilo.ai', + providerBaseUrl: 'https://api.kilo.ai', + sessionIngestBaseUrl: 'https://ingest.kilosessions.ai', + }, +} as const; + +function encryptedCapability(value: unknown): string { + return `kka1.${encryptWithSymmetricKey(JSON.stringify(value), encryptionKey)}`; +} + +describe('KiloSessionCapabilityCodec', () => { + it('issues an opaque four-hour capability bound to the session and container', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-07T12:00:00.000Z')); + const codec = new KiloSessionCapabilityCodec(encryptionKey); + + const capability = codec.issue(claims); + + expect(capability).toMatch(/^kka1\./); + expect(capability).not.toContain(claims.userToken); + expect(capability).not.toContain(claims.providerToken); + expect(codec.decode(capability)).toEqual({ + purpose: 'kilo_api_session', + version: 1, + ...claims, + issuedAt: Date.parse('2026-06-07T12:00:00.000Z'), + expiresAt: Date.parse('2026-06-07T16:00:00.000Z'), + }); + vi.useRealTimers(); + }); + + it.each([ + ['wrong prefix', 'kka2.not-supported'], + [ + 'SCM capability', + new GitHubSessionCapabilityCodec(encryptionKey).issue({ + userId: 'user_1', + outboundContainerId: 'outbound-container-1', + owner: 'acme', + repo: 'widgets', + source: 'installation', + identity: { + installationId: 'installation_1', + accountLogin: 'acme', + appType: 'standard', + gitAuthor: { name: 'Kilo', email: 'kilo@example.com' }, + }, + }), + ], + ['non-canonical ciphertext', 'kka1.AA:AA:AA'], + ])('rejects %s', (_description, capability) => { + expect(() => new KiloSessionCapabilityCodec(encryptionKey).decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + }); + + it.each([ + ['wrong purpose', { purpose: 'github_scm_session' }], + ['unknown claim', { extra: true }], + ['unknown target claim', { targets: { ...claims.targets, extra: true } }], + ['invalid timestamp order', { issuedAt: 2, expiresAt: 1 }], + ['excessive lifetime', { issuedAt: 1, expiresAt: 1 + 4 * 60 * 60 * 1000 + 1 }], + ])('rejects decrypted claims with %s', (_description, override) => { + const issuedAt = Date.now(); + const base = { + purpose: 'kilo_api_session', + version: 1, + ...claims, + issuedAt, + expiresAt: issuedAt + 60_000, + }; + const capability = encryptedCapability({ ...base, ...override }); + + expect(() => new KiloSessionCapabilityCodec(encryptionKey).decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + }); + + it('rejects oversized encrypted claims', () => { + const issuedAt = Date.now(); + const capability = encryptedCapability({ + purpose: 'kilo_api_session', + version: 1, + ...claims, + userToken: 'x'.repeat(70_000), + issuedAt, + expiresAt: issuedAt + 60_000, + }); + + expect(() => new KiloSessionCapabilityCodec(encryptionKey).decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + }); + + it('rejects a capability issued in the future', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-07T12:00:00.000Z')); + const issuedAt = Date.now() + 60_000; + const capability = encryptedCapability({ + purpose: 'kilo_api_session', + version: 1, + ...claims, + issuedAt, + expiresAt: issuedAt + 60_000, + }); + + expect(() => new KiloSessionCapabilityCodec(encryptionKey).decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + vi.useRealTimers(); + }); + + it('rejects expiry and tampering without exposing enclosed tokens in errors', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-07T12:00:00.000Z')); + const codec = new KiloSessionCapabilityCodec(encryptionKey); + const capability = codec.issue(claims); + + vi.setSystemTime(new Date('2026-06-07T16:00:00.000Z')); + let expiredError: unknown; + try { + codec.decode(capability); + } catch (error) { + expiredError = error; + } + expect(expiredError).toBeInstanceOf(KiloSessionCapabilityError); + expect(expiredError).toMatchObject({ reason: 'expired_capability' }); + expect(JSON.stringify(expiredError)).not.toContain(claims.userToken); + expect(JSON.stringify(expiredError)).not.toContain(claims.providerToken); + + const changedOffset = capability.lastIndexOf('.') + 4; + const changedCharacter = capability[changedOffset] === 'A' ? 'B' : 'A'; + const tampered = `${capability.slice(0, changedOffset)}${changedCharacter}${capability.slice(changedOffset + 1)}`; + expect(() => codec.decode(tampered)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + vi.useRealTimers(); + }); +}); diff --git a/services/git-token-service/src/kilo-session-capability.ts b/services/git-token-service/src/kilo-session-capability.ts new file mode 100644 index 0000000000..1e6aaa88b7 --- /dev/null +++ b/services/git-token-service/src/kilo-session-capability.ts @@ -0,0 +1,121 @@ +import { decryptWithSymmetricKey, encryptWithSymmetricKey } from '@kilocode/encryption'; +import { z } from 'zod'; +import { hasCanonicalEncryptedValueFormat } from './github-session-capability.js'; + +const CAPABILITY_PREFIX = 'kka1.'; +const CAPABILITY_PURPOSE = 'kilo_api_session'; +const MAX_KILO_SESSION_CAPABILITY_LIFETIME_MS = 4 * 60 * 60 * 1000; +const MAX_KILO_SESSION_CAPABILITY_LENGTH = 64 * 1024; + +const KiloSessionCapabilityTargetsSchema = z + .object({ + backendBaseUrl: z.string().url(), + providerBaseUrl: z.string().url(), + sessionIngestBaseUrl: z.string().url(), + }) + .strict(); + +const KiloSessionCapabilityClaimsSchema = z + .object({ + version: z.literal(1), + purpose: z.literal(CAPABILITY_PURPOSE), + userId: z.string().min(1), + cloudAgentSessionId: z.string().min(1), + kiloSessionId: z.string().min(1), + outboundContainerId: z.string().min(1), + userToken: z.string().min(1), + providerToken: z.string().min(1).optional(), + targets: KiloSessionCapabilityTargetsSchema, + issuedAt: z.number().int().nonnegative(), + expiresAt: z.number().int().positive(), + }) + .strict() + .refine(claims => claims.expiresAt > claims.issuedAt) + .refine(claims => claims.expiresAt - claims.issuedAt <= MAX_KILO_SESSION_CAPABILITY_LIFETIME_MS); + +export type KiloSessionCapabilityTargets = z.infer; +export type KiloSessionCapabilitySubject = { + userId: string; + cloudAgentSessionId: string; + kiloSessionId: string; + outboundContainerId: string; + userToken: string; + providerToken?: string; + targets: KiloSessionCapabilityTargets; +}; +export type KiloSessionCapabilityClaims = z.infer; +export type KiloSessionCapabilityFailureReason = + | 'invalid_capability' + | 'expired_capability' + | 'capability_configuration_error'; + +export class KiloSessionCapabilityError extends Error { + constructor(readonly reason: KiloSessionCapabilityFailureReason) { + super(reason); + this.name = 'KiloSessionCapabilityError'; + } +} + +export class KiloSessionCapabilityCodec { + constructor(private readonly encryptionKey: string) {} + + issue(subject: KiloSessionCapabilitySubject): string { + const issuedAt = Date.now(); + const parsed = KiloSessionCapabilityClaimsSchema.safeParse({ + version: 1, + purpose: CAPABILITY_PURPOSE, + ...subject, + issuedAt, + expiresAt: issuedAt + MAX_KILO_SESSION_CAPABILITY_LIFETIME_MS, + }); + if (!parsed.success) throw new KiloSessionCapabilityError('invalid_capability'); + + try { + const capability = `${CAPABILITY_PREFIX}${encryptWithSymmetricKey(JSON.stringify(parsed.data), this.encryptionKey)}`; + if (capability.length > MAX_KILO_SESSION_CAPABILITY_LENGTH) { + throw new KiloSessionCapabilityError('invalid_capability'); + } + return capability; + } catch (error) { + if (error instanceof KiloSessionCapabilityError) throw error; + throw new KiloSessionCapabilityError('capability_configuration_error'); + } + } + + decode(capability: string): KiloSessionCapabilityClaims { + if (capability.length > MAX_KILO_SESSION_CAPABILITY_LENGTH) { + throw new KiloSessionCapabilityError('invalid_capability'); + } + if (!capability.startsWith(CAPABILITY_PREFIX)) { + throw new KiloSessionCapabilityError('invalid_capability'); + } + + const encrypted = capability.slice(CAPABILITY_PREFIX.length); + if (!hasCanonicalEncryptedValueFormat(encrypted)) { + throw new KiloSessionCapabilityError('invalid_capability'); + } + + let serialized: string; + try { + serialized = decryptWithSymmetricKey(encrypted, this.encryptionKey); + } catch { + throw new KiloSessionCapabilityError('invalid_capability'); + } + + let value: unknown; + try { + value = JSON.parse(serialized); + } catch { + throw new KiloSessionCapabilityError('invalid_capability'); + } + + const parsed = KiloSessionCapabilityClaimsSchema.safeParse(value); + if (!parsed.success || parsed.data.issuedAt > Date.now()) { + throw new KiloSessionCapabilityError('invalid_capability'); + } + if (parsed.data.expiresAt <= Date.now()) { + throw new KiloSessionCapabilityError('expired_capability'); + } + return parsed.data; + } +} From b8cf3dfeb81ca5ec25c3b9a603a8231198c9e8e6 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Thu, 2 Jul 2026 21:21:05 +0200 Subject: [PATCH 2/2] fix(git-token-service): close cross-session capability redemption gap Address review feedback on the Kilo session capability RPCs: - Exclude session-ingest-shaped paths from the backend catch-all and order the session-ingest branch first, so a shared backend/session-ingest origin can no longer serve another session's export/import route as backend_api with the user token. - Fail closed with invalid_upstream_url when requestUrl is not a string, keeping the discriminated-union contract at the WorkerEntrypoint RPC boundary instead of throwing. - Allow a 60s clock-skew tolerance on the issuedAt future check so a freshly issued capability is not spuriously rejected across isolates. --- services/git-token-service/src/index.test.ts | 21 ++++++++++++ .../src/kilo-capability-policy.test.ts | 34 +++++++++++++++++++ .../src/kilo-capability-policy.ts | 31 +++++++++++++---- .../src/kilo-session-capability.test.ts | 22 ++++++++++-- .../src/kilo-session-capability.ts | 9 ++++- 5 files changed, 108 insertions(+), 9 deletions(-) diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index 930ac68fa4..7657b04d39 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -1438,6 +1438,27 @@ describe('GitTokenRPCEntrypoint Kilo session capability RPCs', () => { ).resolves.toEqual({ success: false, reason: 'upstream_not_allowed' }); }); + it('does not leak the user token to another session ingest route on a shared origin', async () => { + const service = createService(); + const issued = await service.issueKiloSessionCapability({ + ...kiloSubject, + targets: { + backendBaseUrl: 'https://api.kilo.ai', + providerBaseUrl: 'https://api.kilo.ai/api/openrouter', + sessionIngestBaseUrl: 'https://api.kilo.ai', + }, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + + await expect( + service.redeemKiloSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestUrl: 'https://api.kilo.ai/api/session/another-session/export', + }) + ).resolves.toEqual({ success: false, reason: 'upstream_not_allowed' }); + }); + it('does not redeem a capability from another outbound container', async () => { const service = createService(); const issued = await service.issueKiloSessionCapability(kiloSubject); diff --git a/services/git-token-service/src/kilo-capability-policy.test.ts b/services/git-token-service/src/kilo-capability-policy.test.ts index f2f17fcc80..2a6c52d511 100644 --- a/services/git-token-service/src/kilo-capability-policy.test.ts +++ b/services/git-token-service/src/kilo-capability-policy.test.ts @@ -78,6 +78,40 @@ describe('classifyKiloCapabilityRequest', () => { ) ).toEqual({ success: false, reason: 'upstream_not_allowed' }); }); + + it('fails closed for a non-string request url', () => { + expect( + classifyKiloCapabilityRequest(null as unknown as string, targets, kiloSessionId) + ).toEqual({ success: false, reason: 'invalid_upstream_url' }); + }); + + describe('when backend and session ingest share an origin', () => { + const sharedOriginTargets = { + backendBaseUrl: 'https://api.kilo.ai', + providerBaseUrl: 'https://api.kilo.ai/api/openrouter', + sessionIngestBaseUrl: 'https://api.kilo.ai', + }; + + it('does not let the backend catch-all shadow another session ingest route', () => { + expect( + classifyKiloCapabilityRequest( + 'https://api.kilo.ai/api/session/other-session/export', + sharedOriginTargets, + kiloSessionId + ) + ).toEqual({ success: false, reason: 'upstream_not_allowed' }); + }); + + it('still routes the bound session ingest route', () => { + expect( + classifyKiloCapabilityRequest( + `https://api.kilo.ai/api/session/${kiloSessionId}/export`, + sharedOriginTargets, + kiloSessionId + ) + ).toEqual({ success: true, routeClass: 'session_ingest', credential: 'user' }); + }); + }); }); describe('areValidKiloCapabilityTargets', () => { diff --git a/services/git-token-service/src/kilo-capability-policy.ts b/services/git-token-service/src/kilo-capability-policy.ts index a96a5245e2..604e1504bf 100644 --- a/services/git-token-service/src/kilo-capability-policy.ts +++ b/services/git-token-service/src/kilo-capability-policy.ts @@ -107,11 +107,23 @@ function isSessionIngestRoute(pathname: string, basePath: string, kiloSessionId: return pathname === `${sessionPrefix}/export` || pathname === `${sessionPrefix}/import`; } +function isSessionIngestShapedRoute(pathname: string, basePath: string): boolean { + const sessionsPrefix = appendPath(basePath, '/api/session/'); + if (!pathname.startsWith(sessionsPrefix)) return false; + return /^[^/]+\/(?:export|import)$/.test(pathname.slice(sessionsPrefix.length)); +} + export function classifyKiloCapabilityRequest( requestUrl: string, targets: KiloSessionCapabilityTargets, kiloSessionId: string ): KiloCapabilityRouteClassification { + // requestUrl is only compile-time typed at the WorkerEntrypoint RPC boundary; a + // caller can still send a non-string, which must fail closed like every other + // branch instead of throwing from the .split below. + if (typeof requestUrl !== 'string') { + return { success: false, reason: 'invalid_upstream_url' }; + } // Only the path is subject to the traversal/encoding guard; query strings may // legitimately carry percent-encoded slashes and dots. if (hasUnsafeEncodedPath(requestUrl.split(/[?#]/, 1)[0])) { @@ -149,17 +161,24 @@ export function classifyKiloCapabilityRequest( credential: 'provider', }; } - if (isWithinTarget(url, backend)) { - if (isProviderRoute(url.pathname, backend.basePath)) { - return { success: false, reason: 'upstream_not_allowed' }; - } - return { success: true, routeClass: 'backend_api', credential: 'user' }; - } if ( isWithinTarget(url, sessionIngest) && isSessionIngestRoute(url.pathname, sessionIngest.basePath, kiloSessionId) ) { return { success: true, routeClass: 'session_ingest', credential: 'user' }; } + // Backend is the catch-all for its origin, so it must exclude provider- and + // session-ingest-shaped paths. Otherwise a shared backend/session-ingest origin + // would let this branch serve another session's ingest route as backend_api, + // bypassing the bound-session guard above. + if (isWithinTarget(url, backend)) { + if ( + isProviderRoute(url.pathname, backend.basePath) || + isSessionIngestShapedRoute(url.pathname, backend.basePath) + ) { + return { success: false, reason: 'upstream_not_allowed' }; + } + return { success: true, routeClass: 'backend_api', credential: 'user' }; + } return { success: false, reason: 'upstream_not_allowed' }; } diff --git a/services/git-token-service/src/kilo-session-capability.test.ts b/services/git-token-service/src/kilo-session-capability.test.ts index d6b43eaabc..a97298beca 100644 --- a/services/git-token-service/src/kilo-session-capability.test.ts +++ b/services/git-token-service/src/kilo-session-capability.test.ts @@ -109,10 +109,10 @@ describe('KiloSessionCapabilityCodec', () => { ); }); - it('rejects a capability issued in the future', () => { + it('rejects a capability issued beyond the clock-skew tolerance', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-06-07T12:00:00.000Z')); - const issuedAt = Date.now() + 60_000; + const issuedAt = Date.now() + 5 * 60_000; const capability = encryptedCapability({ purpose: 'kilo_api_session', version: 1, @@ -127,6 +127,24 @@ describe('KiloSessionCapabilityCodec', () => { vi.useRealTimers(); }); + it('accepts a capability issued within the clock-skew tolerance', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-07T12:00:00.000Z')); + const issuedAt = Date.now() + 30_000; + const capability = encryptedCapability({ + purpose: 'kilo_api_session', + version: 1, + ...claims, + issuedAt, + expiresAt: issuedAt + 60_000, + }); + + expect(new KiloSessionCapabilityCodec(encryptionKey).decode(capability)).toMatchObject({ + issuedAt, + }); + vi.useRealTimers(); + }); + it('rejects expiry and tampering without exposing enclosed tokens in errors', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-06-07T12:00:00.000Z')); diff --git a/services/git-token-service/src/kilo-session-capability.ts b/services/git-token-service/src/kilo-session-capability.ts index 1e6aaa88b7..57a5caa575 100644 --- a/services/git-token-service/src/kilo-session-capability.ts +++ b/services/git-token-service/src/kilo-session-capability.ts @@ -6,6 +6,10 @@ const CAPABILITY_PREFIX = 'kka1.'; const CAPABILITY_PURPOSE = 'kilo_api_session'; const MAX_KILO_SESSION_CAPABILITY_LIFETIME_MS = 4 * 60 * 60 * 1000; const MAX_KILO_SESSION_CAPABILITY_LENGTH = 64 * 1024; +// issue() and decode() can run on different isolates whose clocks are not +// perfectly aligned; allow a small future skew so a freshly issued capability is +// not spuriously rejected as forged on the first redemption. +const KILO_SESSION_CAPABILITY_CLOCK_SKEW_TOLERANCE_MS = 60 * 1000; const KiloSessionCapabilityTargetsSchema = z .object({ @@ -110,7 +114,10 @@ export class KiloSessionCapabilityCodec { } const parsed = KiloSessionCapabilityClaimsSchema.safeParse(value); - if (!parsed.success || parsed.data.issuedAt > Date.now()) { + if ( + !parsed.success || + parsed.data.issuedAt > Date.now() + KILO_SESSION_CAPABILITY_CLOCK_SKEW_TOLERANCE_MS + ) { throw new KiloSessionCapabilityError('invalid_capability'); } if (parsed.data.expiresAt <= Date.now()) {