From 4f059eed4c6b2b6e3d64ccef5a391f0dd5d344df Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 13 Oct 2026 10:14:04 +0700 Subject: [PATCH 1/5] fix(ci): execute trusted claude mentions --- .github/workflows/claude.yml | 51 ++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..2437df482 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,51 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + pull_request_review: + types: [submitted] + workflow_dispatch: + +permissions: {} + +concurrency: + group: claude-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: false + +jobs: + claude: + if: | + github.event_name == 'workflow_dispatch' || + (github.event.sender.type != 'Bot' && ( + (contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) && contains(github.event.comment.body, '@claude')) || + (contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association) && contains(github.event.review.body, '@claude')) + )) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run Claude Code + uses: anthropics/claude-code-action@1d6de8cb0c237e7c15e9e1bdf973826ebae490cc # v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + base_branch: next + additional_permissions: | + actions: read + claude_args: | + --max-turns 30 + --allowedTools "Bash(git:*),Bash(gh:*),Bash(node:*),Bash(pnpm:*)" + # Manual dispatch validates authentication and GitHub access without changing files. + prompt: ${{ github.event_name == 'workflow_dispatch' && 'Read the repository name and default branch using gh api repos/ai-driven-dev/framework. Do not change files, commits, branches, issues, or comments. Report CLAUDE_GITHUB_OK with the repository name and default branch.' || '' }} From 2131dadeda194d181d3834f08d740875e9770bff Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 13 Oct 2026 10:16:24 +0700 Subject: [PATCH 2/5] fix(ci): prepare node and pnpm for claude tasks --- .github/workflows/claude.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 2437df482..797205603 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -37,6 +37,14 @@ jobs: with: persist-credentials: false + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + + - name: Enable pnpm + run: corepack enable + - name: Run Claude Code uses: anthropics/claude-code-action@1d6de8cb0c237e7c15e9e1bdf973826ebae490cc # v1 with: From 3fe4f771d046134a11dcc12600214681d9978404 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 13 Oct 2026 17:55:37 +0700 Subject: [PATCH 3/5] fix(ci): gate claude access before exposing credentials --- .github/workflows/claude.yml | 66 ++++++++++---- aidd_docs/memory/deployment.md | 3 +- docs/MAINTAINERS.md | 28 ++++++ scripts/__tests__/authorize-claude.test.js | 101 +++++++++++++++++++++ scripts/authorize-claude.cjs | 65 +++++++++++++ 5 files changed, 245 insertions(+), 18 deletions(-) create mode 100644 scripts/__tests__/authorize-claude.test.js create mode 100644 scripts/authorize-claude.cjs diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 797205603..17f32585f 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -11,32 +11,52 @@ on: permissions: {} -concurrency: - group: claude-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} - cancel-in-progress: false - jobs: - claude: + authorize: if: | - github.event_name == 'workflow_dispatch' || - (github.event.sender.type != 'Bot' && ( - (contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) && contains(github.event.comment.body, '@claude')) || - (contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association) && contains(github.event.review.body, '@claude')) - )) + github.event.sender.type == 'User' && ( + github.event_name == 'workflow_dispatch' || + contains(github.event.comment.body, '@claude') || + contains(github.event.review.body, '@claude') + ) && (github.event_name != 'workflow_dispatch' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: + contents: read + outputs: + allowed: ${{ steps.authorize.outputs.allowed }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Authorize actor and source repository + id: authorize + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/authorize-claude.cjs + + claude: + needs: authorize + if: needs.authorize.outputs.allowed == 'true' runs-on: ubuntu-latest timeout-minutes: 30 + # One subscription-backed run at a time; queue approved requests instead of replacing them. + concurrency: + group: claude-framework + queue: max + cancel-in-progress: false permissions: - contents: write - pull-requests: write - issues: write + contents: read id-token: write actions: read steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.repository.default_branch }} persist-credentials: false - - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -50,10 +70,22 @@ jobs: with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} base_branch: next + use_commit_signing: true + use_sticky_comment: true + include_comments_by_actor: ${{ github.actor }} + allowed_bots: "" + allowed_non_write_users: "" + show_full_output: false + settings: '{"disableAllHooks":true}' + # The App token is separate from GITHUB_TOKEN; explicitly limit its effective grant. additional_permissions: | + contents: ${{ github.event_name == 'workflow_dispatch' && 'read' || 'write' }} + pull_requests: ${{ github.event_name == 'workflow_dispatch' && 'read' || 'write' }} + issues: ${{ github.event_name == 'workflow_dispatch' && 'read' || 'write' }} actions: read claude_args: | - --max-turns 30 - --allowedTools "Bash(git:*),Bash(gh:*),Bash(node:*),Bash(pnpm:*)" + --max-turns 20 + --allowedTools "${{ github.event_name == 'workflow_dispatch' && 'Bash(gh repo view:*)' || 'Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(gh repo view:*),Bash(gh issue view:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh run view:*),Bash(pnpm install --frozen-lockfile --ignore-scripts),Bash(pnpm exec lefthook run pre-commit),Bash(pnpm exec vitest run:*)' }}" + --disallowedTools "${{ github.event_name == 'workflow_dispatch' && 'Edit,Write,Bash(pnpm:*),Bash(git:*),Bash(gh api:*),Bash(gh pr merge:*),Bash(gh pr review:*),Bash(node:*),Bash(curl:*),Bash(wget:*)' || 'Bash(gh api:*),Bash(gh pr merge:*),Bash(gh pr review:*),Bash(git push:*),Bash(git reset:*),Bash(git rebase:*),Bash(node:*),Bash(curl:*),Bash(wget:*)' }}" # Manual dispatch validates authentication and GitHub access without changing files. - prompt: ${{ github.event_name == 'workflow_dispatch' && 'Read the repository name and default branch using gh api repos/ai-driven-dev/framework. Do not change files, commits, branches, issues, or comments. Report CLAUDE_GITHUB_OK with the repository name and default branch.' || '' }} + prompt: ${{ github.event_name == 'workflow_dispatch' && 'Read the repository name and default branch using gh repo view ai-driven-dev/framework --json nameWithOwner,defaultBranchRef. Do not change files, commits, branches, issues, or comments. Report CLAUDE_GITHUB_OK with the repository name and default branch.' || '' }} diff --git a/aidd_docs/memory/deployment.md b/aidd_docs/memory/deployment.md index 901f9cdd9..fb868d150 100644 --- a/aidd_docs/memory/deployment.md +++ b/aidd_docs/memory/deployment.md @@ -17,6 +17,7 @@ Where the project runs and how it ships: CI/CD, environments, and release. | `dependabot-auto-merge.yml` | merges dependency PRs that pass | | `close-finished-milestones.yml` | closes a milestone once its issues are | | `star-history.yml` | refreshes the README star chart | +| `claude.yml` | trusted `@claude` comments and a manual read-only smoke test; authorization, subscription ownership, limits and operations in [Maintainers](../../docs/MAINTAINERS.md#claude-on-github) | ```mermaid flowchart LR @@ -27,7 +28,7 @@ flowchart LR Release --> Back["back-merge.yml"] ``` -Automatic on a push to `main`. Three workflows also accept a manual run: `promote.yml`, `close-finished-milestones.yml`, `star-history.yml`. +Automatic on a push to `main`. Four workflows also accept a manual run: `promote.yml`, `close-finished-milestones.yml`, `star-history.yml`, `claude.yml`. ## Environments diff --git a/docs/MAINTAINERS.md b/docs/MAINTAINERS.md index 982af57d2..4da94bcc7 100644 --- a/docs/MAINTAINERS.md +++ b/docs/MAINTAINERS.md @@ -22,6 +22,34 @@ How to operate this repository day to day. This file is the **Maintainer** playb - **Roadmap.** Priority = the community vote (mechanism in `GOVERNANCE.md`). Accepted items live on board #8 — keep `ROADMAP.md` a pointer, don't maintain a second list. - **Review PRs.** Approve as CODEOWNERS, then squash-merge (merge policy → [`GOVERNANCE.md`](../GOVERNANCE.md#-code-decisions)). +## Claude on GitHub + +Mention `@claude` in an issue comment, PR conversation, inline review comment, or submitted review. Include a bounded task and its expected verification, for example: `@claude fix the parser error described above and run the parser tests`. Review the raw issue/PR content before invoking the agent; community-authored text and imported code can contain prompt injection. + +The workflow checks current repository write access for both the original sender and any rerunner before the Claude job can access its secret. Membership or `author_association` alone grants nothing. Bots and contributors without write access cannot invoke it; they can ask a maintainer to relay a reviewed request. Rejected requests appear in the **Authorize actor and source repository** job log, without consuming Claude inference quota. + +The write-capable agent accepts issues and open PRs whose source is a feature branch in this repository. It refuses fork PRs, deleted sources, closed PRs, and PRs sourced from `main` or `next`. For an external contribution, review it first and, if appropriate, import the reviewed changes into a repository feature branch before asking Claude to edit them. Fork review with a secret-backed write-capable agent is deliberately unsupported. + +Authentication uses the installed official Claude GitHub App and the `CLAUDE_CODE_OAUTH_TOKEN` repository secret. The sponsor's personal Claude subscription pays for inference; this is not community API billing. Every authorized invocation consumes that subscription's quota. One Claude job runs at a time, with at most 20 turns and a 30-minute timeout. GitHub queues up to 100 approved jobs; beyond that, runs are cancelled. These controls limit bursts and individual runs, not total daily usage. + +The workflow checks out the trusted default branch before authorization; the official action subsequently selects the eligible PR head and restores its protected Claude configuration from the PR base. Repo-defined automatic Claude hooks are disabled. Approved shell commands cover read-only Git/GitHub inspection and specific pnpm installation/check commands; general `gh api`, arbitrary Node execution, merges, approvals, force pushes and history rewriting are not approved. Package-manager checks still execute repository code: inspect changes to scripts, plugins, dependencies and tool configuration before invoking Claude. Tool permissions and prompt sanitization cannot guarantee protection from every injection. + +The workflow token is read-only apart from the OIDC permission. Claude receives a separate short-lived, repository-scoped App token with `contents`, `pull_requests` and `issues` write access, plus `actions` read access. No workflow, administration or ruleset-bypass access is requested. Verified commits use GitHub API signing. Agent-generated changes still follow the maintainer review and CI requirements in [Governance](../GOVERNANCE.md#-code-decisions); do not grant Claude a branch-protection bypass. A new push requires reviewers to inspect the updated diff, even when an older approval remains visible. + +All direct actions are pinned to full commit SHAs. The selected Claude action also pins its Claude CLI version internally; Dependabot tracks action updates on `next`. Review the upstream action's security changes when updating its pin. + +### Verify and operate + +1. Integrate `.github/workflows/claude.yml` on the default branch (`main`); GitHub comment events and the App's workflow validation require it there. Maintainer approval remains required. +2. Run `gh workflow run claude.yml --repo ai-driven-dev/framework`. The smoke test requests a **read-only App token**, forbids local edits, and must report `CLAUDE_GITHUB_OK` with the repository and default branch in its run output. A successful local OAuth test alone does not validate GitHub Actions. +3. On a disposable repository feature PR, invoke a bounded change and verify the progress comment, verified commit, changed files and CI. Check that a fork request and an actor without write access stop in the authorization job. +4. To stop new requests, disable **Claude Code** in Actions or run `gh workflow disable claude.yml --repo ai-driven-dev/framework`; also cancel active and queued runs when necessary. Re-enable with `gh workflow enable claude.yml --repo ai-driven-dev/framework`. +5. The subscription token created on 2026-10-13 is valid for one year. Before expiry or when sponsorship changes, generate a replacement with `claude setup-token`, update the GitHub secret through `gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo ai-driven-dev/framework` using its stdin prompt, and rerun the smoke test. Keep any backup outside this repository and revoke obsolete credentials in the sponsor's Claude account. Never paste a token into a comment, workflow, PR body or command argument. + +The official action refuses rebases and force pushes; amending an already-published commit must be performed outside this action. + +Primary references: [Claude GitHub Actions setup](https://code.claude.com/docs/en/github-actions), [action security and trust model](https://github.com/anthropics/claude-code-action/blob/1d6de8cb0c237e7c15e9e1bdf973826ebae490cc/docs/security.md), [action limitations](https://github.com/anthropics/claude-code-action/blob/1d6de8cb0c237e7c15e9e1bdf973826ebae490cc/docs/faq.md#why-wont-claude-rebase-my-branch), [GitHub secure workflows](https://docs.github.com/en/actions/reference/security/secure-use), [GitHub concurrency](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency). + ## 📋 Four axes, one board Each axis answers one question, and only `Status` lives on the board. Routing (`next`/`main`) is not an axis — it derives from the branch prefix ([routing table](../aidd_docs/memory/vcs.md#branches)). diff --git a/scripts/__tests__/authorize-claude.test.js b/scripts/__tests__/authorize-claude.test.js new file mode 100644 index 000000000..b8dfb8450 --- /dev/null +++ b/scripts/__tests__/authorize-claude.test.js @@ -0,0 +1,101 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const fs = require("node:fs"); +const path = require("node:path"); +const yaml = require("js-yaml"); +const { authorize } = require("../authorize-claude.cjs"); + +const repository = "ai-driven-dev/framework"; +function context(overrides = {}) { + return { + repository, actor: "maintainer", triggeringActor: "maintainer", eventName: "issue_comment", + event: { repository: { full_name: repository }, sender: { login: "maintainer", type: "User" }, + issue: { number: 984, pull_request: {} }, comment: { body: "@claude fix the failing check", author_association: "MEMBER" } }, + request: async (url) => url.endsWith("/permission") ? { permission: "write" } : + { state: "open", head: { repo: { full_name: repository }, ref: "feat/example" } }, + ...overrides, + }; +} + +test("author association never grants write access", async () => { + for (const permission of ["read", "triage", "none", undefined]) { + const result = await authorize(context({ request: async () => ({ permission }) })); + assert.equal(result.allowed, false, permission); + } +}); +test("accepts current writers on same-repository feature PRs and issues", async () => { + for (const permission of ["write", "admin", "maintain"]) { + const ctx = context(); + const original = ctx.request; + ctx.request = async (url) => url.endsWith("/permission") ? { permission } : original(url); + assert.equal((await authorize(ctx)).allowed, true); + delete ctx.event.issue.pull_request; + assert.equal((await authorize(ctx)).allowed, true); + } +}); +test("checks rerunner permissions as well as original sender", async () => { + const seen = []; + const result = await authorize(context({ triggeringActor: "outsider", request: async (url) => { + seen.push(url); return { permission: url.includes("/outsider/") ? "read" : "write" }; + } })); + assert.equal(result.allowed, false); + assert.equal(seen.length, 2); +}); +test("rejects forks, deleted sources, protected source branches and closed PRs", async () => { + for (const pr of [ + { state: "open", head: { repo: { full_name: "outsider/framework" }, ref: "feature" } }, + { state: "open", head: { repo: null, ref: "feature" } }, + ...["main", "next"].map((ref) => ({ state: "open", head: { repo: { full_name: repository }, ref } })), + { state: "closed", head: { repo: { full_name: repository }, ref: "feature" } }, + ]) { + const result = await authorize(context({ request: async (url) => url.endsWith("/permission") ? { permission: "write" } : pr })); + assert.equal(result.allowed, false, JSON.stringify(pr)); + } +}); +test("bots, mismatched actors/repositories and non-mentions cannot reach the API", async () => { + for (const mutate of [ + (ctx) => { ctx.event.sender.type = "Bot"; }, + (ctx) => { ctx.actor = "other"; }, + (ctx) => { ctx.repository = "other/framework"; }, + (ctx) => { ctx.eventName = "pull_request_target"; }, + ...["hello", "@claude-bot fix", "email@claude"].map((body) => (ctx) => { ctx.event.comment.body = body; }), + ]) { + const ctx = context({ request: () => { throw new Error("API must not be called"); } }); + mutate(ctx); + assert.equal((await authorize(ctx)).allowed, false); + } +}); +test("supports inline and submitted reviews with live PR checks", async () => { + for (const eventName of ["pull_request_review_comment", "pull_request_review"]) { + const ctx = context({ eventName }); + ctx.event.pull_request = { number: 984 }; delete ctx.event.issue; + if (eventName === "pull_request_review") { ctx.event.review = ctx.event.comment; delete ctx.event.comment; } + assert.equal((await authorize(ctx)).allowed, true); + } +}); +test("manual smoke tests still need live write access", async () => { + assert.equal((await authorize(context({ eventName: "workflow_dispatch" }))).allowed, true); + assert.equal((await authorize(context({ eventName: "workflow_dispatch", request: async () => ({ permission: "read" }) }))).allowed, false); +}); +test("API errors fail closed", async () => { + await assert.rejects(authorize(context({ request: async () => { throw new Error("GitHub unavailable"); } })), /GitHub unavailable/); +}); +test("Claude secrets are gated by a read-only trusted-checkout job", () => { + const workflow = yaml.load(fs.readFileSync(path.resolve(__dirname, "../../.github/workflows/claude.yml"), "utf8")); + assert.deepEqual(workflow.permissions, {}); + assert.deepEqual(workflow.jobs.authorize.permissions, { contents: "read" }); + assert.equal(workflow.jobs.claude.needs, "authorize"); + assert.equal(workflow.jobs.claude.if, "needs.authorize.outputs.allowed == 'true'"); + assert.doesNotMatch(JSON.stringify(workflow.jobs.authorize), /secrets\./); + assert.equal(workflow.jobs.claude.concurrency.group, "claude-framework"); + assert.equal(workflow.jobs.claude.concurrency.queue, "max"); + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps) { + if (step.uses) assert.match(step.uses, /@[a-f0-9]{40}$/); + if (step.uses?.startsWith("actions/checkout@")) { + assert.equal(step.with["persist-credentials"], false); + assert.equal(step.with.ref, "${{ github.event.repository.default_branch }}"); + } + } + } +}); diff --git a/scripts/authorize-claude.cjs b/scripts/authorize-claude.cjs new file mode 100644 index 000000000..97838bea1 --- /dev/null +++ b/scripts/authorize-claude.cjs @@ -0,0 +1,65 @@ +const fs = require("node:fs"); + +// This runs from the workflow's trusted default-branch checkout, without Claude secrets. +async function authorize({ event, eventName, repository, actor, triggeringActor, request }) { + const deny = (reason) => ({ allowed: false, reason }); + if (!["issue_comment", "pull_request_review_comment", "pull_request_review", "workflow_dispatch"].includes(eventName)) { + return deny("Unsupported event"); + } + if (!/^[\w.-]+\/[\w.-]+$/.test(repository) || event.repository?.full_name !== repository) { + return deny("Repository mismatch"); + } + if (event.sender?.type !== "User" || actor !== event.sender.login) return deny("Human sender required"); + if (![actor, triggeringActor].every((login) => /^[A-Za-z0-9-]+$/.test(login))) { + return deny("Invalid actor"); + } + const body = event.comment?.body ?? event.review?.body ?? ""; + if (eventName !== "workflow_dispatch" && !/(?:^|\s)@claude(?=$|\s|[,:;.!?])/i.test(body)) { + return deny("No Claude mention"); + } + for (const login of new Set([actor, triggeringActor])) { + const access = await request(`/repos/${repository}/collaborators/${login}/permission`); + if (!["admin", "write", "maintain"].includes(access.permission)) return deny("Repository write access required"); + } + if (eventName === "workflow_dispatch") return { allowed: true, reason: "Authorized smoke test" }; + const number = event.issue?.number ?? event.pull_request?.number; + if (!Number.isSafeInteger(number) || number <= 0) return deny("Invalid issue or pull request"); + const isPr = Boolean(event.issue?.pull_request || event.pull_request); + if (isPr) { + const pr = await request(`/repos/${repository}/pulls/${number}`); + if (pr.state !== "open") return deny("Open pull request required"); + if (pr.head?.repo?.full_name !== repository) return deny("Fork pull requests cannot run the write-capable agent"); + if (!pr.head?.ref || ["main", "next"].includes(pr.head.ref)) return deny("Protected source branch"); + } + return { allowed: true, reason: "Authorized repository contribution" }; +} + +async function main() { + const token = process.env.GH_TOKEN; + if (!token) throw new Error("GitHub token missing"); + const result = await authorize({ + event: JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")), + eventName: process.env.GITHUB_EVENT_NAME, + repository: process.env.GITHUB_REPOSITORY, + actor: process.env.GITHUB_ACTOR, + triggeringActor: process.env.GITHUB_TRIGGERING_ACTOR, + request: async (path) => { + const response = await fetch(`https://api.github.com${path}`, { + headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" }, + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) throw new Error(`GitHub authorization check failed (${response.status})`); + return response.json(); + }, + }); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `allowed=${result.allowed}\n`); + console.log(result.reason); +} + +module.exports = { authorize }; +if (require.main === module) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} From e8b7c755ec9ab22ed045ff6585795175d5e04591 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 13 Oct 2026 17:59:30 +0700 Subject: [PATCH 4/5] fix(ci): set explicit claude permission modes --- .github/workflows/claude.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 17f32585f..d28460f93 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -85,6 +85,7 @@ jobs: actions: read claude_args: | --max-turns 20 + --permission-mode ${{ github.event_name == 'workflow_dispatch' && 'dontAsk' || 'acceptEdits' }} --allowedTools "${{ github.event_name == 'workflow_dispatch' && 'Bash(gh repo view:*)' || 'Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(gh repo view:*),Bash(gh issue view:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh run view:*),Bash(pnpm install --frozen-lockfile --ignore-scripts),Bash(pnpm exec lefthook run pre-commit),Bash(pnpm exec vitest run:*)' }}" --disallowedTools "${{ github.event_name == 'workflow_dispatch' && 'Edit,Write,Bash(pnpm:*),Bash(git:*),Bash(gh api:*),Bash(gh pr merge:*),Bash(gh pr review:*),Bash(node:*),Bash(curl:*),Bash(wget:*)' || 'Bash(gh api:*),Bash(gh pr merge:*),Bash(gh pr review:*),Bash(git push:*),Bash(git reset:*),Bash(git rebase:*),Bash(node:*),Bash(curl:*),Bash(wget:*)' }}" # Manual dispatch validates authentication and GitHub access without changing files. From e2fd9d7a0c13473007dc5a1bf90d1c379a017777 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 13 Oct 2026 18:47:20 +0700 Subject: [PATCH 5/5] fix(ci): reserve claude invocation for the subscription owner --- .github/workflows/claude.yml | 2 +- .../challenges/claude-personal-access.md | 15 ++++++ docs/MAINTAINERS.md | 6 ++- scripts/__tests__/authorize-claude.test.js | 46 +++++++++++++------ scripts/authorize-claude.cjs | 6 ++- 5 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 aidd_docs/tasks/challenges/claude-personal-access.md diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d28460f93..90e33b4f8 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -14,7 +14,7 @@ permissions: {} jobs: authorize: if: | - github.event.sender.type == 'User' && ( + github.actor_id == '8973343' && github.triggering_actor == github.actor && github.event.sender.type == 'User' && ( github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '@claude') || contains(github.event.review.body, '@claude') diff --git a/aidd_docs/tasks/challenges/claude-personal-access.md b/aidd_docs/tasks/challenges/claude-personal-access.md new file mode 100644 index 000000000..04290e3b3 --- /dev/null +++ b/aidd_docs/tasks/challenges/claude-personal-access.md @@ -0,0 +1,15 @@ +My confidence level of correctness now: 70% + +# Correctness (100%) +- L'accès normal au workflow est réservé à l'identité GitHub numérique `8973343`, vérifiée via le compte authentifié `alexsoyes`. Un autre mainteneur, même avec écriture, ne peut ni invoquer Claude ni relancer une demande d'Alex. +- Le filtre du workflow est doublé d'une vérification du sender, du rerunner et de l'identité retournée par l'API des permissions. Un renommage du compte est accepté ; la réutilisation de son ancien login par un autre compte est refusée. +- Les 12 tests ciblés passent, notamment les cas autre writer, relance par un tiers et usurpation du login. Les protections de forks et les limites d'exécution sont conservées. +- Ce choix correspond au moindre privilège pour un usage personnel financé par Alex. Le dépôt reste communautaire : l'autorisation de lancer son assistant ne remplace pas la revue des contributions par les mainteneurs. + +# Deal breakers +- La configuration n'est pas encore activée sur `main` : la PR #1014 nécessite une revue de mainteneur. Le lancement réel de Claude et son test de bout en bout ne sont donc pas validés. Le score concerne cet état incomplet, pas une probabilité de sécurité mesurée. + +# Suggestions (enhancements only) +- La restriction du workflow ne garantit pas une garde exclusive du jeton. Les utilisateurs ayant écriture peuvent utiliser les secrets du dépôt via des workflows ; les administrateurs peuvent modifier la configuration. Cette frontière de confiance est [documentée par GitHub](https://docs.github.com/en/actions/reference/security/secure-use#use-secrets-for-sensitive-information). +- Si Alex souhaite aussi contrôler chaque utilisation du secret, un environnement GitHub protégé avec Alex comme reviewer est une option. Il ajoute une approbation par exécution ; les administrateurs du dépôt restent de confiance. Cette protection supplémentaire n'a pas été imposée au parcours actuel. +- L'accès réservé à une personne crée une dépendance à sa disponibilité et à son abonnement. C'est un compromis explicite d'outil personnel, pas un service garanti à la communauté. diff --git a/docs/MAINTAINERS.md b/docs/MAINTAINERS.md index 4da94bcc7..bc9240717 100644 --- a/docs/MAINTAINERS.md +++ b/docs/MAINTAINERS.md @@ -26,14 +26,16 @@ How to operate this repository day to day. This file is the **Maintainer** playb Mention `@claude` in an issue comment, PR conversation, inline review comment, or submitted review. Include a bounded task and its expected verification, for example: `@claude fix the parser error described above and run the parser tests`. Review the raw issue/PR content before invoking the agent; community-authored text and imported code can contain prompt injection. -The workflow checks current repository write access for both the original sender and any rerunner before the Claude job can access its secret. Membership or `author_association` alone grants nothing. Bots and contributors without write access cannot invoke it; they can ask a maintainer to relay a reviewed request. Rejected requests appear in the **Authorize actor and source repository** job log, without consuming Claude inference quota. +Only Alex’s verified GitHub account, currently `alexsoyes` (numeric ID `8973343`), can invoke this workflow, manually dispatch it or rerun a request. The workflow checks that stable identity, the rerunner and current repository write access before the Claude job can access its secret. A renamed login continues to work; reusing the old login with a different account does not. Membership, `author_association` or another maintainer’s write access grants no invocation rights. Other contributors can ask Alex to relay a reviewed request. Rejected requests appear in the **Authorize actor and source repository** job log, without consuming Claude inference quota. The write-capable agent accepts issues and open PRs whose source is a feature branch in this repository. It refuses fork PRs, deleted sources, closed PRs, and PRs sourced from `main` or `next`. For an external contribution, review it first and, if appropriate, import the reviewed changes into a repository feature branch before asking Claude to edit them. Fork review with a secret-backed write-capable agent is deliberately unsupported. -Authentication uses the installed official Claude GitHub App and the `CLAUDE_CODE_OAUTH_TOKEN` repository secret. The sponsor's personal Claude subscription pays for inference; this is not community API billing. Every authorized invocation consumes that subscription's quota. One Claude job runs at a time, with at most 20 turns and a 30-minute timeout. GitHub queues up to 100 approved jobs; beyond that, runs are cancelled. These controls limit bursts and individual runs, not total daily usage. +Authentication uses the installed official Claude GitHub App and the `CLAUDE_CODE_OAUTH_TOKEN` repository secret. The sponsor's personal Claude subscription pays for inference; this is not community API billing. Every authorized invocation consumes Alex’s personal subscription quota. One Claude job runs at a time, with at most 20 turns and a 30-minute timeout. GitHub queues up to 100 approved jobs; beyond that, runs are cancelled. These controls limit bursts and individual runs, not total daily usage. The workflow checks out the trusted default branch before authorization; the official action subsequently selects the eligible PR head and restores its protected Claude configuration from the PR base. Repo-defined automatic Claude hooks are disabled. Approved shell commands cover read-only Git/GitHub inspection and specific pnpm installation/check commands; general `gh api`, arbitrary Node execution, merges, approvals, force pushes and history rewriting are not approved. Package-manager checks still execute repository code: inspect changes to scripts, plugins, dependencies and tool configuration before invoking Claude. Tool permissions and prompt sanitization cannot guarantee protection from every injection. +This is an invocation restriction, not exclusive custody of the credential: GitHub repository writers can use repository secrets through workflows, and administrators can change workflow/protection settings. It assumes those people are trusted. If exclusive approval of secret access is required, move the token to a protected GitHub environment with Alex as its required reviewer; every run then needs an additional approval, and repository administrators remain within the trust boundary. + The workflow token is read-only apart from the OIDC permission. Claude receives a separate short-lived, repository-scoped App token with `contents`, `pull_requests` and `issues` write access, plus `actions` read access. No workflow, administration or ruleset-bypass access is requested. Verified commits use GitHub API signing. Agent-generated changes still follow the maintainer review and CI requirements in [Governance](../GOVERNANCE.md#-code-decisions); do not grant Claude a branch-protection bypass. A new push requires reviewers to inspect the updated diff, even when an older approval remains visible. All direct actions are pinned to full commit SHAs. The selected Claude action also pins its Claude CLI version internally; Dependabot tracks action updates on `next`. Review the upstream action's security changes when updating its pin. diff --git a/scripts/__tests__/authorize-claude.test.js b/scripts/__tests__/authorize-claude.test.js index b8dfb8450..2616ead47 100644 --- a/scripts/__tests__/authorize-claude.test.js +++ b/scripts/__tests__/authorize-claude.test.js @@ -6,12 +6,13 @@ const yaml = require("js-yaml"); const { authorize } = require("../authorize-claude.cjs"); const repository = "ai-driven-dev/framework"; +const ownerId = 8973343; function context(overrides = {}) { return { - repository, actor: "maintainer", triggeringActor: "maintainer", eventName: "issue_comment", - event: { repository: { full_name: repository }, sender: { login: "maintainer", type: "User" }, + repository, actor: "alexsoyes", triggeringActor: "alexsoyes", eventName: "issue_comment", + event: { repository: { full_name: repository }, sender: { id: ownerId, login: "alexsoyes", type: "User" }, issue: { number: 984, pull_request: {} }, comment: { body: "@claude fix the failing check", author_association: "MEMBER" } }, - request: async (url) => url.endsWith("/permission") ? { permission: "write" } : + request: async (url) => url.endsWith("/permission") ? { permission: "write", user: { id: ownerId } } : { state: "open", head: { repo: { full_name: repository }, ref: "feat/example" } }, ...overrides, }; @@ -19,27 +20,44 @@ function context(overrides = {}) { test("author association never grants write access", async () => { for (const permission of ["read", "triage", "none", undefined]) { - const result = await authorize(context({ request: async () => ({ permission }) })); + const result = await authorize(context({ request: async () => ({ permission, user: { id: ownerId } }) })); assert.equal(result.allowed, false, permission); } }); -test("accepts current writers on same-repository feature PRs and issues", async () => { +test("accepts the owner with current write access on internal PRs and issues", async () => { for (const permission of ["write", "admin", "maintain"]) { const ctx = context(); const original = ctx.request; - ctx.request = async (url) => url.endsWith("/permission") ? { permission } : original(url); + ctx.request = async (url) => url.endsWith("/permission") ? { permission, user: { id: ownerId } } : original(url); assert.equal((await authorize(ctx)).allowed, true); delete ctx.event.issue.pull_request; assert.equal((await authorize(ctx)).allowed, true); } }); -test("checks rerunner permissions as well as original sender", async () => { - const seen = []; - const result = await authorize(context({ triggeringActor: "outsider", request: async (url) => { - seen.push(url); return { permission: url.includes("/outsider/") ? "read" : "write" }; +test("another maintainer cannot rerun an owner request even with write access", async () => { + const result = await authorize(context({ triggeringActor: "other-maintainer", request: () => { + throw new Error("No API call should be needed"); } })); assert.equal(result.allowed, false); - assert.equal(seen.length, 2); +}); +test("another writer cannot invoke comments or manual dispatch", async () => { + for (const eventName of ["issue_comment", "workflow_dispatch"]) { + const ctx = context({ eventName, actor: "other-maintainer", triggeringActor: "other-maintainer", request: () => { + throw new Error("No API call should be needed"); + } }); + ctx.event.sender = { id: 123, login: "other-maintainer", type: "User" }; + assert.equal((await authorize(ctx)).allowed, false); + } +}); +test("a reused login cannot impersonate the owner", async () => { + const ctx = context(); ctx.event.sender.id = 123; + assert.equal((await authorize(ctx)).allowed, false); + assert.equal((await authorize(context({ request: async () => ({ permission: "admin", user: { id: 123 } }) }))).allowed, false); +}); +test("the owner's numeric identity survives a login rename", async () => { + const ctx = context({ actor: "renamed-owner", triggeringActor: "renamed-owner" }); + ctx.event.sender.login = "renamed-owner"; + assert.equal((await authorize(ctx)).allowed, true); }); test("rejects forks, deleted sources, protected source branches and closed PRs", async () => { for (const pr of [ @@ -48,7 +66,7 @@ test("rejects forks, deleted sources, protected source branches and closed PRs", ...["main", "next"].map((ref) => ({ state: "open", head: { repo: { full_name: repository }, ref } })), { state: "closed", head: { repo: { full_name: repository }, ref: "feature" } }, ]) { - const result = await authorize(context({ request: async (url) => url.endsWith("/permission") ? { permission: "write" } : pr })); + const result = await authorize(context({ request: async (url) => url.endsWith("/permission") ? { permission: "write", user: { id: ownerId } } : pr })); assert.equal(result.allowed, false, JSON.stringify(pr)); } }); @@ -75,7 +93,7 @@ test("supports inline and submitted reviews with live PR checks", async () => { }); test("manual smoke tests still need live write access", async () => { assert.equal((await authorize(context({ eventName: "workflow_dispatch" }))).allowed, true); - assert.equal((await authorize(context({ eventName: "workflow_dispatch", request: async () => ({ permission: "read" }) }))).allowed, false); + assert.equal((await authorize(context({ eventName: "workflow_dispatch", request: async () => ({ permission: "read", user: { id: ownerId } }) }))).allowed, false); }); test("API errors fail closed", async () => { await assert.rejects(authorize(context({ request: async () => { throw new Error("GitHub unavailable"); } })), /GitHub unavailable/); @@ -86,6 +104,8 @@ test("Claude secrets are gated by a read-only trusted-checkout job", () => { assert.deepEqual(workflow.jobs.authorize.permissions, { contents: "read" }); assert.equal(workflow.jobs.claude.needs, "authorize"); assert.equal(workflow.jobs.claude.if, "needs.authorize.outputs.allowed == 'true'"); + assert.match(workflow.jobs.authorize.if, /github\.actor_id == '8973343'/); + assert.match(workflow.jobs.authorize.if, /github\.triggering_actor == github\.actor/); assert.doesNotMatch(JSON.stringify(workflow.jobs.authorize), /secrets\./); assert.equal(workflow.jobs.claude.concurrency.group, "claude-framework"); assert.equal(workflow.jobs.claude.concurrency.queue, "max"); diff --git a/scripts/authorize-claude.cjs b/scripts/authorize-claude.cjs index 97838bea1..b828c34a0 100644 --- a/scripts/authorize-claude.cjs +++ b/scripts/authorize-claude.cjs @@ -1,4 +1,6 @@ const fs = require("node:fs"); +// Verified authenticated GitHub account: alexsoyes. Numeric identity survives a rename. +const ownerId = 8973343; // This runs from the workflow's trusted default-branch checkout, without Claude secrets. async function authorize({ event, eventName, repository, actor, triggeringActor, request }) { @@ -10,15 +12,17 @@ async function authorize({ event, eventName, repository, actor, triggeringActor, return deny("Repository mismatch"); } if (event.sender?.type !== "User" || actor !== event.sender.login) return deny("Human sender required"); - if (![actor, triggeringActor].every((login) => /^[A-Za-z0-9-]+$/.test(login))) { + if (![actor, triggeringActor].every((login) => typeof login === "string" && /^[A-Za-z0-9-]+$/.test(login))) { return deny("Invalid actor"); } + if (event.sender.id !== ownerId || triggeringActor !== actor) return deny("Only the subscription owner can invoke or rerun Claude"); const body = event.comment?.body ?? event.review?.body ?? ""; if (eventName !== "workflow_dispatch" && !/(?:^|\s)@claude(?=$|\s|[,:;.!?])/i.test(body)) { return deny("No Claude mention"); } for (const login of new Set([actor, triggeringActor])) { const access = await request(`/repos/${repository}/collaborators/${login}/permission`); + if (access.user?.id !== ownerId) return deny("GitHub account identity mismatch"); if (!["admin", "write", "maintain"].includes(access.permission)) return deny("Repository write access required"); } if (eventName === "workflow_dispatch") return { allowed: true, reason: "Authorized smoke test" };