diff --git a/TELEMETRY.md b/TELEMETRY.md index f27e546e1a..9c63fc1908 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -257,9 +257,15 @@ Events: `sandbox.egress.credential.needed`, Spans: resumed `chat.turn`, `chat.reply` Attributes: `app.credential.provider`, `app.credential.delivery`, +`app.credential.token_fingerprint`, `app.ai.retryable_reason`, `app.ai.session_id`, `app.ai.resume_session_version` +`app.credential.token_fingerprint` appears on GitHub installation mint +(`plugin.log.info` with `app.log.message=github.installation_token.issued`) and +on `sandbox.egress.upstream_auth.rejected` so mint and rejected hops can be +matched. + ### Skills And Plugins A skill/tool is missing, plugin discovery failed, or capability activation looks wrong. diff --git a/packages/junior-github/src/credential-support.ts b/packages/junior-github/src/credential-support.ts index f08ee05743..87b0e71d10 100644 --- a/packages/junior-github/src/credential-support.ts +++ b/packages/junior-github/src/credential-support.ts @@ -4,10 +4,11 @@ * This module owns OAuth refresh, installation tokens, credential leases, and * repository-scoped credential parsing. */ -import { createPrivateKey, createSign } from "node:crypto"; +import { createHmac, createPrivateKey, createSign } from "node:crypto"; import type { PluginCredentialResult, PluginGrant, + PluginLogger, PluginProviderAccount, PluginStoredTokens, PluginUserTokenSlot, @@ -849,11 +850,22 @@ export async function issueInstallationToken( }; } +function fingerprintInstallationToken(token: string): string { + return createHmac("sha256", "junior.credential-fingerprint.v1") + .update(token, "utf8") + .digest("hex") + .slice(0, 12); +} + /** Issue a bounded GitHub App installation credential. */ export async function issueInstallationCredential( options: InstallationCredentialOptions, + log?: Pick, ): Promise { const token = await issueInstallationToken(options); + log?.info("github.installation_token.issued", { + "app.credential.token_fingerprint": fingerprintInstallationToken(token.token), + }); return createCredentialLease({ token: token.token, expiresAtMs: token.expiresAtMs, diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index b9ed177ad3..74cd412c40 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -956,27 +956,25 @@ export function githubPlugin( async issueCredential(ctx) { try { if (ctx.grant.name === "installation-read") { - return await issueInstallationCredential({ - appIdEnv, - privateKeyEnv, - installationIdEnv, - ...(declaredReadPermissions - ? { permissions: declaredReadPermissions } - : { loadPermissions: loadReadPermissions }), - }); + return await issueInstallationCredential( + { + appIdEnv, + privateKeyEnv, + installationIdEnv, + ...(declaredReadPermissions + ? { permissions: declaredReadPermissions } + : { loadPermissions: loadReadPermissions }), + }, + ctx.log, + ); } if (ctx.grant.name === "installation-write") { - const repository = githubRepositoryFromLeaseScope( - ctx.grant.leaseScope, + // Repository-only mint keeps the installed App permission envelope. + const repository = githubRepositoryFromLeaseScope(ctx.grant.leaseScope); + return await issueInstallationCredential( + { appIdEnv, privateKeyEnv, installationIdEnv, repositories: [repository.name] }, + ctx.log, ); - return await issueInstallationCredential({ - appIdEnv, - privateKeyEnv, - installationIdEnv, - // This repository-only variant cannot downscope the installed - // App envelope with an operation-specific permission body. - repositories: [repository.name], - }); } if (USER_TOKEN_GRANTS.has(ctx.grant.name)) { return await issueUserCredential(ctx, { diff --git a/packages/junior-github/tests/github-plugin.test.ts b/packages/junior-github/tests/github-plugin.test.ts index ce5ed57a6d..4ce668d609 100644 --- a/packages/junior-github/tests/github-plugin.test.ts +++ b/packages/junior-github/tests/github-plugin.test.ts @@ -105,9 +105,10 @@ function beforeToolContext(actor: TestActor, actors?: TestActor[]) { }; } +const pluginLogInfo = vi.fn(); const pluginLog = { error() {}, - info() {}, + info: pluginLogInfo, warn() {}, }; @@ -2232,6 +2233,7 @@ Conversation: \`local:test:old-conversation\` process.env.GITHUB_APP_ID = "123"; process.env.GITHUB_INSTALLATION_ID = "456"; process.env.GITHUB_APP_PRIVATE_KEY = privateKey; + pluginLogInfo.mockClear(); const requests = mockGitHubInstallationApi(); const plugin = githubPlugin({ appPermissions: { @@ -2263,6 +2265,12 @@ Conversation: \`local:test:old-conversation\` }, headers: expect.any(Object), }); + expect(pluginLogInfo).toHaveBeenCalledWith( + "github.installation_token.issued", + expect.objectContaining({ + "app.credential.token_fingerprint": expect.any(String), + }), + ); }); it("issues read-only GitHub App installation credentials from plugin hooks", async () => { diff --git a/packages/junior/src/chat/credentials/token-fingerprint.ts b/packages/junior/src/chat/credentials/token-fingerprint.ts new file mode 100644 index 0000000000..8d87e74d12 --- /dev/null +++ b/packages/junior/src/chat/credentials/token-fingerprint.ts @@ -0,0 +1,46 @@ +import { createHmac } from "node:crypto"; + +const CREDENTIAL_FINGERPRINT_DOMAIN = "junior.credential-fingerprint.v1"; + +/** Short non-reversible id for correlating a credential without logging the secret. */ +export function fingerprintCredentialToken(token: string): string { + return createHmac("sha256", CREDENTIAL_FINGERPRINT_DOMAIN) + .update(token, "utf8") + .digest("hex") + .slice(0, 12); +} + +/** Recover a token from Bearer or git smart-HTTP Basic Authorization values. */ +export function credentialTokenFromAuthorizationHeader( + value: string | undefined, +): string | undefined { + if (!value?.trim()) return undefined; + const trimmed = value.trim(); + const bearer = /^Bearer\s+(.+)$/i.exec(trimmed); + if (bearer?.[1]?.trim()) return bearer[1].trim(); + const basic = /^Basic\s+(.+)$/i.exec(trimmed); + if (!basic?.[1]) return undefined; + try { + const decoded = Buffer.from(basic[1].trim(), "base64").toString("utf8"); + const separator = decoded.indexOf(":"); + if (separator < 0) return undefined; + const credential = decoded.slice(separator + 1); + return credential || undefined; + } catch { + return undefined; + } +} + +/** Fingerprint the first Authorization token on lease header transforms. */ +export function fingerprintLeaseAuthorization( + headerTransforms: Array<{ headers: Record }>, +): string | undefined { + for (const transform of headerTransforms) { + for (const [key, value] of Object.entries(transform.headers)) { + if (key.toLowerCase() !== "authorization") continue; + const token = credentialTokenFromAuthorizationHeader(value); + if (token) return fingerprintCredentialToken(token); + } + } + return undefined; +} diff --git a/packages/junior/src/chat/egress/credentialed.ts b/packages/junior/src/chat/egress/credentialed.ts index bfe1b259ce..ca328efdfb 100644 --- a/packages/junior/src/chat/egress/credentialed.ts +++ b/packages/junior/src/chat/egress/credentialed.ts @@ -1,3 +1,4 @@ +import { fingerprintLeaseAuthorization } from "@/chat/credentials/token-fingerprint"; import { logInfo, logWarn } from "@/chat/logging"; import { onPluginEgressResponse } from "@/chat/plugins/credential-hooks"; import { matchesSandboxEgressDomain } from "@/chat/sandbox/egress/policy"; @@ -814,8 +815,14 @@ export async function executeCredentialedEgressRequest(input: { upstream.status === UPSTREAM_TOKEN_REJECTION_STATUS || upstream.status === UPSTREAM_PERMISSION_REJECTION_STATUS ) { + const tokenFingerprint = fingerprintLeaseAuthorization( + lease.headerTransforms, + ); logWarn("sandbox.egress.upstream_auth.rejected", { ...attributes(upstream.status, upstream), + ...(tokenFingerprint + ? { "app.credential.token_fingerprint": tokenFingerprint } + : {}), ...(upstream.status === UPSTREAM_TOKEN_REJECTION_STATUS ? { "app.sandbox.egress.www_authenticate": diff --git a/packages/junior/tests/unit/credentials/token-fingerprint.test.ts b/packages/junior/tests/unit/credentials/token-fingerprint.test.ts new file mode 100644 index 0000000000..9a8fb9c0a7 --- /dev/null +++ b/packages/junior/tests/unit/credentials/token-fingerprint.test.ts @@ -0,0 +1,42 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + credentialTokenFromAuthorizationHeader, + fingerprintCredentialToken, + fingerprintLeaseAuthorization, +} from "@/chat/credentials/token-fingerprint"; + +function expectedFingerprint(token: string): string { + return createHmac("sha256", "junior.credential-fingerprint.v1") + .update(token, "utf8") + .digest("hex") + .slice(0, 12); +} + +describe("token fingerprint helpers", () => { + it("hashes tokens to a stable short fingerprint", () => { + expect(fingerprintCredentialToken("installation-token")).toBe( + expectedFingerprint("installation-token"), + ); + }); + + it("recovers bearer and git smart-http basic tokens", () => { + expect( + credentialTokenFromAuthorizationHeader("Bearer installation-token"), + ).toBe("installation-token"); + const basic = Buffer.from("x-access-token:installation-token").toString( + "base64", + ); + expect(credentialTokenFromAuthorizationHeader(`Basic ${basic}`)).toBe( + "installation-token", + ); + }); + + it("fingerprints lease authorization headers", () => { + expect( + fingerprintLeaseAuthorization([ + { headers: { Authorization: "Bearer installation-token" } }, + ]), + ).toBe(expectedFingerprint("installation-token")); + }); +});