fix(github): default repository owner to the only installed organization (#20141)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents use a managed GitHub connection to create repositories and to
fork repositories
> - The Paperclip GitHub App can be installed only on an organization,
and not on the user's personal account
> - Then `create_repository` and `fork_repository` fail when
`organization` is empty, because GitHub sends them to the personal
account
> - Agents, approval cards, and connection tests all fail on their first
attempt with `403 Resource not accessible by integration`
> - This pull request fills an empty `organization` with the only
organization where the App is installed
> - The benefit is that repository creation works on the first attempt
for these connections

## Linked Issues or Issue Description

No public issue exists. Refs #19655 (GitHub repair guidance for the same
error).

**What happened?**
A user connected GitHub through the managed connection. The Paperclip
GitHub App was installed on one organization and not on the user's
personal account. An agent called `create_repository` without
`organization`. GitHub returned `POST https://api.github.com/user/repos:
403 Resource not accessible by integration`. The test button on the
connection page gave the same error. The agent then stopped and asked
the user to create the repository manually.

**Expected behavior**
When the App can create repositories only in one organization, an empty
`organization` uses that organization. The approval card and the test
result show that owner.

**Steps to reproduce**
1. Install the Paperclip GitHub App on one organization only. Do not
install it on the personal account.
2. Connect GitHub with the managed connection.
3. Run `create_repository` with only `name`, from an agent or from the
connection test dialog.
4. GitHub returns 403.

## What Changed

- Added `server/src/services/tool-access/github-default-owner.ts`. It
finds the default organization from the grant's access snapshot. It
returns an organization only when there is exactly one installation, and
the snapshot marks that owner's repositories as organization
repositories. Older snapshots without account types get no default until
access is refreshed.
- `governedToolArguments` in `tool-gateway.ts` fills an empty
`organization` for `create_repository` and `fork_repository` on managed
GitHub connections. The change occurs before policy, approval signing,
previews, and audit. Thus agent calls, approval cards, and connection
tests use the same owner.
- The grant lookup runs only for these two tools, and only when
`organization` is empty. If the credential is unavailable, the call does
not change and fails with its own error at dispatch.
- An explicit owner does not change. A fork into its own owner does not
change. Token (PAT) connections, personal installations, and multiple
organizations keep GitHub's behavior.
- `projectedConnectionToolInputSchema` describes the default on the
`organization` argument. Agents and the connection test form see where
the repository goes.
- Updated `doc/connections/GITHUB.md`.

## Verification

- `pnpm --filter @paperclipai/server typecheck` passes.
- `npx vitest run
src/services/tool-access/github-default-owner.test.ts`: new unit tests
for the owner rules (single organization, personal installation, several
organizations, another person's account, older snapshots, blank values,
forks into the same owner, schema text).
- New gateway test in `src/__tests__/tool-gateway.test.ts`: "defaults
GitHub repository creation to the only installed organization for agents
and connection tests". It covers an agent call, an explicit owner, a
connection test call, and a personal installation. It fails without the
gateway change.
- Related suites pass, 393 tests in 9 files: `tool-gateway.test.ts`,
`tool-gateway-service.test.ts`, `connection-instructions.test.ts`,
`github-access-errors.test.ts`, `github-default-owner.test.ts`,
`tool-gateway-discovery-http.test.ts`,
`github-operation-credentials.test.ts`,
`project-git-authorization.test.ts`, `prompt-injection-gateway.test.ts`.
- Not tested against a live GitHub App installation.

## Risks

- Low risk. The default applies only when the grant snapshot shows one
organization installation and no personal installation. Without the
change, these calls fail with 403.
- A stale access snapshot can choose the organization after a user
installs the App on the personal account. Installation events and
**Refresh access** update the snapshot.
- An approval that was created before this change, without
`organization`, no longer matches the projected arguments. That approval
cannot run and must be requested again. Without the change, it fails
with 403.
- `gh repo create` and `gh repo fork` in agent shells do not use the
gateway, so they keep GitHub's personal default.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (code reading, shell commands, and test runs).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-11-08 17:08:13 -08:00
1 parent 8e3b64a257
commit 0e49576fd9
6 files changed
+282 -2

No files matched your search

