From b7e215b39cc8561099cff74e3f26811abfdb393d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 14 Jul 2026 09:15:16 -0700 Subject: [PATCH] fix(github): Preserve installation permissions on write tokens Repository write leases could still be downscoped by appPermissions, recreating incomplete tokens across related push and pull request operations. Make the write issuance variant repository-only so it cannot include an operation-specific permission body. Align routing guidance, skills, docs, and regression coverage with automatic GitHub App credentials. Remove the completed plan for the retired split-grant model. Co-Authored-By: OpenAI GPT-5 --- .../github-bot-resource-ownership/design.md | 190 ---------------- .../github-bot-resource-ownership/proposal.md | 77 ------- .../specs/github-resource-identity/spec.md | 209 ------------------ .../github-bot-resource-ownership/tasks.md | 112 ---------- .../src/content/docs/extend/github-plugin.md | 23 +- .../evals/github/skill-workflows.eval.ts | 11 +- packages/junior-github/SETUP.md | 20 +- .../junior-github/skills/github-code/SKILL.md | 11 +- .../github-code/references/api-surface.md | 4 +- .../references/troubleshooting-workarounds.md | 5 +- .../references/troubleshooting-workarounds.md | 25 ++- packages/junior-github/src/index.ts | 85 ++++--- packages/junior-github/src/permissions.ts | 2 +- .../junior-github/tests/github-plugin.test.ts | 98 +------- .../integration/sandbox-egress-proxy.test.ts | 9 +- 15 files changed, 109 insertions(+), 772 deletions(-) delete mode 100644 openspec/changes/github-bot-resource-ownership/design.md delete mode 100644 openspec/changes/github-bot-resource-ownership/proposal.md delete mode 100644 openspec/changes/github-bot-resource-ownership/specs/github-resource-identity/spec.md delete mode 100644 openspec/changes/github-bot-resource-ownership/tasks.md diff --git a/openspec/changes/github-bot-resource-ownership/design.md b/openspec/changes/github-bot-resource-ownership/design.md deleted file mode 100644 index 201acc20d..000000000 --- a/openspec/changes/github-bot-resource-ownership/design.md +++ /dev/null @@ -1,190 +0,0 @@ -# Design: GitHub Bot Resource Ownership - -## Current State - -The GitHub plugin has three credential grants: - -- `installation-read` mints a GitHub App installation token explicitly scoped - down to read-capable permissions. -- `user-read` uses the current or delegated user's GitHub App OAuth token for - user-identity reads such as `GET /user`. -- `user-write` uses the current or delegated user's OAuth token for every - classified write, including issue and pull request creation, comments, - metadata changes, GraphQL mutations, and Git pushes. - -Credential leases are cached by provider, signed egress context, and grant -name. The cache does not distinguish repositories or individual write -operations. The credential issuance hook receives the selected grant but not -the outbound request, so it cannot currently mint a repository-scoped token -from the request target. - -Typed `github_createIssue` and `github_createPullRequest` tools already issue -host-owned requests with trusted operation names, deterministic idempotency, -and runtime-owned conversation footers. Raw issue and pull request creation is -denied. - -Git commits currently use the run actor as author, Junior as committer, and -Junior as a co-author. Additional run actors are credited as co-authors. - -## Invariants - -1. Junior-owned GitHub resources execute under Junior's installation identity. -2. Human attribution never selects or expands a bot credential. -3. No generic installation write lease exists. -4. Every bot write is classified into an explicit operation family before - credential issuance. -5. Bot write leases are isolated by operation family and target repository. -6. Unknown, ambiguous, or unsupported writes fail closed; they do not silently - borrow a user token. -7. Human-judgment operations remain human-authenticated. -8. High-impact repository operations require separate policy. - -## Grant Model - -Retain `installation-read` and `user-read`. Replace ordinary GitHub resource -writes with the following installation grant families: - -- `installation-issues-write` -- `installation-pull-requests-write` -- `installation-pr-branch-write` - -Keep `user-write` only for explicitly enumerated operations whose provider -meaning requires a human identity. It is not the fallback for an unknown write. - -Each plugin-selected grant gains an optional opaque lease scope. For GitHub bot -writes, the plugin sets the scope to the canonical repository identity plus any -narrower operation discriminator required to prevent unsafe reuse. The host -includes that scope in the lease cache key while continuing to use the stable -grant name for logs and auth/permission signals. - -The GitHub plugin derives and validates the provider-specific repository target -while classifying the request, stores the canonical target in the grant's -plugin-owned lease scope, and uses that trusted scope during credential -issuance. This keeps provider request parsing inside the GitHub plugin and -avoids widening the generic credential hook with provider request details. - -## Operation Classification - -### Issues - -The issue grant covers only explicitly supported issue-resource routes: - -- Typed issue creation -- Issue title/body/state updates -- Issue comments -- Labels and assignees -- Close and reopen through the issue state contract - -The classifier must distinguish issue resources from pull requests where -GitHub shares the issues API. Unsupported issue mutations remain denied until -they are added to the allowlist with tests. - -### Pull Requests - -The pull request grant covers only explicitly supported pull-request resource -routes: - -- Typed pull request creation -- Pull request title/body/base/draft metadata updates -- Ready-for-review and requested-reviewer changes -- Pull request close - -Pull request comments, labels, and assignees use GitHub's issue endpoints and -therefore select the issue installation grant while retaining Junior's -installation identity. - -Reviews, approvals, change requests, merge, branch deletion, and administrative -operations are separate operation families and do not use this grant. - -### Raw REST And GraphQL - -Trusted typed host/plugin operations may select a bot grant directly after the -plugin verifies their method and endpoint. Raw sandbox traffic may receive a -bot grant only when the method, endpoint, and target repository unambiguously -match an approved allowlist entry. - -Unknown REST writes, unknown GraphQL mutations, oversized GraphQL bodies, and -generic non-read fallbacks are denied before credential issuance. They do not -receive `installation-*-write` or `user-write` automatically. - -## Pull Request Branch Writes - -`git-receive-pack` proves that a Git push is occurring and identifies the -target repository. That is sufficient for the intended operation boundary: -Git pushes select `installation-pr-branch-write`, while REST contents writes, -merges, and unknown API writes remain denied. - -The installation token is repository-scoped and requests `contents: write`. -When the configured GitHub App permission envelope includes `workflows: write`, -the token includes it so workflow-file pushes can succeed. - -This grant is actor-independent service-principal authority. A headless -resource-event turn runs as the `resource-event` system actor with no user -credential subject, but it can still receive the repository-scoped -installation token. That lets a conversation subscribed to a Junior-owned pull -request react to CI or review events, create a follow-up commit as Junior, and -push the fix. Human-identity operations such as reviews remain on `user-write` -and still require explicit delegated authorization. - -This classifier intentionally does not add a separate branch-ownership or -force-update or ref-deletion engine. A repository-scoped token can be used by Git smart HTTP -for any ref GitHub permits within that repository. Deployments rely on GitHub -branch protection, rulesets, and App installation scope for those controls. - -## Attribution - -Junior-managed commits use the configured Junior bot identity as both Git -author and committer. Every resolvable human in the run's attribution set, -including the primary run actor, is appended once as `Co-Authored-By`. System -actors and human actors without a valid name and email are omitted rather than -guessed. - -Typed issue and pull request bodies receive a runtime-owned `Requested by` -attribution adjacent to the existing Junior conversation footer. Attribution -uses verified runtime actor display data and the conversation link when -available. It must not invent a GitHub handle or use attribution as an -authorization input. - -For system-originated runs, the body names the system actor only when the -runtime exposes that actor to the tool boundary. Creator metadata may be shown -only when it is explicitly carried as audit metadata; it must not be inferred -from conversation history. - -## Failure Behavior - -- Missing installation configuration or unsupported App permissions produce a - credential-unavailable or permission-denied result, never an OAuth prompt. -- Unsupported bot writes fail with an actionable policy-denied message. -- A provider `403` reports the selected scoped grant and GitHub permission - headers without retrying under a user's identity. -- Human-only operations may request delegated OAuth through the existing - private authorization flow. -- Cached leases are never reused across repository or operation scopes. - -## Compatibility And Rollout - -1. Introduce scoped lease-cache support and request-aware credential issuance - without changing existing grants. -2. Cut typed issue and pull request creation to installation grants. -3. Add explicitly tested issue and pull request lifecycle routes. -4. Invert commit attribution to Junior-as-author with all humans as co-authors. -5. Cut Git smart-HTTP pushes to the repository-scoped branch-write grant. -6. Remove ordinary resource mutations from `user-write`; leave only enumerated - human-identity operations. - -There is no compatibility fallback from bot identity to user identity. A route -remains on its prior explicit identity until cut over, then follows the new -policy exclusively. - -## Verification - -- Focused GitHub plugin tests for every grant classification and permission - body. -- Sandbox egress integration tests proving repository/operation lease isolation - and fail-closed unknown writes. -- Typed issue and pull request integration tests proving installation - credentials and requester attribution. -- Git hook tests proving Junior author/committer identity and complete human - co-author attribution. -- Git smart-HTTP classification and repository-scoped permission-body tests. -- Local-agent behavior checks for issue and pull request creation. diff --git a/openspec/changes/github-bot-resource-ownership/proposal.md b/openspec/changes/github-bot-resource-ownership/proposal.md deleted file mode 100644 index 324a4a6a0..000000000 --- a/openspec/changes/github-bot-resource-ownership/proposal.md +++ /dev/null @@ -1,77 +0,0 @@ -# GitHub Bot Resource Ownership - -## Summary - -Make Junior the primary GitHub identity for issues, pull requests, and -Junior-managed pull-request branches while keeping bot write authority -deny-by-default and narrowly scoped to approved resource workflows. - -## Motivation - -GitHub reads currently use GitHub App installation tokens, but writes use a -requesting or delegated user's GitHub App OAuth token. That makes ordinary -Junior-owned work appear to have been performed by a human and turns every -multi-actor run into a credential-delegation decision. - -Junior should act as itself when it owns the resulting resource. Human identity -belongs in explicit attribution metadata, not in the provider credential used -to execute the operation. This matches the existing scheduler split between a -system execution actor and creator audit metadata and reduces the confused -deputy surface tracked in #773. - -The cutover must not create an ambient installation write token. A credential -issued for an approved issue, pull request, or branch operation must not become -usable for arbitrary REST mutations, GraphQL mutations, pushes, or repository -administration. - -## Scope - -- Define service-principal identity as the default execution identity for - Junior-owned provider resources. -- Make Junior the creator and lifecycle actor for GitHub issues and pull - requests within an explicit operation allowlist. -- Add operation-scoped installation grants for issue management, pull request - management, and repository-scoped Git smart-HTTP pushes. -- Allow headless system turns, including subscribed resource-event deliveries, - to use those scoped installation grants without borrowing a human OAuth - credential. -- Bind bot write leases to the classified operation and target repository and - request only the minimum GitHub App permissions required by that grant. -- Keep raw or unclassified GitHub writes off bot credentials. -- Keep operations that inherently express human judgment, including reviews, - approvals, and change requests, on explicit delegated user identity. -- Keep merge, repository administration, and non-Git write surfaces outside the - initial bot-owned allowlist. -- Make Junior the Git author and committer for Junior-managed commits and credit - every resolvable human run actor with `Co-Authored-By` trailers. -- Add runtime-owned requester attribution to issues and pull requests without - fabricating GitHub identities. -- Update identity, credential, GitHub setup, and behavior documentation. - -## Non-Goals - -- Giving the GitHub App a generic or provider-wide write grant. -- Automatically falling back from a denied bot operation to a human OAuth - token. -- Treating run actor membership as provider authorization. -- Performing reviews, approvals, or change requests as Junior. -- Enabling merges, REST ref administration, workflow administration, or - repository administration through this change. -- Providing an independent branch-ownership, force-push, or ref-deletion policy beyond the - target repository scope and GitHub branch protections. -- Solving provider-resource ownership for every plugin in the first - implementation. - -## Design Notes - -Typed issue and pull request creation already carry trusted operation names and -are the first safe cutover. Additional lifecycle routes must be explicitly -classified before receiving installation write credentials. - -Git smart HTTP exposes a distinct `git-receive-pack` operation and target -repository. Junior classifies that surface into a repository-scoped -installation grant. This intentionally does not add a second branch-level -policy engine; operators use GitHub branch protection and installation scope -for branch, force-update, and ref-deletion controls. - -Related work: #773, #777, #778, #780, and #789. diff --git a/openspec/changes/github-bot-resource-ownership/specs/github-resource-identity/spec.md b/openspec/changes/github-bot-resource-ownership/specs/github-resource-identity/spec.md deleted file mode 100644 index 91b9ef828..000000000 --- a/openspec/changes/github-bot-resource-ownership/specs/github-resource-identity/spec.md +++ /dev/null @@ -1,209 +0,0 @@ -# GitHub Resource Identity - -## ADDED Requirements - -### Requirement: Junior-Owned Resources Use Junior Identity - -GitHub resources that Junior creates and operates as part of an approved -Junior-owned workflow SHALL use the GitHub App installation identity rather -than a requesting or delegated user's OAuth identity. - -#### Scenario: Typed issue creation - -- **WHEN** Junior executes the typed GitHub issue creation operation -- **THEN** the issue is created with a GitHub App installation credential -- **AND** GitHub records Junior's App identity as the creator -- **AND** no user OAuth token is requested or injected. - -#### Scenario: Typed pull request creation - -- **WHEN** Junior executes the typed GitHub pull request creation operation -- **THEN** the pull request is created with a GitHub App installation - credential -- **AND** GitHub records Junior's App identity as the creator -- **AND** no user OAuth token is requested or injected. - -### Requirement: Bot Writes Are Explicitly Allowlisted - -Junior SHALL issue installation write credentials only for explicitly -classified GitHub operation families. A generic installation write grant SHALL -NOT exist. - -#### Scenario: Approved issue lifecycle operation - -- **WHEN** a request unambiguously matches an allowlisted issue create, update, - comment, label, assignment, close, or reopen operation -- **THEN** Junior may select the scoped issue installation grant -- **AND** the grant authorizes only the approved issue operation family. - -#### Scenario: Approved pull request lifecycle operation - -- **WHEN** a request unambiguously matches an allowlisted pull request create, - PR-native metadata update, ready-for-review, requested-reviewer, or close - operation -- **THEN** Junior may select the scoped pull request installation grant -- **AND** the grant does not authorize reviews, approvals, merge, branch - deletion, or repository administration. - -#### Scenario: Issue-backed pull request lifecycle operation - -- **WHEN** a pull request comment, label, or assignee mutation uses GitHub's - issue endpoint -- **THEN** Junior selects the scoped issue installation grant -- **AND** the operation remains attributed to Junior's installation identity. - -#### Scenario: Unknown GitHub write - -- **WHEN** a REST write, GraphQL mutation, or Git smart HTTP write cannot be - proven to match an approved bot-owned operation -- **THEN** Junior denies the request before credential issuance -- **AND** Junior does not fall back to a generic bot token or user OAuth token. - -### Requirement: Bot Write Leases Are Resource Scoped - -Every GitHub installation write lease SHALL be isolated by operation family and -target repository and SHALL request only the GitHub App permissions required by -that operation. - -#### Scenario: Issue token issuance - -- **WHEN** Junior issues an installation credential for an approved issue - operation in `owner/repo` -- **THEN** the token is restricted to `owner/repo` -- **AND** it requests issue write permission plus only provider-required read - permissions -- **AND** its cache identity cannot collide with a pull request or another - repository's lease. - -#### Scenario: Pull request token issuance - -- **WHEN** Junior issues an installation credential for an approved pull - request operation in `owner/repo` -- **THEN** the token is restricted to `owner/repo` -- **AND** it requests pull request write permission plus only permissions - required by the approved operation -- **AND** its cache identity cannot collide with an issue or another - repository's lease. - -#### Scenario: Provider rejects scoped credential - -- **WHEN** GitHub rejects a scoped installation write credential with `403` -- **THEN** Junior reports a permission denial for the selected scoped grant -- **AND** it does not retry under a human credential. - -### Requirement: Human-Identity Operations Remain Explicit - -GitHub operations whose provider meaning inherently represents personal human -judgment or user-private access SHALL use an explicitly delegated human -credential and SHALL NOT use Junior's installation write grants. - -#### Scenario: Pull request review - -- **WHEN** Junior is asked to approve, request changes on, or submit a human - review for a pull request -- **THEN** the operation requires an explicit human credential subject -- **AND** the resulting review is not attributed to Junior's installation - identity. - -#### Scenario: Human credential unavailable - -- **WHEN** a human-identity operation lacks a valid delegated credential -- **THEN** Junior follows the existing private authorization flow or reports - that authorization is required -- **AND** it does not substitute the installation identity. - -### Requirement: High-Impact Operations Are Separately Gated - -Bot ownership of issues and pull requests SHALL NOT implicitly authorize merge, -REST ref mutation, workflow administration, repository administration, or -other high-impact API operations. - -#### Scenario: Pull request merge - -- **WHEN** a request attempts to merge a pull request -- **THEN** the issue and pull request installation grants do not authorize it -- **AND** Junior requires a separately specified policy and grant. - -### Requirement: Git Pushes Use A Repository-Scoped Installation Grant - -Junior SHALL classify Git smart-HTTP `git-receive-pack` traffic into a distinct -installation grant scoped to the target repository. The grant SHALL NOT -authorize REST contents writes, merge, or unknown GitHub API mutations. - -#### Scenario: Git smart-HTTP push - -- **WHEN** Junior sends `git-receive-pack` traffic to a GitHub repository -- **THEN** Junior issues an `installation-pr-branch-write` credential scoped to - that repository -- **AND** a lease for another repository cannot be reused. - -#### Scenario: Branch-level policy limitation - -- **WHEN** the repository-scoped credential is used for Git smart HTTP -- **THEN** Junior does not claim to independently distinguish branch ownership, - force updates, or ref deletion -- **AND** operator guidance requires GitHub branch protection, rulesets, and - constrained App installation scope for those controls. - -#### Scenario: Workflow-changing push - -- **WHEN** the configured App permission envelope includes `workflows: write` -- **THEN** Junior includes that permission in the repository-scoped branch - token so workflow-file pushes can succeed -- **AND** otherwise requests only `contents: write` and `metadata: read`. - -#### Scenario: Headless subscribed pull request follow-up - -- **WHEN** a subscribed pull request event starts a headless resource-event - turn and Junior needs to commit and push a fix -- **THEN** the turn uses the `resource-event` system actor without a user - credential subject -- **AND** Git smart-HTTP push receives the repository-scoped - `installation-pr-branch-write` credential -- **AND** timeout or yield continuation preserves the `resource-event` system - actor without requiring a Slack user actor -- **AND** human reviews or other user-owned operations still require explicit - delegated authorization. - -### Requirement: Human Attribution Is Explicit Metadata - -Junior-owned GitHub resources SHALL credit human contributors through -runtime-owned attribution metadata that does not affect credential selection. - -#### Scenario: Junior-managed commit - -- **WHEN** Junior creates a commit for a Junior-managed pull request branch -- **THEN** the configured Junior bot identity is the Git author and committer -- **AND** every resolvable human run actor, including the primary actor, appears - once in a `Co-Authored-By` trailer -- **AND** system actors and incomplete human profiles are not fabricated into - trailers. - -#### Scenario: User-requested issue or pull request - -- **WHEN** a verified human actor requests issue or pull request creation -- **THEN** Junior adds runtime-owned `Requested by` attribution to the body -- **AND** the existing conversation footer remains deterministic -- **AND** the attribution does not invent a GitHub handle or become an - authorization input. - -#### Scenario: System-originated resource - -- **WHEN** a system actor creates a GitHub resource without explicit creator - audit metadata at the tool boundary -- **THEN** Junior attributes the system actor only when that actor is present -- **AND** it does not infer a human requester from prior messages or durable - object ownership. - -### Requirement: Service Principal Preference Is Documented - -Junior's canonical identity and credential specifications SHALL state that -service-principal identity is preferred for Junior-owned provider operations -and delegated user credentials are explicit exceptions. - -#### Scenario: Identity documentation - -- **WHEN** maintainers inspect the identity and credential specifications -- **THEN** they can determine when Junior acts as itself -- **AND** they can distinguish execution identity from actor, creator, - requester, author, and credential-subject attribution. diff --git a/openspec/changes/github-bot-resource-ownership/tasks.md b/openspec/changes/github-bot-resource-ownership/tasks.md deleted file mode 100644 index 539f2f658..000000000 --- a/openspec/changes/github-bot-resource-ownership/tasks.md +++ /dev/null @@ -1,112 +0,0 @@ -# Tasks - -## 1. Credential Grant Scope - -- [x] Extend the plugin grant contract with a small provider-neutral lease scope - used for credential-cache isolation. -- [x] Include the lease scope in sandbox egress cache lookup, persistence, - clearing, and validation without changing user-visible grant names. -- [x] Keep the lease scope opaque to the host so provider plugins can carry a - validated resource target into credential issuance without leaking - provider request types. -- [x] Add component coverage proving leases cannot be reused across operation - families, repositories, actors, sandbox contexts, or expired contexts. - -## 2. GitHub Installation Write Credentials - -- [x] Add explicit `installation-issues-write` and - `installation-pull-requests-write` GitHub grants. -- [x] Parse and canonicalize the target repository inside the GitHub provider - boundary. -- [x] Mint repository-scoped installation tokens with the minimum permissions - required by the selected grant. -- [x] Keep missing installation configuration and unsupported App permissions - as unavailable or permission-denied outcomes without OAuth fallback. -- [x] Add focused GitHub plugin tests for permission bodies, repository scope, - cache scope, and provider rejection diagnostics. - -## 3. Issue And Pull Request Ownership - -- [x] Move typed issue creation from `user-write` to the scoped issue - installation grant. -- [x] Move typed pull request creation from `user-write` to the scoped pull - request installation grant. -- [x] Add explicit REST classifications for the approved issue lifecycle - operations and deny unsupported issue mutations. -- [x] Add explicit REST classifications for the approved pull request lifecycle - operations and deny reviews, approvals, merge, deletion, and - administration. -- [x] Keep unknown REST writes, GraphQL mutations, oversized GraphQL bodies, and - generic non-read fallbacks denied rather than delegated automatically. -- [x] Add sandbox egress integration coverage for typed creation, allowlisted - lifecycle writes, denied mutations, and no user-OAuth fallback. - -## 4. Resource Attribution - -- [x] Add a GitHub-owned attribution helper that formats verified runtime actors - without inventing provider handles. -- [x] Append deterministic `Requested by` attribution to typed issue bodies. -- [x] Append deterministic `Requested by` attribution to typed pull request - bodies. -- [x] Make Junior the Git author and committer for Junior-managed commits. -- [x] Include every resolvable human run actor, including the primary actor, - exactly once as `Co-Authored-By`. -- [x] Add focused tool and Git-hook tests for interactive, multi-actor, missing - profile, duplicate actor, and system-actor attribution. - -## 5. Repository-Scoped Branch Pushes - -- [x] Select `installation-pr-branch-write` only for Git smart-HTTP - `git-receive-pack` traffic. -- [x] Bind the branch-write lease to the canonical target repository. -- [x] Request `contents: write` and include `workflows: write` only when it is - present in the configured App permission envelope. -- [x] Keep REST contents writes, merges, and unknown write surfaces denied. -- [x] Document that branch ownership, force-update, and ref-deletion controls rely on GitHub - branch protection, rulesets, and installation scope. -- [x] Add focused classification and credential permission-body coverage. -- [x] Add host egress coverage proving a headless resource-event system actor - can receive the scoped branch-write credential without a user subject. -- [x] Preserve the resource-event system actor across Slack continuation slices - and cover resumed headless turns at the integration boundary. - -## 6. Human-Identity Exceptions - -- [x] Enumerate the remaining GitHub operations that genuinely require human - identity, beginning with reviews, approvals, change requests, and - user-private reads. -- [x] Restrict `user-write` selection to those enumerated operations. -- [x] Preserve private OAuth authorization only for explicit human-identity - operations. -- [x] Add integration coverage proving bot-owned operations do not prompt for - OAuth and human-owned operations do not use installation credentials. - -## 7. Specifications And Documentation - -- [x] Update chat ownership documentation with service-principal preference for - Junior-owned provider operations. -- [x] Update credential documentation with scoped installation write - grants, cache isolation, request-aware issuance, and failure behavior. -- [x] Update GitHub setup and public plugin documentation with required App - permissions and the bot-owned operation allowlist. -- [x] Update GitHub skills so identity, confirmation, and denied-operation - guidance matches the runtime policy. -- [x] Cross-reference #780 and preserve the distinction between execution - identity and requester/contributor attribution. - -## 8. Validation - -- [x] Run focused `@sentry/junior-github` tests for grant classification, - credential issuance, typed tools, and Git attribution. -- [x] Run a focused resource-event eval proving the agent fixes, commits, and - pushes a watched pull request branch against an isolated local Git - remote without requesting human authorization; deterministic integration - tests separately prove bot identity and installation credential issuance. -- [x] Run focused `@sentry/junior` integration tests for sandbox egress lease - scope, provider denial, and push intent. -- [x] Run package typechecks and the repository typecheck gate. -- [ ] Run local-agent validation for issue creation and pull request creation - using `pnpm cli -- chat ...`. -- [ ] Verify the created GitHub issue and pull request show Junior as creator, - include requester attribution, and do not require user OAuth. -- [ ] Verify a human review still requires delegated human identity. diff --git a/packages/docs/src/content/docs/extend/github-plugin.md b/packages/docs/src/content/docs/extend/github-plugin.md index 72e0c4bb7..353c633b2 100644 --- a/packages/docs/src/content/docs/extend/github-plugin.md +++ b/packages/docs/src/content/docs/extend/github-plugin.md @@ -32,22 +32,15 @@ import { githubPlugin } from "@sentry/junior-github"; export const plugins = defineJuniorPlugins([ githubPlugin({ - appPermissions: { - actions: "write", - checks: "read", - contents: "write", - issues: "write", - metadata: "read", - pull_requests: "write", - workflows: "write", - }, botNameEnv: "GITHUB_APP_BOT_NAME", botEmailEnv: "GITHUB_APP_BOT_EMAIL", }), ]); ``` -`appPermissions` should match the permissions configured on the GitHub App. Junior requests read-level installation tokens for read traffic and repository-scoped tokens with the configured App permission envelope for supported workflow dispatch, issue, pull request, and branch writes. Unsupported writes are still denied by the egress policy instead of borrowing a user token. +Junior requests read-level installation tokens for read traffic. Allowlisted writes receive a repository-scoped token with the complete permission envelope approved on the GitHub App installation, so a branch push and the following pull request operation cannot receive mismatched permissions. Unsupported writes are still denied by the egress policy instead of borrowing a user token. + +You can optionally declare `appPermissions` when registering the plugin. Junior uses that declaration to advertise capabilities and avoid an installation lookup when downscoping read tokens; it does not downscope write tokens. Keep the declaration aligned with the permissions approved in the GitHub App settings. ## Configure environment variables @@ -154,10 +147,10 @@ Then confirm: 1. The issue is created in the expected repository. 2. The author is the GitHub App bot, and the body includes `Requested by` attribution for the verified runtime actor. -3. A follow-up GitHub request can update or comment on the same issue without asking the user to handle tokens manually after authorization. +3. A follow-up GitHub request can update or comment on the same issue without asking the user to authorize GitHub or handle tokens manually. 4. A pushed branch can be turned into a draft PR with `github_createPullRequest` using explicit `repo`, `head`, and `base` values. -For code changes, a local `git commit` does not call GitHub. The GitHub write happens when Junior pushes the branch. That repository-scoped installation token requires `Contents: write`; configure `Workflows: write` when Junior may change files under `.github/workflows`. Creating the PR after the branch exists is a separate pull-request write operation. +For code changes, a local `git commit` does not call GitHub. The GitHub write happens when Junior pushes the branch. The App installation requires `Contents: write`; grant it `Workflows: write` when Junior may change files under `.github/workflows`. Creating the PR after the branch exists is a separate pull-request write operation, but it uses the same repository-scoped write credential. To verify PR event watches, create a PR through Junior in Slack and ask Junior to keep an eye on CI, review changes, or merge state. Trigger one configured GitHub webhook event, then confirm GitHub reports a successful delivery to `/api/webhooks/github` and Junior handles the event in the original Slack conversation according to the watch intent. @@ -165,15 +158,14 @@ To verify PR event watches, create a PR through Junior in Slack and ask Junior t - Junior mints GitHub App installation and user-to-server tokens on the host, not in the sandbox. - When the GitHub skill runs authenticated `gh` or `git` commands, sandbox traffic to `api.github.com` and `github.com` is forwarded through Junior for host-side auth. -- App-readable requests use installation tokens downscoped to read. Allowlisted workflow dispatch, issue, pull request, and branch writes use repository-scoped installation tokens carrying the configured App permission envelope. GitHub account identity checks and human review operations use user-to-server tokens. +- App-readable requests use installation tokens downscoped to read. Allowlisted workflow dispatch, issue, pull request, and branch writes use repository-scoped installation tokens carrying the complete installed App permission envelope. GitHub account identity checks and human review operations use user-to-server tokens. - GitHub App user-to-server tokens do not use OAuth scopes as their permission model. Their effective access comes from the App permissions, installation scope, and requesting user's access. - The GitHub App installation determines which repositories are reachable, and repository write grants narrow issued tokens to the parsed target repository. - The host-side lease is bounded by the sandbox session and token expiry. It is not exposed as reusable long-lived auth inside the sandbox. - GitHub webhooks are accepted only when the `X-Hub-Signature-256` header matches `GITHUB_WEBHOOK_SECRET`. - Resource event subscriptions are conversation-scoped. Core owns subscription records, dedupe, TTL, and mailbox delivery; GitHub webhook handling only verifies and normalizes provider events. - Resource-event turns do not inherit a subscriber's user credential. Bot-owned issue, pull request, and smart-HTTP push operations use scoped installation credentials; human-owned operations still enter the normal authorization flow. -- Capability scoping is mainly an accident-prevention layer: it keeps routine issue, contents, and pull-request workflows from minting broader write access than they need. -- It is not a full containment boundary. The agent can still request broader GitHub capabilities when a task genuinely needs them, so operators should treat GitHub App installation scope as the real trust boundary. +- The write boundary is the App installation scope, the single-repository token scope, and Junior's endpoint allowlist. `appPermissions` declarations do not narrow write tokens. ## Failure modes @@ -184,6 +176,7 @@ To verify PR event watches, create a PR through Junior in Slack and ask Junior t - GitHub webhook delivery returns `202 Ignored`: the delivery was signed correctly but does not map to a supported PR watch event. Use one of the configured event types above. - GitHub delivery succeeds but no Slack follow-up appears: confirm the original conversation has an active resource event subscription for that PR and event type. A successful webhook alone does not create a subscription. - Missing repository context: Junior could not determine which repository to use. Include `owner/repo` directly in the GitHub request, or configure a default GitHub repository for that thread, and retry. +- A `403` response that says to use `github_createIssue` or `github_createPullRequest` is a Junior routing denial, not evidence of missing App permissions. Retry with the named tool. - Private OAuth prompt for a human-identity operation such as a pull request review: the actor has not authorized the GitHub App yet, or the stored user-to-server token expired. Complete the private authorization prompt; do not paste personal access tokens into the chat or sandbox. - Permission-style failures during issue or pull request workflows: the GitHub App lacks the required permission or installation scope. Update the app permissions or install target, then retry. - Fork creation is outside the write allowlist. Routine PR creation should push a branch explicitly and use `github_createPullRequest` instead of creating a fork. diff --git a/packages/junior-evals/evals/github/skill-workflows.eval.ts b/packages/junior-evals/evals/github/skill-workflows.eval.ts index a09b3e105..0d44035af 100644 --- a/packages/junior-evals/evals/github/skill-workflows.eval.ts +++ b/packages/junior-evals/evals/github/skill-workflows.eval.ts @@ -51,7 +51,7 @@ describeEval("GitHub Skill Workflows", slackEvals, (it) => { expect(verifyCall).toMatchObject({ result: { ok: true } }); }); - it("when asked about PR auth sequencing, mention push auth before PR auth", async ({ + it("when asked about PR auth sequencing, explain automatic installation credentials", async ({ run, }) => { await run({ @@ -66,12 +66,13 @@ describeEval("GitHub Skill Workflows", slackEvals, (it) => { ], criteria: rubric({ pass: [ - "The answer explicitly says the branch push happens before `gh pr create` for the PR step.", - "The answer says the push step needs GitHub write access for the remote.", + "The answer says the branch is pushed before the pull request is created.", + "The answer explains that Junior automatically injects the GitHub App credential for the standard push and pull request workflow, with no user-managed token or authorization step.", ], fail: [ - "Do not imply that PR creation auth alone is sufficient before the push.", - "Do not omit the explicit push-auth step.", + "Do not tell the user to grant Pull requests: write, authorize GitHub, provide a token, or take another authentication action for this standard bot workflow.", + "Do not recommend `gh pr create` for new pull requests.", + "Do not imply that pull request creation credentials alone are sufficient before the push.", ], }), }); diff --git a/packages/junior-github/SETUP.md b/packages/junior-github/SETUP.md index 32b47a60d..e9e519aee 100644 --- a/packages/junior-github/SETUP.md +++ b/packages/junior-github/SETUP.md @@ -64,7 +64,7 @@ vercel env update GITHUB_APP_PRIVATE_KEY production --sensitive < ./github-app-p Repeat for `preview` and `development` as needed. After env changes, redeploy so the new deployment picks up updated values. -### Optional permission overrides +### Optional permission declaration By default, `installation-read` grants read the app installation's current permission envelope once per process and scopes read-capable permissions down to `read` when requesting a token. To declare the GitHub App permission envelope in the plugin and avoid that installation lookup, pass `appPermissions` when registering the plugin: @@ -81,11 +81,11 @@ githubPlugin({ }); ``` -Junior records these permissions as plugin capabilities. The configured values are the maximum GitHub App envelope Junior may need for writes. Installation-read token requests remain read-only by requesting read-capable configured permissions at `read` level and omitting GitHub permission fields that have no `read` value. GitHub remains the source of truth for whether a permission name or level exists. +Junior records these permissions as plugin capabilities. Installation-read token requests remain read-only by requesting read-capable configured permissions at `read` level and omitting GitHub permission fields that have no `read` value. Installation-write token requests intentionally omit the `permissions` field, so GitHub applies the complete permission envelope approved on the App installation. GitHub remains the source of truth for whether a permission name or level exists. GitHub App user-to-server tokens do not use OAuth scopes as their permission model. Their effective access is limited by the GitHub App's installed permissions, the app installation's repository access, and the requesting user's own GitHub access. Repository-scoped installation tokens instead use the App permission envelope and installation repository access without borrowing the requesting user's authority. GitHub returns an empty `scope` value for user-to-server tokens, so Junior cannot verify granted scopes from the token response. -If you pass `additionalUserScopes`, Junior includes those values in the authorization URL and records the requested scope string as a local reauthorization contract. This does not expand or prove GitHub API permissions — configure GitHub App permissions with `appPermissions` and in the GitHub App settings for provider-enforced access: +If you pass `additionalUserScopes`, Junior includes those values in the authorization URL and records the requested scope string as a local reauthorization contract. This does not expand or prove GitHub API permissions. Configure provider-enforced access in the GitHub App settings; `appPermissions` only declares capabilities and controls read-token downscoping: ```ts githubPlugin({ @@ -93,7 +93,7 @@ githubPlugin({ }); ``` -Use `additionalUserScopes` only when a human-identity integration flow requires specific GitHub OAuth scope parameters in the authorization URL. Do not rely on it to authorize Junior-owned repository or workflow writes — those use repository-scoped installation tokens and the configured GitHub App permissions. +Use `additionalUserScopes` only when a human-identity integration flow requires specific GitHub OAuth scope parameters in the authorization URL. Do not rely on it to authorize Junior-owned repository or workflow writes — those use repository-scoped installation tokens and the permissions approved on the GitHub App installation. ## 3) Runtime behavior @@ -128,12 +128,12 @@ new resources through the typed tools, for example Use `gh` for the allowlisted lifecycle operations described by the skill. `gh` supports either direct `GITHUB_TOKEN` (for local debugging) or sandbox-level header injection. -The plugin uses installation credentials for read-only GitHub traffic, workflow dispatches, allowlisted issue and pull request mutations, and Git smart-HTTP pushes. GitHub App permissions still need to cover the operation: actions for workflow dispatches, issues for issue edits/comments/labels, contents for pushes, pull requests for PR mutations, and workflows for workflow-file pushes. +The plugin uses installation credentials for read-only GitHub traffic, workflow dispatches, allowlisted issue and pull request mutations, and Git smart-HTTP pushes. The installed GitHub App permissions still need to cover the operation: actions for workflow dispatches, issues for issue edits/comments/labels, contents for pushes, pull requests for PR mutations, and workflows for workflow-file pushes. Committing and pushing code uses more than one GitHub surface: - Creating the local Git commit does not call GitHub. Junior sets the GitHub App bot as author and committer and credits resolvable human actors with `Co-Authored-By` trailers. -- Pushing a branch with Git smart HTTP (`git push`) uses the repository-scoped `installation-write` grant and requires `Contents: write`. If `Workflows: write` is configured, it is included so workflow-file changes can be pushed. +- Pushing a branch with Git smart HTTP (`git push`) uses the repository-scoped `installation-write` grant and requires the App installation to have `Contents: write`. Workflow-file changes also require the installation to have `Workflows: write`. - The smart-HTTP classifier does not distinguish Junior-managed branches or independently detect force updates or ref deletion. Use GitHub branch protection and limit the App installation to repositories where Junior may push. - REST Git database and ref writes are denied by the current write allowlist. Use Git smart HTTP (`git push`) for branch updates instead. - Opening the PR after the branch exists is separate: `github_createPullRequest` needs pull-request write permission, but it should not create or push commits itself. @@ -144,10 +144,10 @@ Repository scoping and the egress allowlist are the write boundaries. Credential Be careful with mixed-surface PR commands. Use the allowlisted REST endpoints rather than GraphQL-backed `gh pr` mutation commands. PR-native title, body, -base, state, ready-for-review, and requested-reviewer changes use the pull -request grant. Comments, labels, and assignees use GitHub's issue endpoints and -the issue grant. Merge, branch deletion, REST ref mutation, and administration -remain unsupported API operations. +base, state, ready-for-review, and requested-reviewer changes use pull request +endpoints. Comments, labels, and assignees use GitHub's issue endpoints. All +allowlisted bot writes share `installation-write`; merge, branch deletion, REST +ref mutation, and administration remain unsupported API operations. For PR creation in automation, push explicitly and pass that branch as `head`: diff --git a/packages/junior-github/skills/github-code/SKILL.md b/packages/junior-github/skills/github-code/SKILL.md index a1981bdc7..074c5b474 100644 --- a/packages/junior-github/skills/github-code/SKILL.md +++ b/packages/junior-github/skills/github-code/SKILL.md @@ -1,11 +1,18 @@ --- name: github-code -description: Work with GitHub repositories, source code, branches, commits, pushes, pull requests, reviews, diffs, and CI runs. Use for repo implementation tasks, source-code investigation, cloning/fetching, branch and push workflows, PR creation, PR updates, PR review feedback, and GitHub Actions checks. Prefer this skill for repository and code tasks even when the repo concerns Sentry products. +description: Work with GitHub repositories, source code, branches, commits, pushes, pull requests, reviews, diffs, CI runs, and GitHub App credentials for branch and PR workflows. Use for repo implementation tasks, source-code investigation, cloning/fetching, branch and push workflows, PR creation or updates, PR review feedback, GitHub credential or permission failures involving repositories, pushes, or PRs, and GitHub Actions checks. Prefer this skill for repository and code tasks even when the repo concerns Sentry products. --- # GitHub Code Operations -Use `gh` and `git` for repository checkout, source investigation, code changes, commits, and pull request inspection or mutation. Use Junior's `github_createPullRequest` tool for new pull requests. +## Credential contract + +- Supported App-owned GitHub operations require no user-managed credential. Junior automatically mints and injects a repository-scoped GitHub App `installation-write` credential. +- For a pull request from an existing branch, push the branch first, then call `github_createPullRequest`; both operations receive the automatic installation credential. +- Do not ask the user to authorize GitHub, provide a token, or grant Contents/Pull requests permissions before trying a supported operation. +- Request remediation only after a supported operation returns evidence of an upstream provider denial. A Junior tool-routing denial means retry with the required tool, not request permissions. + +Use `gh` and `git` for repository checkout, source investigation, code changes, commits, and pull request inspection or mutation. Use Junior's `github_createPullRequest` tool instead of `gh pr create` for new pull requests. ## References diff --git a/packages/junior-github/skills/github-code/references/api-surface.md b/packages/junior-github/skills/github-code/references/api-surface.md index 17c27e185..9550692bb 100644 --- a/packages/junior-github/skills/github-code/references/api-surface.md +++ b/packages/junior-github/skills/github-code/references/api-surface.md @@ -65,9 +65,9 @@ jr-rpc config set github.repo owner/repo - Prefer `--json` output for machine-readable parsing where available. - Pass extra `git clone` flags after `--` (e.g. `gh repo clone owner/repo -- --depth=1`). - A local `git commit` does not call GitHub. Pushing that commit uses Junior's repository-scoped installation credential and requires `github.contents.write` on the target repo. -- If the commit changes workflow files under `.github/workflows`, expect `github.workflows.write` in addition to contents write. +- If the commit changes workflow files under `.github/workflows`, the App installation needs Workflows write in addition to Contents write. - Before `github_createPullRequest`, push the head branch explicitly and resolve the target repo's default branch for `base`. That push requires GitHub write access to the remote. - Merge, fork creation, workflow reruns or cancellations, REST contents/Git database writes, and repository administration are outside the current write allowlist. - If the explicit `git push` fails with 401/403 or another access/permission error, verify the repo context and retry once. If it still fails, load troubleshooting guidance and report the exact command failure. -- PR comments, labels, and assignees use GitHub's issue endpoints and therefore the issue grant; use the `github-issues` REST guidance for those operations. +- PR comments, labels, and assignees use GitHub's issue endpoints; use the `github-issues` REST guidance for those operations. All allowlisted bot writes share the same repository-scoped `installation-write` credential. - Return actionable errors for access, permission, not-found, and validation failures. diff --git a/packages/junior-github/skills/github-code/references/troubleshooting-workarounds.md b/packages/junior-github/skills/github-code/references/troubleshooting-workarounds.md index 10ee9b1e0..f0310fe03 100644 --- a/packages/junior-github/skills/github-code/references/troubleshooting-workarounds.md +++ b/packages/junior-github/skills/github-code/references/troubleshooting-workarounds.md @@ -13,9 +13,10 @@ Use this table to recover quickly while keeping operations deterministic. | `junior-auth-required provider=github grant=user-write` | User-to-server OAuth is missing or stale for a human-identity operation. | Follow the private OAuth prompt; do not ask the user to paste or manage tokens manually. | | `git push` fails with 401/403 or auth/permission output | Write permission is missing, app installation is too narrow, or remote is wrong. | Verify the remote and repo context, retry once, then confirm app permissions and installation scope if it still fails. | | Bash result includes `permission_denied` with `source: "upstream"` | GitHub returned 403 after Junior injected the named grant. | Do not call this a Junior runtime block. Use the message, connected account, upstream target, grant requirements, accepted-permissions, and SSO fields to explain the GitHub denial. | -| 403 Forbidden | App lacks required permission on repo or install scope is too narrow. | Verify the repo context, then confirm GitHub App permissions and installation scope. | +| 403 without `permission_denied` where `source: "upstream"` | Junior may have rejected an unsupported route before contacting GitHub. | Read the response body. Follow any required-tool instruction; do not ask for GitHub permissions unless the failure is confirmed upstream. | | `gh auth status` shows `Token scopes: none` | Expected for GitHub App user-to-server tokens. | Do not treat this as read-only proof. Use the failed command, `permission_denied.acceptedPermissions`, and GitHub App permissions instead. | -| `github_createPullRequest` fails with auth/permission errors | The branch was not pushed first, or repo context is wrong. | Push the branch explicitly first, then retry `github_createPullRequest` with explicit `repo`, `head`, and `base` values. | +| `github_createPullRequest` returns upstream 401/403 | The App installation or target repository does not permit the operation. | Use the structured upstream denial to verify installation scope and accepted permissions; do not request user OAuth for this bot-owned operation. | +| `github_createPullRequest` returns 422 for `head` | The head branch was not pushed or the explicit head ref is wrong. | Push the branch, then retry with explicit `repo`, `head`, and `base` values. | | `github_createPullRequest` fails with 422 validation on `base` | The `base` branch does not exist in the target repo. | Resolve the default branch with `gh repo view owner/repo --json defaultBranchRef --jq .defaultBranchRef.name`, then retry with that value as `base`. | | `git blame`, long log history, or old commits are missing after clone | Repo was cloned shallow by design. | Deepen incrementally with `git -C DIRECTORY fetch --depth=N origin`, or `git -C DIRECTORY fetch --unshallow` when full history is required. | | `sandbox setup failed (dnf install gh failed ...)` | `gh` package not available from the plugin runtime dependency bootstrap. | Report the plugin runtime bootstrap failure; do not try to repair package installation from the skill workflow. | diff --git a/packages/junior-github/skills/github-issues/references/troubleshooting-workarounds.md b/packages/junior-github/skills/github-issues/references/troubleshooting-workarounds.md index e402b0d23..0106c6e95 100644 --- a/packages/junior-github/skills/github-issues/references/troubleshooting-workarounds.md +++ b/packages/junior-github/skills/github-issues/references/troubleshooting-workarounds.md @@ -2,18 +2,19 @@ Use this table to recover quickly while keeping operations deterministic. -| Symptom | Likely cause | Fix | -| ------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `unknown command "issue"` from `gh` | CLI version too old or wrong binary in the plugin runtime. | Verify `gh --version`; if it is unavailable or too old, report that the GitHub plugin runtime dependency is not available. | -| `Missing required option --repo` | Repo not passed and no default was resolved. | Resolve with `jr-rpc config get github.repo`; pass `--repo owner/repo` explicitly when missing. | -| Command affects or authenticates against the wrong repo | Stale `github.repo` default or authenticated command missing explicit repo. | Pass `--repo owner/repo` for the target repository, or update `github.repo` before retrying. | -| `GraphQL: Could not resolve to a Repository` | Repo slug is wrong or inaccessible. | Validate `owner/repo` and confirm app installation on target repository. | -| 401 Unauthorized | Issued GitHub credentials were rejected upstream. | Verify the target repo, then use the grant/auth signal to distinguish stale user OAuth from app installation or host env setup. | -| `junior-auth-required provider=github grant=user-write` | User-to-server OAuth is missing or stale for a human-identity operation. | Follow the private OAuth prompt; do not ask the user to paste or manage tokens manually. | -| 403 Forbidden | App lacks required permission on repo or install scope is too narrow. | Verify the repo context, then confirm GitHub App permissions and installation scope. | -| 404 Not Found | Issue number or repo is wrong. | Validate repo + issue ID with `gh issue view NUMBER --repo owner/repo`. | -| Issue label mutation fails | Wrong REST payload or wrong repo context. | Use the allowlisted issue labels endpoint with an explicit `owner/repo` path and valid JSON input. | -| Comment command fails with empty body | JSON input is missing a non-empty `body`. | Ensure the REST comment payload contains a non-empty `body` before retrying. | +| Symptom | Likely cause | Fix | +| ---------------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `unknown command "issue"` from `gh` | CLI version too old or wrong binary in the plugin runtime. | Verify `gh --version`; if it is unavailable or too old, report that the GitHub plugin runtime dependency is not available. | +| `Missing required option --repo` | Repo not passed and no default was resolved. | Resolve with `jr-rpc config get github.repo`; pass `--repo owner/repo` explicitly when missing. | +| Command affects or authenticates against the wrong repo | Stale `github.repo` default or authenticated command missing explicit repo. | Pass `--repo owner/repo` for the target repository, or update `github.repo` before retrying. | +| `GraphQL: Could not resolve to a Repository` | Repo slug is wrong or inaccessible. | Validate `owner/repo` and confirm app installation on target repository. | +| 401 Unauthorized | Issued GitHub credentials were rejected upstream. | Verify the target repo, then use the grant/auth signal to distinguish stale user OAuth from app installation or host env setup. | +| `junior-auth-required provider=github grant=user-write` | User-to-server OAuth is missing or stale for a human-identity operation. | Follow the private OAuth prompt; do not ask the user to paste or manage tokens manually. | +| 403 without `permission_denied` where `source: "upstream"` | Junior may have rejected an unsupported route before contacting GitHub. | Read the response body. Follow any required-tool instruction; do not ask for GitHub permissions unless the failure is confirmed upstream. | +| `permission_denied` with `source: "upstream"` | GitHub rejected the injected installation credential. | Verify the target, accepted permissions, and App installation scope; do not request user OAuth for a bot-owned issue operation. | +| 404 Not Found | Issue number or repo is wrong. | Validate repo + issue ID with `gh issue view NUMBER --repo owner/repo`. | +| Issue label mutation fails | Wrong REST payload or wrong repo context. | Use the allowlisted issue labels endpoint with an explicit `owner/repo` path and valid JSON input. | +| Comment command fails with empty body | JSON input is missing a non-empty `body`. | Ensure the REST comment payload contains a non-empty `body` before retrying. | ## Retry guidance diff --git a/packages/junior-github/src/index.ts b/packages/junior-github/src/index.ts index c38825b49..a1b85aaff 100644 --- a/packages/junior-github/src/index.ts +++ b/packages/junior-github/src/index.ts @@ -44,11 +44,12 @@ export interface GitHubPluginOptions { additionalUserScopes?: string[]; /** - * GitHub App installation permissions Junior should request for app tokens. + * GitHub App permissions Junior should expose as capabilities and downscope + * to read for installation-read tokens. * * Keys may use GitHub permission names with underscores or hyphens. Junior - * records these as plugin capabilities and requests read-only installation - * tokens by scoping read-capable permissions down to `read`. + * records these as plugin capabilities. Installation-write tokens inherit + * the App installation's complete permission envelope. * GitHub remains the source of truth for whether a permission exists. */ appPermissions?: GitHubAppPermissions; @@ -133,15 +134,31 @@ interface UserCredentialOptions { userScope?: string; } -interface InstallationCredentialOptions { +interface InstallationCredentialBaseOptions { appIdEnv: string; installationIdEnv: string; - loadPermissions?: LoadInstallationReadPermissions; - permissions?: GitHubAppPermissions; privateKeyEnv: string; - repositories?: string[]; } +type InstallationCredentialOptions = InstallationCredentialBaseOptions & + ( + | { + loadPermissions?: never; + permissions?: never; + repositories: string[]; + } + | { + loadPermissions?: never; + permissions: GitHubAppPermissions; + repositories?: never; + } + | { + loadPermissions: LoadInstallationReadPermissions; + permissions?: never; + repositories?: never; + } + ); + type LoadInstallationReadPermissions = (input: { appJwt: string; installationId: number; @@ -163,6 +180,8 @@ const USER_REFRESH_TIMEOUT_MS = 20_000; 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 CREATE_TOOL_ROUTING_GUIDANCE = + "This is a Junior tool-routing denial, not a GitHub permission failure. Do not ask the user for GitHub permissions; retry with the required Junior tool."; const USER_WRITE_REQUIREMENTS = [ "requesting GitHub user permission to perform this operation", ]; @@ -1089,19 +1108,22 @@ async function issueInstallationCredential( } const appJwt = createAppJwt(appId, options.privateKeyEnv); - const permissions = - options.permissions ?? - (await options.loadPermissions?.({ appJwt, installationId })); + const body = + "repositories" in options + ? { repositories: options.repositories } + : { + permissions: + "permissions" in options + ? options.permissions + : await options.loadPermissions({ appJwt, installationId }), + }; const accessTokenResponse = await githubRequest( "https://api.github.com", `/app/installations/${installationId}/access_tokens`, { method: "POST", token: appJwt, - body: { - ...(permissions ? { permissions } : {}), - ...(options.repositories ? { repositories: options.repositories } : {}), - }, + body, }, ); const parsedToken = parseInstallationTokenResponse(accessTokenResponse); @@ -1507,7 +1529,7 @@ function assertGitHubWriteAllowed(input: { ) ) { throw new EgressPolicyDenied( - "GitHub issue creation must use the github_createIssue tool so Junior can own idempotency and the conversation footer.", + `GitHub issue creation must use the github_createIssue tool so Junior can own idempotency and the conversation footer. ${CREATE_TOOL_ROUTING_GUIDANCE}`, ); } if ( @@ -1519,28 +1541,25 @@ function assertGitHubWriteAllowed(input: { ) ) { throw new EgressPolicyDenied( - "GitHub pull request creation must use the github_createPullRequest tool so Junior can own idempotency and the conversation footer.", + `GitHub pull request creation must use the github_createPullRequest tool so Junior can own idempotency and the conversation footer. ${CREATE_TOOL_ROUTING_GUIDANCE}`, ); } } -function grantRequirements(reason: GitHubGrantReason): string[] | undefined { - return reason === "github.user-write" ? USER_WRITE_REQUIREMENTS : undefined; -} - function grantForAccess( access: PluginGrantAccess, reason: GitHubGrantReason, name: GitHubGrantName, leaseScope?: string, ): GitHubGrant { - const requirements = grantRequirements(reason); return { name, access, ...(leaseScope ? { leaseScope } : {}), reason, - ...(requirements ? { requirements } : {}), + ...(reason === "github.user-write" + ? { requirements: USER_WRITE_REQUIREMENTS } + : {}), }; } @@ -1640,12 +1659,12 @@ export function githubPlugin( const privateKeyEnv = options.privateKeyEnv ?? GITHUB_APP_PRIVATE_KEY_ENV; const installationIdEnv = options.installationIdEnv ?? GITHUB_INSTALLATION_ID_ENV; - const appPermissions = normalizePermissions(options.appPermissions); - const appReadPermissions = appPermissions - ? readGrantPermissions(appPermissions) + const declaredAppPermissions = normalizePermissions(options.appPermissions); + const declaredReadPermissions = declaredAppPermissions + ? readGrantPermissions(declaredAppPermissions) : undefined; const loadReadPermissions = createPermissionCache(); - const appCapabilities = permissionCapabilities(appPermissions); + const appCapabilities = permissionCapabilities(declaredAppPermissions); const userScopes = normalizeScopeList(options.additionalUserScopes); const userScope = userScopes.length ? userScopes.join(" ") : undefined; @@ -1763,8 +1782,8 @@ export function githubPlugin( appIdEnv, privateKeyEnv, installationIdEnv, - ...(appReadPermissions - ? { permissions: appReadPermissions } + ...(declaredReadPermissions + ? { permissions: declaredReadPermissions } : { loadPermissions: loadReadPermissions }), }); } @@ -1776,14 +1795,8 @@ export function githubPlugin( appIdEnv, privateKeyEnv, installationIdEnv, - ...(appPermissions - ? { - permissions: { - ...appPermissions, - metadata: "read", - }, - } - : {}), + // This repository-only variant cannot downscope the installed + // App envelope with an operation-specific permission body. repositories: [repository.name], }); } diff --git a/packages/junior-github/src/permissions.ts b/packages/junior-github/src/permissions.ts index b40ddc29d..0ef2055e6 100644 --- a/packages/junior-github/src/permissions.ts +++ b/packages/junior-github/src/permissions.ts @@ -14,7 +14,7 @@ function normalizeScope(rawScope: string): string { return String(rawScope).trim().replace(/-/g, "_"); } -/** Validate configured GitHub App permissions before using them in grants. */ +/** Validate the configured GitHub App capability and read-token declaration. */ export function normalizePermissions( permissions: GitHubAppPermissions | undefined, ): GitHubAppPermissions | undefined { diff --git a/packages/junior-github/tests/github-plugin.test.ts b/packages/junior-github/tests/github-plugin.test.ts index cb4eeaab3..c092bd3cc 100644 --- a/packages/junior-github/tests/github-plugin.test.ts +++ b/packages/junior-github/tests/github-plugin.test.ts @@ -1595,7 +1595,7 @@ Conversation: \`local:test:old-conversation\` }); }); - it("issues repository-scoped installation credentials for GitHub resource writes", async () => { + it("preserves installed App permissions on repository-scoped write credentials", async () => { const privateKey = generateKeyPairSync("rsa", { modulusLength: 2048 }) .privateKey.export({ type: "pkcs8", format: "pem" }) .toString(); @@ -1605,92 +1605,7 @@ Conversation: \`local:test:old-conversation\` const requests = mockGitHubInstallationApi(); const plugin = githubPlugin({ appPermissions: { - actions: "write", contents: "write", - issues: "write", - pull_requests: "write", - workflows: "write", - }, - }); - - const actionsResult = await plugin.hooks?.issueCredential?.({ - actor: { platform: "system", name: "scheduler" }, - grant: { - name: "installation-write", - access: "write", - leaseScope: "repository:getsentry/junior", - reason: "github.installation-write", - }, - db, - log: pluginLog, - plugin: { name: "github" }, - tokens: {}, - }); - - const issueResult = await plugin.hooks?.issueCredential?.({ - actor: { platform: "system", name: "scheduler" }, - grant: { - name: "installation-write", - access: "write", - leaseScope: "repository:getsentry/junior", - reason: "github.installation-write", - }, - db, - log: pluginLog, - plugin: { name: "github" }, - tokens: {}, - }); - - const pullResult = await plugin.hooks?.issueCredential?.({ - actor: { platform: "system", name: "scheduler" }, - grant: { - name: "installation-write", - access: "write", - leaseScope: "repository:getsentry/junior", - reason: "github.installation-write", - }, - db, - log: pluginLog, - plugin: { name: "github" }, - tokens: {}, - }); - - expect(actionsResult?.type).toBe("lease"); - expect(issueResult?.type).toBe("lease"); - expect(pullResult?.type).toBe("lease"); - expect(requests).toHaveLength(3); - for (const request of requests) { - expect(request).toMatchObject({ - method: "POST", - body: { - permissions: { - actions: "write", - contents: "write", - issues: "write", - metadata: "read", - pull_requests: "write", - workflows: "write", - }, - repositories: ["junior"], - }, - }); - } - }); - - it("issues repository-scoped push credentials for headless resource events", async () => { - const privateKey = generateKeyPairSync("rsa", { modulusLength: 2048 }) - .privateKey.export({ type: "pkcs8", format: "pem" }) - .toString(); - process.env.GITHUB_APP_ID = "123"; - process.env.GITHUB_INSTALLATION_ID = "456"; - process.env.GITHUB_APP_PRIVATE_KEY = privateKey; - const requests = mockGitHubInstallationApi(); - const plugin = githubPlugin({ - appPermissions: { - contents: "write", - issues: "write", - pull_requests: "write", - workflows: "write", }, }); @@ -1710,18 +1625,13 @@ Conversation: \`local:test:old-conversation\` expect(result?.type).toBe("lease"); expect(requests).toHaveLength(1); - expect(requests[0]).toMatchObject({ + expect(requests[0]).toEqual({ + url: "https://api.github.com/app/installations/456/access_tokens", method: "POST", body: { - permissions: { - contents: "write", - issues: "write", - metadata: "read", - pull_requests: "write", - workflows: "write", - }, repositories: ["junior"], }, + headers: expect.any(Object), }); }); diff --git a/packages/junior/tests/integration/sandbox-egress-proxy.test.ts b/packages/junior/tests/integration/sandbox-egress-proxy.test.ts index 707aa44a7..e17254262 100644 --- a/packages/junior/tests/integration/sandbox-egress-proxy.test.ts +++ b/packages/junior/tests/integration/sandbox-egress-proxy.test.ts @@ -986,7 +986,6 @@ describe("sandbox egress proxy integration", () => { expect(upstreamFetch).toHaveBeenCalledTimes(1); expect(tokenRequests).toEqual([ { - permissions: { actions: "write", metadata: "read" }, repositories: ["junior"], }, ]); @@ -1154,7 +1153,7 @@ describe("sandbox egress proxy integration", () => { expect(response.status).toBe(403); await expect(response.json()).resolves.toEqual({ error: - "GitHub issue creation must use the github_createIssue tool so Junior can own idempotency and the conversation footer.", + "GitHub issue creation must use the github_createIssue tool so Junior can own idempotency and the conversation footer. This is a Junior tool-routing denial, not a GitHub permission failure. Do not ask the user for GitHub permissions; retry with the required Junior tool.", }); expect(upstreamFetch).not.toHaveBeenCalled(); await expect( @@ -1196,7 +1195,7 @@ describe("sandbox egress proxy integration", () => { expect(response.status).toBe(403); await expect(response.json()).resolves.toEqual({ error: - "GitHub issue creation must use the github_createIssue tool so Junior can own idempotency and the conversation footer.", + "GitHub issue creation must use the github_createIssue tool so Junior can own idempotency and the conversation footer. This is a Junior tool-routing denial, not a GitHub permission failure. Do not ask the user for GitHub permissions; retry with the required Junior tool.", }); expect(upstreamFetch).not.toHaveBeenCalled(); }); @@ -1242,7 +1241,7 @@ describe("sandbox egress proxy integration", () => { expect(response.status).toBe(403); await expect(response.json()).resolves.toEqual({ error: - "GitHub pull request creation must use the github_createPullRequest tool so Junior can own idempotency and the conversation footer.", + "GitHub pull request creation must use the github_createPullRequest tool so Junior can own idempotency and the conversation footer. This is a Junior tool-routing denial, not a GitHub permission failure. Do not ask the user for GitHub permissions; retry with the required Junior tool.", }); expect(upstreamFetch).not.toHaveBeenCalled(); await expect( @@ -1297,7 +1296,7 @@ describe("sandbox egress proxy integration", () => { expect(response.status).toBe(403); await expect(response.json()).resolves.toEqual({ error: - "GitHub pull request creation must use the github_createPullRequest tool so Junior can own idempotency and the conversation footer.", + "GitHub pull request creation must use the github_createPullRequest tool so Junior can own idempotency and the conversation footer. This is a Junior tool-routing denial, not a GitHub permission failure. Do not ask the user for GitHub permissions; retry with the required Junior tool.", }); expect(upstreamFetch).not.toHaveBeenCalled(); });