Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/junior-github/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Use `additionalUserScopes` only when an integration flow requires specific GitHu
## 3) Runtime behavior

- When either GitHub skill is active, authenticated `gh` and `git` commands cause the runtime to inject GitHub credentials automatically for the current turn.
- The plugin classifies GitHub traffic from the forwarded HTTP request. Safe app-readable API methods, GraphQL `GET`/`HEAD`/`OPTIONS` requests, and `git-upload-pack` use the `installation-read` grant. `GET /user` uses the `user-read` grant so account identity checks use the requester token. Write-specific REST URLs, non-read GraphQL methods, other non-read API methods, and `git-receive-pack` use the `user-write` grant.
- The plugin classifies GitHub traffic from the forwarded HTTP request. Safe app-readable API methods, GraphQL `GET`/`HEAD`/`OPTIONS` requests, GraphQL `POST` bodies that prove the operation is a query, and `git-upload-pack` use the `installation-read` grant. `GET /user` uses the `user-read` grant so account identity checks use the requester token. Write-specific REST URLs, GraphQL mutations/subscriptions, unknown GraphQL `POST` bodies, other non-read API methods, and `git-receive-pack` use the `user-write` grant.
- `user-read` and `user-write` require the requester, or an explicitly delegated user subject from an allowed system run, to authorize the GitHub App through the private OAuth flow. Missing or expired user authorization pauses interactive turns, sends a private authorization link, and resumes after approval.
- Git commits use the requester as the commit author, Junior as committer, and a Junior `Co-Authored-By` trailer.
- Issued credentials are reused only within the current turn, credential leases are cached separately by plugin grant name, and upstream 403 permission denials clear the cached lease before the next retry.
Expand Down
145 changes: 139 additions & 6 deletions packages/junior-github/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const GITHUB_AUTH_TOKEN_ENV = "GITHUB_TOKEN";
const GITHUB_AUTH_TOKEN_PLACEHOLDER = "ghp_host_managed_credential";
const MAX_LEASE_MS = 60 * 60 * 1000;
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
const GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES = 64 * 1024;
const HTTP_READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const USER_TOKEN_GRANTS = new Set(["user-read", "user-write"]);
const CONTENTS_WRITE_REQUIREMENTS = [
Expand Down Expand Up @@ -701,20 +702,133 @@ function githubUserReadReason(method, upstreamUrl) {
: undefined;
}

function githubGraphqlAccess(method, upstreamUrl) {
function parseGitHubGraphqlOperation(bodyText) {
if (typeof bodyText !== "string" || bodyText.trim().length === 0) {
return undefined;
}
let parsed;
try {
parsed = JSON.parse(bodyText);
} catch {
return undefined;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return undefined;
}
const query = parsed.query;
if (typeof query !== "string") {
return undefined;
}
const operationName =
typeof parsed.operationName === "string"
? parsed.operationName.trim()
: undefined;
const normalized = maskGraphqlStringLiterals(
query.replace(/^\s*#[^\n\r]*(?:\r?\n|$)/gm, ""),
).trim();
if (operationName) {
const namedOperation = normalized.match(
new RegExp(
`\\b(query|mutation|subscription)\\s+${escapeRegExp(operationName)}\\b`,
),
)?.[1];
return namedOperation ? graphqlOperationAccess(namedOperation) : undefined;
}
const operation = normalized.match(/\b(query|mutation|subscription)\b/)?.[1];
const operationAccess = graphqlOperationAccess(operation);
if (operationAccess) {
return operationAccess;
}
if (normalized.startsWith("{")) {
return "read";
}
return undefined;
}

function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function graphqlOperationAccess(operation) {
if (operation === "mutation" || operation === "subscription") {
return "write";
}
Comment thread
cursor[bot] marked this conversation as resolved.
if (operation === "query") {
return "read";
}
return undefined;
Comment thread
cursor[bot] marked this conversation as resolved.
}

function maskGraphqlStringLiterals(query) {
return query.replace(/"""[\s\S]*?"""|"(?:\\.|[^"\\])*"/g, (match) =>
" ".repeat(match.length),
);
}

function githubGraphqlAccess(method, upstreamUrl, bodyText) {
if (!isGitHubGraphqlUrl(upstreamUrl)) {
return undefined;
}
if (HTTP_READ_METHODS.has(method)) {
return "read";
}
// GitHub GraphQL POST bodies can be read queries or write mutations. The
// egress hook intentionally does not read sandbox request bodies, so non-read
// methods require user-write attribution rather than risking an unattributed
// mutation through an installation-read token.
const operation = parseGitHubGraphqlOperation(bodyText);
if (operation) {
return operation;
}
// Unknown GraphQL POST bodies still require user-write attribution rather
// than risking an unattributed mutation through an installation-read token.
return "write";
}

function githubGraphqlPermissionDeniedMessage(bodyText) {
let parsed;
try {
parsed = JSON.parse(bodyText);
} catch {
return undefined;
}
if (!isRecord(parsed) || !Array.isArray(parsed.errors)) {
return undefined;
}
for (const error of parsed.errors) {
if (!isRecord(error) || typeof error.message !== "string") {
continue;
}
const message = error.message;
if (
error.type === "NOT_FOUND" &&
/\bCould not resolve to a Repository with the name\b/.test(message)
) {
return `GitHub GraphQL could not access the repository: ${message}`;
}
if (/\bResource not accessible by integration\b/.test(message)) {
return `GitHub GraphQL denied access: ${message}`;
}
}
return undefined;
}

function shouldInspectGitHubGraphqlResponse(ctx) {
if (
ctx.request.method.toUpperCase() !== "POST" ||
ctx.response.status !== 200
) {
return false;
}
let upstreamUrl;
try {
upstreamUrl = new URL(ctx.request.url);
} catch {
return false;
}
if (!isGitHubGraphqlUrl(upstreamUrl)) {
return false;
}
const contentType = ctx.response.headers.get("content-type");
return contentType ? /\bjson\b/i.test(contentType) : false;
}

function githubApiWriteReason(method, upstreamUrl) {
const pathname = upstreamUrl.pathname.toLowerCase();
if (!isGitHubApiUrl(upstreamUrl)) {
Expand Down Expand Up @@ -830,7 +944,11 @@ async function githubGrantForEgress(ctx) {
return grantForAccess("write", writeReason, "user-write");
}

const graphqlAccess = githubGraphqlAccess(method, upstreamUrl);
const graphqlAccess = githubGraphqlAccess(
method,
upstreamUrl,
ctx.request.bodyText,
);
if (graphqlAccess) {
return grantForAccess(
graphqlAccess,
Expand Down Expand Up @@ -969,6 +1087,21 @@ export function githubPlugin(options = {}) {
grantForEgress(ctx) {
return githubGrantForEgress(ctx);
},
async onEgressResponse(ctx) {
if (!shouldInspectGitHubGraphqlResponse(ctx)) {
return;
}
const bodyText = await ctx.response.readText(
GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES,
);
if (!bodyText) {
return;
}
const message = githubGraphqlPermissionDeniedMessage(bodyText);
if (message) {
ctx.permissionDenied(message);
}
},
async resolveOAuthAccount(ctx) {
return await resolveUserAccount(ctx.tokens);
},
Expand Down
34 changes: 34 additions & 0 deletions packages/junior-plugin-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,23 @@ export type AgentPluginAuthorization = z.output<
typeof agentPluginAuthorizationSchema
>;

/** Interrupt sandbox egress so Junior can start provider authorization. */
export class EgressAuthRequired extends Error {
authorization?: AgentPluginAuthorization;

constructor(
message: string,
options?: {
authorization?: AgentPluginAuthorization;
cause?: unknown;
},
) {
super(message, { cause: options?.cause });
this.name = "EgressAuthRequired";
this.authorization = options?.authorization;
}
}

/** Provider account identity resolved by a plugin OAuth hook. */
export type AgentPluginProviderAccount = z.output<
typeof agentPluginProviderAccountSchema
Expand All @@ -453,6 +470,8 @@ export type AgentPluginGrant = z.output<typeof agentPluginGrantSchema>;

/** Request details available while selecting the grant for sandbox egress. */
export interface AgentPluginEgressRequest {
/** Capped request body text when the host exposes it for provider-specific grant classification. */
bodyText?: string;
method: string;
url: string;
}
Expand All @@ -461,6 +480,20 @@ export interface EgressHookContext extends AgentPluginContext {
request: AgentPluginEgressRequest;
}

export interface AgentPluginEgressResponse {
/** Snapshot of upstream response headers; mutations do not affect pass-through. */
headers: Headers;
readText(maxBytes: number): Promise<string | undefined>;
status: number;
}

export interface EgressResponseHookContext extends AgentPluginContext {
grant: AgentPluginGrant;
permissionDenied(message: string): void;
request: Omit<AgentPluginEgressRequest, "bodyText">;
response: AgentPluginEgressResponse;
}

/** Header mutations a plugin-issued credential lease may apply to owned domains. */
export type AgentPluginCredentialHeaderTransform = z.output<
typeof agentPluginCredentialHeaderTransformSchema
Expand Down Expand Up @@ -529,6 +562,7 @@ export interface AgentPluginHooks {
issueCredential?(
ctx: IssueCredentialHookContext,
): Promise<AgentPluginCredentialResult> | AgentPluginCredentialResult;
onEgressResponse?(ctx: EgressResponseHookContext): Promise<void> | void;
resolveOAuthAccount?(
ctx: ResolveOAuthAccountHookContext,
):
Expand Down
52 changes: 52 additions & 0 deletions packages/junior/src/chat/plugins/credential-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ function parseCredentialResult(
}

export interface EgressGrantInput {
bodyText?: string;
method: string;
provider: string;
upstreamUrl: URL;
Expand All @@ -109,13 +110,64 @@ export async function selectPluginGrant(
plugin: { name: plugin.name },
log: createAgentPluginLogger(plugin.name),
request: {
...(input.bodyText !== undefined ? { bodyText: input.bodyText } : {}),
method: input.method,
url: input.upstreamUrl.toString(),
},
});
return result === undefined ? undefined : parseGrant(result, plugin.name);
}

export interface EgressResponseInput {
grant: AgentPluginGrant;
method: string;
provider: string;
response: {
headers: Headers;
readText(maxBytes: number): Promise<string | undefined>;
status: number;
};
upstreamUrl: URL;
}

export interface EgressResponseEffects {
permissionDenied?: {
message: string;
};
}

/** Let the owning plugin inspect an upstream response without changing pass-through behavior. */
export async function onPluginEgressResponse(
input: EgressResponseInput,
): Promise<EgressResponseEffects> {
const plugin = agentPluginFor(input.provider);
const hook = plugin?.hooks?.onEgressResponse;
if (!plugin || !hook) {
return {};
}
let permissionDenied: { message: string } | undefined;
await hook({
plugin: { name: plugin.name },
log: createAgentPluginLogger(plugin.name),
grant: input.grant,
permissionDenied(message) {
const trimmed = message.trim();
if (!trimmed) {
throw new Error(
`Plugin "${plugin.name}" onEgressResponse permissionDenied message is empty`,
);
}
permissionDenied = { message: trimmed };
},
request: {
method: input.method,
url: input.upstreamUrl.toString(),
},
response: input.response,
});
return permissionDenied ? { permissionDenied } : {};
}

/** Return whether a plugin owns credential issuance for egress. */
export function hasEgressCredentialHooks(provider: string): boolean {
const hooks = agentPluginFor(provider)?.hooks;
Expand Down
2 changes: 2 additions & 0 deletions packages/junior/src/chat/sandbox/egress-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ function assertLeaseTransformsOwnedByProvider(

/** Select the plugin-defined or default grant needed for one outbound request. */
export async function selectSandboxEgressGrant(input: {
bodyText?: string;
method: string;
provider: string;
upstreamUrl: URL;
Expand All @@ -120,6 +121,7 @@ export async function selectSandboxEgressGrant(input: {
}

const pluginGrant = await selectPluginGrant({
...(input.bodyText !== undefined ? { bodyText: input.bodyText } : {}),
provider: input.provider,
method: input.method,
upstreamUrl: input.upstreamUrl,
Expand Down
Loading
Loading