Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 77 additions & 10 deletions packages/core/src/claustrum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,10 +273,48 @@ export function custodyCredentialId(label: string): string {
return `${CUSTODY_CREDENTIAL_PREFIX}${label}`
}

// A resolved MANIFEST binding carries the credential id verbatim (the parser
// stores it as-written and the resolver returns it; round 2 dropped the
// parse-time derivation check). Anything else — legacy source, unresolved,
// or a manifest entry whose id happens to be undefined — derives from the
// label, the only id the caller can construct without a binding in hand.
// Callers building completion records (local-exit, reachability probe)
// pass this single rule instead of inlining it.
export function custodyCredentialIdFromResolution(
resolution: CustodyHandleResolution,
label: string,
): string {
if (resolution.status === 'resolved' && resolution.source === 'manifest') {
return resolution.credentialId ?? custodyCredentialId(label)
}
return custodyCredentialId(label)
}

function isValidCustodyCredentialId(value: unknown): value is string {
return typeof value === 'string' && value.length > 0
}

// The manifest is a co-tenant file: a sibling plugin (`provider: 'openai'`,
// `serve: 'openai-auth'`) writes its own block in the same file. Provider
// scoping is what stops us from binding its handles — the parser has to
// enforce it because the sibling plugin never talks to us.
//
// Scope on the SECOND colon-separated segment (the provider segment). The
// first segment is a "kind" prefix the vault owner extends (`oauth:`,
// `chatgpt:`, `antigravity:`, `apikey:`, …) and the rule deliberately does
// NOT enumerate or constrain it: enumerating the kinds would reject live
// credentials the moment a new one ships. The third-and-later segments are
// the label, which is never consulted here — the label is the lookup key
// elsewhere, not an authorization check.
function isScopedCustodyCredentialId(
value: unknown,
provider: string,
): value is string {
if (typeof value !== 'string' || value.length === 0) return false
const segments = value.split(':')
return segments[1] === provider
}

function legacyOrUnresolved(
account: OAuthAccount,
reason: Extract<CustodyHandleResolution, { status: 'unresolved' }>['reason'],
Expand Down Expand Up @@ -321,10 +359,10 @@ export function resolveCustodyHandle(input: {
return { status: 'unresolved', reason: 'corrupt-binding' }
}

// The manifest carries the credential id verbatim; the runtime fence in
// custody-mode.ts is the one that compares it against vault ground truth.
const entry = manifest.accounts.find(
(candidate) =>
candidate.label === account.label &&
candidate.credentialId === custodyCredentialId(account.label),
(candidate) => candidate.label === account.label,
)
if (!entry) return legacyOrUnresolved(account, 'missing-entry')
if (manifest.superseded.has(entry.handle)) {
Expand Down Expand Up @@ -372,6 +410,27 @@ export function readCustodyHandles(
if (!Object.hasOwn(source, 'accounts') || !Array.isArray(source.accounts)) {
throw new Error('invalid manifest accounts')
}
// The label is the sole lookup key; two entries with the same label cannot
// tell the resolver which one to bind, so every such entry is marked corrupt
// rather than silently picking a winner.
const labelCounts = new Map<string, number>()
for (const candidate of source.accounts) {
if (
!isRecord(candidate) ||
!Object.hasOwn(candidate, 'label') ||
typeof candidate.label !== 'string' ||
!isValidCustodyLabel(candidate.label)
)
continue
labelCounts.set(
candidate.label,
(labelCounts.get(candidate.label) ?? 0) + 1,
)
}
const duplicateLabels = new Set(
[...labelCounts].filter(([, count]) => count > 1).map(([label]) => label),
)

const superseded = new Set<string>()
const corruptLabels = new Set<string>()
const accounts: CustodyHandleAccount[] = []
Expand All @@ -388,13 +447,15 @@ export function readCustodyHandles(
!Object.hasOwn(entry, 'credential_id') ||
typeof entry.handle !== 'string' ||
!isValidCustodyHandle(entry.handle) ||
!isValidCustodyCredentialId(entry.credential_id) ||
(provider === 'anthropic' &&
entry.credential_id !== custodyCredentialId(entry.label))
!isScopedCustodyCredentialId(entry.credential_id, provider)
) {
corruptLabels.add(entry.label)
continue
}
if (duplicateLabels.has(entry.label)) {
corruptLabels.add(entry.label)
continue
}
if (Object.hasOwn(entry, 'superseded')) {
if (!Array.isArray(entry.superseded)) {
corruptLabels.add(entry.label)
Expand Down Expand Up @@ -958,8 +1019,7 @@ export async function writeCustodyHandleManifestEntry(
if (
!isValidCustodyLabel(input.entry.label) ||
!isValidCustodyHandle(input.entry.handle) ||
!isValidCustodyCredentialId(input.entry.credentialId) ||
input.entry.credentialId !== custodyCredentialId(input.entry.label)
!isValidCustodyCredentialId(input.entry.credentialId)
) {
return refusal('invalid entry')
}
Expand Down Expand Up @@ -987,8 +1047,7 @@ export async function removeCustodyHandleManifestEntry(
if (
!isValidCustodyLabel(input.entry.label) ||
!isValidCustodyHandle(input.entry.handle) ||
!isValidCustodyCredentialId(input.entry.credentialId) ||
input.entry.credentialId !== custodyCredentialId(input.entry.label)
!isValidCustodyCredentialId(input.entry.credentialId)
) {
return { status: 'refused' }
}
Expand Down Expand Up @@ -1514,6 +1573,7 @@ export type ClaustrumCredential = {
recordVersion: number
projectId?: string
accountId?: string
credentialId?: string
}

export type ClaustrumServedCredential = Pick<
Expand Down Expand Up @@ -1552,6 +1612,7 @@ type CredentialGetResult = {
recordVersion: number
projectId?: string
accountId?: string
credentialId?: string
}

function credentialErrorAction(
Expand Down Expand Up @@ -1688,6 +1749,9 @@ function decodeCredentialGetResponse(response: unknown): CredentialGetResult {
...(typeof result?.account_id === 'string' && {
accountId: result.account_id,
}),
...(typeof result?.credential_id === 'string' && {
credentialId: result.credential_id,
}),
}
}

Expand Down Expand Up @@ -1897,6 +1961,9 @@ export class ClaustrumCredentialCache {
recordVersion: result.recordVersion,
...(result.projectId !== undefined && { projectId: result.projectId }),
...(result.accountId !== undefined && { accountId: result.accountId }),
...(result.credentialId !== undefined && {
credentialId: result.credentialId,
}),
}
if (
credential.expiresAtMs !== null &&
Expand Down
Loading