+7 -1
View File
@@ -154,7 +154,13 @@ repository owner first. The authenticated GitHub username and the accounts
where the App is installed are separate. An organization installation does not
grant access to the user's personal repositories. Personal repository creation
needs App access to that personal account; organization creation needs App access
to that organization and the tool's `organization` argument. The agent must
to that organization and the tool's `organization` argument. When the App is
installed on exactly one account, and its access snapshot identifies that
account as an organization, `create_repository` and `fork_repository` calls that omit `organization` use
that organization, because the personal account is unavailable. Paperclip fills
it in before policy, approval, and audit, so approval cards and test results
show the owner. An explicit owner is unchanged, and any other installation set
keeps GitHub's personal-account default. The agent must
keep the owner requested by the user. The user can choose the intended account
from **Add More Repos on GitHub**, then select **Refresh access**. If creation remains
unavailable, the user can create the repository on GitHub and give the App access,
+54
View File
@@ -738,6 +738,60 @@ describeEmbeddedPostgres("tool gateway acceptance", () => {
} finally { await remote.close(); }
});
it("defaults GitHub repository creation to the only installed organization for agents and connection tests", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const remote = await startFakeRemoteMcpServer(({ body }) => ({ body: {
jsonrpc: "2.0", id: body?.id, result: { content: [{ type: "text", text: "Repository created" }] },
} }));
try {
const { application, connection, catalogEntry } = await createRemoteMcpTool(db, company.id, {
url: remote.url, applicationKey: "github", toolName: "create_repository", riskLevel: "write",
connectionConfig: { sourceTemplateKey: "github", connectionMethodKey: "managed" },
});
await db.update(toolCatalogEntries).set({ inputSchema: {
type: "object", required: ["name"], additionalProperties: false, properties: {
name: { type: "string" },
organization: { type: "string", description: "Organization to create the repository in (omit to create in your personal account)" },
},
} }).where(eq(toolCatalogEntries.id, catalogEntry.id));
const github = { userId: "1", login: "octocat", installationCount: 1, repositoryCount: 1,
repositorySelection: "all" as const, installationIds: ["9"], installationOwnerLogins: ["acme"],
repositories: [{ id: "10", fullName: "acme/site", installationId: "9", ownerType: "organization" as const }] };
await db.update(connectionGrants).set({ providerTenant: { github } })
.where(eq(connectionGrants.connectionId, connection.id));
const toolName = expectedConnectedToolName({ applicationKey: application.applicationKey,
connectionId: connection.id, toolName: catalogEntry.toolName });
await allowToolsForAgent(db, company.id, agent.id, [toolName]);
const gateway = createTestToolGatewayService(db);
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
const listed = (await gateway.listToolsForSession(session.token)).find((tool) => tool.name === toolName);
expect((listed?.parametersSchema as { properties?: Record<string, { description?: string }> } | undefined)
?.properties?.organization?.description).toMatch(/installed only on one organization; then Paperclip uses that organization/);
await gateway.executeTool({ sessionToken: session.token, tool: toolName, parameters: { name: "agent-site" } });
await gateway.executeTool({ sessionToken: session.token, tool: toolName,
parameters: { name: "chosen-site", organization: "globex" } });
await createActiveMember(db, company.id, "test-user");
const testCall = await gateway.executeTestCall({ companyId: company.id, connectionId: connection.id,
agentId: agent.id, userId: "test-user", toolName, parameters: { name: "test-site" } });
expect(testCall).toMatchObject({ decision: "allowed" });
// With the App also on the personal account, GitHub's personal default applies.
await db.update(connectionGrants).set({ providerTenant: { github: { ...github, installationOwnerLogins: ["octocat", "acme"] } } })
.where(eq(connectionGrants.connectionId, connection.id));
await gateway.executeTool({ sessionToken: session.token, tool: toolName, parameters: { name: "personal-site" } });
expect(remote.requests.filter((entry) => entry.body?.method === "tools/call")
.map((entry) => (entry.body?.params as { arguments?: unknown } | undefined)?.arguments)).toEqual([
{ name: "agent-site", organization: "acme" },
{ name: "chosen-site", organization: "globex" },
{ name: "test-site", organization: "acme" },
{ name: "personal-site" },
]);
} finally { await remote.close(); }
});
it.each(["config", "transport", "application", "legacy_application"])("records an actionable GitHub access error from %s metadata after one approved call and keeps the connection usable", async source => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
@@ -15,6 +15,7 @@ import { isPaperclipCloudConnectorStrategy } from "../paperclip-cloud-connector.
import { honchoManagedArguments } from "../honcho-connection.js";
import { assertGoogleChatToolArgumentsSupported, googleChatToolInputSchema } from "../google-chat-tool-policy.js";
import { gmailArchiveToolInputSchema } from "../gmail-archive-tool-policy.js";
import { githubOwnerToolInputSchema } from "./github-default-owner.js";
type ConnectionSettings = {
config: Record<string, unknown>;
@@ -345,6 +346,7 @@ export function projectedConnectionToolInputSchema(
if (managed) inputSchema = stripManagedToolArgumentSchema(inputSchema, managed);
inputSchema = googleChatToolInputSchema(connection, toolName, inputSchema);
inputSchema = gmailArchiveToolInputSchema(connection, toolName, inputSchema);
inputSchema = githubOwnerToolInputSchema(connection, toolName, inputSchema);
const sourceTemplateKey =
typeof connection.config.sourceTemplateKey === "string"
? connection.config.sourceTemplateKey
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import {
githubDefaultOrganization,
githubDefaultOwnerApplies,
githubDefaultOwnerArguments,
githubOwnerToolInputSchema,
} from "./github-default-owner.js";
const managed = { config: { sourceTemplateKey: "github", connectionMethodKey: "managed" } };
const createSchema = {
type: "object",
properties: {
name: { type: "string" },
organization: { type: "string", description: "Organization to create the repository in (omit to create in your personal account)" },
},
required: ["name"],
};
const orgOnly = {
login: "octocat",
installationOwnerLogins: ["acme"],
repositories: [{ id: "1", fullName: "acme/site", installationId: "9", ownerType: "organization" }],
};
describe("githubDefaultOrganization", () => {
it("uses the only installed organization when the personal account has no installation", () => {
expect(githubDefaultOrganization(orgOnly)).toBe("acme");
});
it("requires the snapshot to identify the owner as an organization", () => {
// Snapshots written before ownerType was recorded keep GitHub's behavior.
expect(githubDefaultOrganization({ login: "octocat", installationOwnerLogins: ["hubot"] })).toBeNull();
expect(githubDefaultOrganization({ login: "octocat", installationOwnerLogins: ["hubot"],
repositories: [{ fullName: "hubot/tools", installationId: "9" }] })).toBeNull();
expect(githubDefaultOrganization({ login: "octocat", installationOwnerLogins: ["acme"],
repositories: [{ fullName: "acme/site", ownerType: "organization" }, { fullName: "acme/docs" }] })).toBeNull();
});
it("keeps GitHub's personal default when the personal account is installed", () => {
expect(githubDefaultOrganization({ login: "octocat", installationOwnerLogins: ["octocat"] })).toBeNull();
expect(githubDefaultOrganization({ login: "OctoCat", installationOwnerLogins: ["octocat", "acme"] })).toBeNull();
});
it("does not guess between several organizations", () => {
expect(githubDefaultOrganization({ ...orgOnly, installationOwnerLogins: ["acme", "globex"] })).toBeNull();
expect(githubDefaultOrganization({ ...orgOnly, installationOwnerLogins: ["Acme", "acme"] })).toBe("Acme");
});
it("ignores another person's account and incomplete snapshots", () => {
expect(githubDefaultOrganization({ login: "octocat", installationOwnerLogins: ["hubot"],
repositories: [{ fullName: "hubot/tools", ownerType: "personal" }] })).toBeNull();
expect(githubDefaultOrganization({ installationOwnerLogins: ["acme"] })).toBeNull();
expect(githubDefaultOrganization({ login: "octocat", installationOwnerLogins: [] })).toBeNull();
expect(githubDefaultOrganization(undefined)).toBeNull();
});
});
describe("githubDefaultOwnerArguments", () => {
it("fills an omitted or blank organization", () => {
expect(githubDefaultOwnerArguments("create_repository", { name: "site" }, orgOnly))
.toEqual({ name: "site", organization: "acme" });
expect(githubDefaultOwnerArguments("mcp.github:create-repository", { name: "site", organization: " " }, orgOnly))
.toEqual({ name: "site", organization: "acme" });
expect(githubDefaultOwnerArguments("fork_repository", { owner: "octo-org", repo: "tools" }, orgOnly))
.toEqual({ owner: "octo-org", repo: "tools", organization: "acme" });
});
it("keeps explicit owners and calls without a default", () => {
const explicit = { name: "site", organization: "globex" };
expect(githubDefaultOwnerArguments("create_repository", explicit, orgOnly)).toBe(explicit);
const personal = { name: "site" };
expect(githubDefaultOwnerArguments("create_repository", personal,
{ login: "octocat", installationOwnerLogins: ["octocat"] })).toBe(personal);
});
it("does not fork a repository into its own organization", () => {
const fork = { owner: "ACME", repo: "site" };
expect(githubDefaultOwnerArguments("fork_repository", fork, orgOnly)).toBe(fork);
});
});
describe("githubDefaultOwnerApplies", () => {
it("applies only to managed GitHub owner tools that declare organization", () => {
expect(githubDefaultOwnerApplies(managed, "create_repository", createSchema, { name: "site" })).toBe(true);
expect(githubDefaultOwnerApplies({ config: {}, transportConfig: managed.config }, "fork_repository",
createSchema, { owner: "a", repo: "b" })).toBe(true);
expect(githubDefaultOwnerApplies(managed, "create_repository", createSchema, { name: "site", organization: "acme" })).toBe(false);
expect(githubDefaultOwnerApplies(managed, "create_issue", createSchema, {})).toBe(false);
expect(githubDefaultOwnerApplies(managed, "create_repository", { properties: { name: {} } }, { name: "site" })).toBe(false);
expect(githubDefaultOwnerApplies({ config: { sourceTemplateKey: "github", connectionMethodKey: "mcp-key" } },
"create_repository", createSchema, { name: "site" })).toBe(false);
});
});
describe("githubOwnerToolInputSchema", () => {
it("describes the default for managed GitHub owner tools only", () => {
const projected = githubOwnerToolInputSchema(managed, "create_repository", createSchema);
expect(projected.required).toEqual(["name"]);
expect((projected.properties as Record<string, { description?: string }>).organization?.description)
.toMatch(/installed only on one organization; then Paperclip uses that organization/);
expect(createSchema.properties.organization.description).toMatch(/omit to create in your personal account/);
expect(githubOwnerToolInputSchema({ config: { sourceTemplateKey: "github", connectionMethodKey: "mcp-key" } },
"create_repository", createSchema)).toBe(createSchema);
expect(githubOwnerToolInputSchema(managed, "create_issue", createSchema)).toBe(createSchema);
});
});
@@ -0,0 +1,107 @@
type Connection = { config: Record<string, unknown>; transportConfig?: Record<string, unknown> | null };
/** GitHub tools that target the authenticated user's personal account when
* `organization` is omitted. A managed connection can only write there when
* the Paperclip GitHub App is installed on that personal account. */
const OWNER_DESCRIPTIONS: Record<string, string> = {
create_repository: "Organization to create the repository in. When omitted, the repository goes to your personal account, unless the Paperclip GitHub App is installed only on one organization; then Paperclip uses that organization.",
fork_repository: "Organization to fork to. When omitted, the fork goes to your personal account, unless the Paperclip GitHub App is installed only on one organization; then Paperclip uses that organization.",
};
function record(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function operation(toolName: string): string {
return (toolName.split(/[.:/]/).pop() ?? "").trim().toLowerCase().replace(/-/g, "_");
}
function isManagedGitHub(connection: Connection): boolean {
const config = record(connection.config);
const transport = record(connection.transportConfig);
return (config.sourceTemplateKey ?? transport.sourceTemplateKey) === "github"
&& (config.connectionMethodKey ?? transport.connectionMethodKey) === "managed";
}
function omitted(value: unknown): boolean {
return value === undefined || value === null || (typeof value === "string" && value.trim() === "");
}
function ownerOwnedTool(connection: Connection, toolName: string, inputSchema: unknown): boolean {
return isManagedGitHub(connection)
&& operation(toolName) in OWNER_DESCRIPTIONS
&& "organization" in record(record(inputSchema).properties);
}
/** The only account the Paperclip GitHub App is installed on, when that
* account is an organization rather than the user's own. Repository creation
* without an organization fails for such a grant, so this is the one owner
* where it can succeed. Suspended installations are already excluded from
* the access snapshot. */
export function githubDefaultOrganization(github: unknown): string | null {
const access = record(github);
const login = typeof access.login === "string" ? access.login.trim().toLowerCase() : "";
// GitHub logins are case-insensitive.
const owners = new Map<string, string>();
for (const value of Array.isArray(access.installationOwnerLogins) ? access.installationOwnerLogins : []) {
if (typeof value !== "string" || value.trim() === "") continue;
if (!owners.has(value.trim().toLowerCase())) owners.set(value.trim().toLowerCase(), value.trim());
}
if (!login || owners.size !== 1 || owners.has(login)) return null;
const owner = [...owners.values()][0]!;
// An installation on another person's account can be visible to a
// collaborator, and GitHub cannot create organization repositories there.
// Require the snapshot to identify the owner as an organization; snapshots
// without account types keep GitHub's behavior until access is refreshed.
const ownerTypes = new Set((Array.isArray(access.repositories) ? access.repositories : [])
.map(record)
.filter((entry) => typeof entry.fullName === "string"
&& entry.fullName.split("/")[0]?.toLowerCase() === owner.toLowerCase())
.map((entry) => entry.ownerType));
return ownerTypes.size === 1 && ownerTypes.has("organization") ? owner : null;
}
/** Whether this call could take a default organization. Checked before the
* grant is loaded so other tools never pay for the lookup. */
export function githubDefaultOwnerApplies(
connection: Connection,
toolName: string,
inputSchema: unknown,
parameters: unknown,
): boolean {
return ownerOwnedTool(connection, toolName, inputSchema) && omitted(record(parameters).organization);
}
/** Fill an omitted `organization` from the grant's access snapshot. Explicit
* owners are kept, and a repository is never forked into its own owner. */
export function githubDefaultOwnerArguments(
toolName: string,
parameters: Record<string, unknown>,
github: unknown,
): Record<string, unknown> {
const organization = githubDefaultOrganization(github);
if (!organization || !omitted(parameters.organization)) return parameters;
if (operation(toolName) === "fork_repository" && typeof parameters.owner === "string" &&
parameters.owner.trim().toLowerCase() === organization.toLowerCase()) return parameters;
return { ...parameters, organization };
}
/** Describe the default on the `organization` argument for agents and the
* connection test form. */
export function githubOwnerToolInputSchema(
connection: Connection,
toolName: string,
schema: Record<string, unknown>,
): Record<string, unknown> {
if (!ownerOwnedTool(connection, toolName, schema)) return schema;
const properties = record(schema.properties);
return {
...schema,
properties: {
...properties,
organization: { ...record(properties.organization), description: OWNER_DESCRIPTIONS[operation(toolName)] },
},
};
}
+7 -1
View File
@@ -22,6 +22,7 @@ import { composeConnectionInstructions } from "./connection-instructions.js";
import { googleWorkspaceToolTarget, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted } from "@paperclipai/shared";
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js";
import { githubAccessDeniedInstructions, githubAccessError } from "./tool-access/github-access-errors.js";
import { githubDefaultOwnerApplies, githubDefaultOwnerArguments } from "./tool-access/github-default-owner.js";
import { toolSourceTemplateKeySql } from "./tool-access/source-template-key.js";
import { boundedMcpToolName } from "./mcp-tool-names.js";
import { browserUseService } from "./browser-use.js";
@@ -5075,7 +5076,12 @@ export function createToolGatewayService(
): Promise<unknown> {
if (tool.providerType !== "mcp_remote_http" && tool.providerType !== "provider_rest") return parameters;
const { connection, entry } = await resolveConnectedRemoteTool(session, tool);
return projectedConnectionToolArguments(connection, parameters, entry.toolName, entry.inputSchema ?? {});
const projected = projectedConnectionToolArguments(connection, parameters, entry.toolName, entry.inputSchema ?? {});
if (!githubDefaultOwnerApplies(connection, entry.toolName, entry.inputSchema, projected)) return projected;
// An unavailable credential keeps the call unchanged and fails with its own
// error at dispatch.
const grant = await resolveConnectionGrant(session, connection, false).catch(() => null);
return githubDefaultOwnerArguments(entry.toolName, projected, grant?.providerTenant?.github);
}
async function approvedManagedArgumentsRemainCurrent(