Skip to content
Open
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
6 changes: 6 additions & 0 deletions TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 13 additions & 1 deletion packages/junior-github/src/credential-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<PluginLogger, "info">,
): Promise<PluginCredentialResult> {
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,
Expand Down
34 changes: 16 additions & 18 deletions packages/junior-github/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
10 changes: 9 additions & 1 deletion packages/junior-github/tests/github-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,10 @@ function beforeToolContext(actor: TestActor, actors?: TestActor[]) {
};
}

const pluginLogInfo = vi.fn();
const pluginLog = {
error() {},
info() {},
info: pluginLogInfo,
warn() {},
};

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 () => {
Expand Down
46 changes: 46 additions & 0 deletions packages/junior/src/chat/credentials/token-fingerprint.ts
Original file line number Diff line number Diff line change
@@ -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, string> }>,
): 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;
}
7 changes: 7 additions & 0 deletions packages/junior/src/chat/egress/credentialed.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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":
Expand Down
42 changes: 42 additions & 0 deletions packages/junior/tests/unit/credentials/token-fingerprint.test.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
});
Loading