diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..90e33b4f8 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,92 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + pull_request_review: + types: [submitted] + workflow_dispatch: + +permissions: {} + +jobs: + authorize: + if: | + 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') + ) && (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: 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: + node-version: "22" + + - name: Enable pnpm + run: corepack enable + + - name: Run Claude Code + uses: anthropics/claude-code-action@1d6de8cb0c237e7c15e9e1bdf973826ebae490cc # v1 + 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 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. + 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/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 982af57d2..bc9240717 100644 --- a/docs/MAINTAINERS.md +++ b/docs/MAINTAINERS.md @@ -22,6 +22,36 @@ 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. + +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 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. + +### 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..2616ead47 --- /dev/null +++ b/scripts/__tests__/authorize-claude.test.js @@ -0,0 +1,121 @@ +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"; +const ownerId = 8973343; +function context(overrides = {}) { + return { + 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", user: { id: ownerId } } : + { 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, user: { id: ownerId } }) })); + assert.equal(result.allowed, false, permission); + } +}); +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, 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("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); +}); +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 [ + { 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", user: { id: ownerId } } : 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", 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/); +}); +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.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"); + 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..b828c34a0 --- /dev/null +++ b/scripts/authorize-claude.cjs @@ -0,0 +1,69 @@ +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 }) { + 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) => 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" }; + 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; + }); +}