Skip to content

Commit 7810bc2

Browse files
authored
Merge pull request #50 from simplycubed/sc/31
Closes #31: GitHub App identity: least-privilege per-job tokens, author/reviewer separation
2 parents eb9f6c9 + d3b8649 commit 7810bc2

9 files changed

Lines changed: 175 additions & 79 deletions

File tree

‎.github/workflows/simplycubed.yml‎

Lines changed: 97 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ on:
77
description: Issue or pull request number, used for concurrency.
88
required: true
99
type: string
10+
github-app-id:
11+
description: GitHub App ID for the SimplyCubed App.
12+
required: true
13+
type: string
1014
issue-number:
1115
description: Issue number to run, when triggered from issues:labeled.
1216
default: ""
@@ -38,40 +42,73 @@ on:
3842
secrets:
3943
azure-openai-api-key:
4044
required: true
41-
gh-token:
42-
required: false
45+
github-app-private-key:
46+
required: true
4347

4448
jobs:
4549
run:
4650
if: ${{ inputs['issue-number'] != '' }}
4751
runs-on: ubuntu-latest
4852
concurrency: simplycubed-${{ inputs.ref }}
49-
permissions:
50-
contents: write
51-
issues: write
52-
pull-requests: write
53+
permissions: {}
5354
env:
54-
SIMPLYCUBED_AUTH_TOKEN: ${{ secrets['gh-token'] != '' && secrets['gh-token'] || github.token }}
5555
AZURE_OPENAI_ENDPOINT: ${{ inputs['azure-openai-endpoint'] }}
5656
AZURE_OPENAI_API_KEY: ${{ secrets['azure-openai-api-key'] }}
5757
steps:
58-
# IMPORTANT: if you set secrets.gh-token, use a dedicated non-admin machine
59-
# account's PAT, never a human reviewer's PAT, because the fix loop resolves
60-
# token identity and skips self-authored reviews.
61-
# A GITHUB_TOKEN-authored push or pull request does not trigger downstream
62-
# workflows, so without a PAT the agent can open or update a PR that never
63-
# receives its own `check` run. Required checks then block the merge.
64-
# Issue #31 replaces this fallback with an App token later.
58+
- name: Resolve repository scope
59+
id: repo
60+
run: |
61+
echo "owner=${GITHUB_REPOSITORY_OWNER}" >> "${GITHUB_OUTPUT}"
62+
echo "name=${GITHUB_REPOSITORY#*/}" >> "${GITHUB_OUTPUT}"
63+
64+
- name: Mint GitHub App token
65+
id: app-token
66+
uses: actions/create-github-app-token@v2
67+
with:
68+
app-id: ${{ inputs['github-app-id'] }}
69+
private-key: ${{ secrets['github-app-private-key'] }}
70+
owner: ${{ steps.repo.outputs.owner }}
71+
repositories: ${{ steps.repo.outputs.name }}
72+
permission-contents: write
73+
permission-issues: write
74+
permission-pull-requests: write
75+
76+
- name: Resolve App identity
77+
id: identity
78+
env:
79+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
80+
run: |
81+
login="$(gh api graphql -f query='query { viewer { login } }' --jq '.data.viewer.login')"
82+
if [ -z "${login}" ]; then
83+
echo "failed to resolve GitHub App login from installation token" >&2
84+
exit 1
85+
fi
86+
echo "login=${login}" >> "${GITHUB_OUTPUT}"
87+
echo "installation token identity is ${login}"
88+
89+
- name: Verify installation token permissions
90+
env:
91+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
92+
run: |
93+
gh api "repos/${GITHUB_REPOSITORY}" >/dev/null
94+
gh api "repos/${GITHUB_REPOSITORY}/issues?per_page=1" >/dev/null
95+
gh api "repos/${GITHUB_REPOSITORY}/pulls?per_page=1" >/dev/null
96+
if gh api "repos/${GITHUB_REPOSITORY}/actions/permissions" >/dev/null 2>&1; then
97+
echo "installation token unexpectedly reached Actions administration" >&2
98+
exit 1
99+
fi
100+
echo "installation token has repo contents/issues/pull-requests access and is denied Actions administration"
101+
65102
- name: Check out repository
66103
uses: actions/checkout@v5
67104
with:
68105
fetch-depth: 0
69-
token: ${{ secrets['gh-token'] != '' && secrets['gh-token'] || github.token }}
106+
token: ${{ steps.app-token.outputs.token }}
70107

71108
- name: Authorize labeler
72109
env:
73110
ACTOR_LOGIN: ${{ inputs['actor-login'] }}
74-
GH_TOKEN: ${{ env.SIMPLYCUBED_AUTH_TOKEN }}
111+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
75112
run: |
76113
permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${ACTOR_LOGIN}/permission" --jq .permission)" || {
77114
echo "actor ${ACTOR_LOGIN} is not authorized to run SimplyCubed on ${GITHUB_REPOSITORY}" >&2
@@ -100,54 +137,67 @@ jobs:
100137

101138
- name: Run SimplyCubed
102139
env:
103-
GH_TOKEN: ${{ env.SIMPLYCUBED_AUTH_TOKEN }}
140+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
104141
run: simplycubed run "${GITHUB_REPOSITORY}#${{ inputs['issue-number'] }}" --model "${{ inputs.model }}" --repo-dir .
105142

106143
address:
107144
if: ${{ inputs['pr-number'] != '' }}
108145
runs-on: ubuntu-latest
109146
concurrency: simplycubed-${{ inputs.ref }}
110-
permissions:
111-
contents: write
112-
issues: write
113-
pull-requests: write
147+
permissions: {}
114148
env:
115-
SIMPLYCUBED_AUTH_TOKEN: ${{ secrets['gh-token'] != '' && secrets['gh-token'] || github.token }}
116-
HAS_CUSTOM_GH_TOKEN: ${{ secrets['gh-token'] != '' }}
117149
AZURE_OPENAI_ENDPOINT: ${{ inputs['azure-openai-endpoint'] }}
118150
AZURE_OPENAI_API_KEY: ${{ secrets['azure-openai-api-key'] }}
119151
steps:
120-
# IMPORTANT: if you set secrets.gh-token, use a dedicated non-admin machine
121-
# account's PAT, never a human reviewer's PAT, because the fix loop resolves
122-
# token identity and skips self-authored reviews.
123-
# A GITHUB_TOKEN-authored push or pull request does not trigger downstream
124-
# workflows, so without a PAT the agent can open or update a PR that never
125-
# receives its own `check` run. Required checks then block the merge.
126-
# Issue #31 replaces this fallback with an App token later.
127-
- name: Check out repository
128-
uses: actions/checkout@v5
152+
- name: Resolve repository scope
153+
id: repo
154+
run: |
155+
echo "owner=${GITHUB_REPOSITORY_OWNER}" >> "${GITHUB_OUTPUT}"
156+
echo "name=${GITHUB_REPOSITORY#*/}" >> "${GITHUB_OUTPUT}"
157+
158+
- name: Mint GitHub App token
159+
id: app-token
160+
uses: actions/create-github-app-token@v2
129161
with:
130-
fetch-depth: 0
131-
token: ${{ secrets['gh-token'] != '' && secrets['gh-token'] || github.token }}
162+
app-id: ${{ inputs['github-app-id'] }}
163+
private-key: ${{ secrets['github-app-private-key'] }}
164+
owner: ${{ steps.repo.outputs.owner }}
165+
repositories: ${{ steps.repo.outputs.name }}
166+
permission-contents: write
167+
permission-issues: write
168+
permission-pull-requests: write
132169

133-
- name: Resolve token identity
170+
- name: Resolve App identity
134171
id: identity
135172
env:
136-
GH_TOKEN: ${{ env.SIMPLYCUBED_AUTH_TOKEN }}
173+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
137174
run: |
138-
login="$(gh api graphql -f query='query { viewer { login } }' --jq '.data.viewer.login' 2>/dev/null || true)"
175+
login="$(gh api graphql -f query='query { viewer { login } }' --jq '.data.viewer.login')"
139176
if [ -z "${login}" ]; then
140-
login="$(gh api user --jq .login 2>/dev/null || true)"
141-
fi
142-
if [ -z "${login}" ] && [ "${HAS_CUSTOM_GH_TOKEN}" = "false" ]; then
143-
login="github-actions[bot]"
177+
echo "failed to resolve GitHub App login from installation token" >&2
178+
exit 1
144179
fi
145180
echo "login=${login}" >> "${GITHUB_OUTPUT}"
146-
if [ -n "${login}" ]; then
147-
echo "token identity is ${login}"
148-
else
149-
echo "token identity could not be resolved"
181+
echo "installation token identity is ${login}"
182+
183+
- name: Verify installation token permissions
184+
env:
185+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
186+
run: |
187+
gh api "repos/${GITHUB_REPOSITORY}" >/dev/null
188+
gh api "repos/${GITHUB_REPOSITORY}/issues?per_page=1" >/dev/null
189+
gh api "repos/${GITHUB_REPOSITORY}/pulls?per_page=1" >/dev/null
190+
if gh api "repos/${GITHUB_REPOSITORY}/actions/permissions" >/dev/null 2>&1; then
191+
echo "installation token unexpectedly reached Actions administration" >&2
192+
exit 1
150193
fi
194+
echo "installation token has repo contents/issues/pull-requests access and is denied Actions administration"
195+
196+
- name: Check out repository
197+
uses: actions/checkout@v5
198+
with:
199+
fetch-depth: 0
200+
token: ${{ steps.app-token.outputs.token }}
151201

152202
- name: Skip self-authored review
153203
id: self-review
@@ -166,7 +216,7 @@ jobs:
166216
if: ${{ steps.self-review.outputs.skip != 'true' }}
167217
env:
168218
ACTOR_LOGIN: ${{ inputs['actor-login'] }}
169-
GH_TOKEN: ${{ env.SIMPLYCUBED_AUTH_TOKEN }}
219+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
170220
run: |
171221
permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${ACTOR_LOGIN}/permission" --jq .permission)" || {
172222
echo "actor ${ACTOR_LOGIN} is not authorized to run SimplyCubed on ${GITHUB_REPOSITORY}" >&2
@@ -199,6 +249,6 @@ jobs:
199249
- name: Address review feedback
200250
if: ${{ steps.self-review.outputs.skip != 'true' }}
201251
env:
202-
GH_TOKEN: ${{ env.SIMPLYCUBED_AUTH_TOKEN }}
252+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
203253
SIMPLYCUBED_SELF_LOGIN: ${{ steps.identity.outputs.login }}
204254
run: simplycubed address "${GITHUB_REPOSITORY}#${{ inputs['pr-number'] }}" --model "${{ inputs.model }}" --repo-dir .

‎README.md‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ The whole thing runs on GitHub Actions, event-driven, with no server and no VM f
4444
What this buys you:
4545

4646
- Your code stays in your repos. SimplyCubed never receives it.
47-
- Your model provider keys, your `GITHUB_TOKEN`, and any other secrets stay in your GitHub secret store. They are read by your own Actions runs and never transit our infrastructure.
47+
- Your model provider keys, the GitHub App's private key, and any other secrets stay in your GitHub secret store. They are read by your own Actions runs and never transit our infrastructure.
4848
- The agent holds no deploy credentials and has no path to production. The most it can do is open a pull request against a branch. A human and your branch protection rules decide what happens next.
4949

5050
Setup files are written locally by `simplycubed init` and merged by a human, because the runtime holds no `workflows` permission and cannot add its own workflow files.
@@ -76,13 +76,25 @@ request.
7676

7777
To run the loop inside your own GitHub Actions:
7878

79-
1. In the adopter repo, run `simplycubed init --workflow`. That writes `.github/simplycubed.yml`, writes `.github/workflows/simplycubed.yml`, and creates the `sc:*` labels through your local `gh` auth.
80-
2. Fill in the real `gate:` in `.github/simplycubed.yml`.
81-
3. Add the repository variable `AZURE_OPENAI_ENDPOINT` and the repository secret `AZURE_OPENAI_API_KEY`.
82-
4. Optionally add a PAT as `SIMPLYCUBED_GH_TOKEN`. This is strongly recommended: pushes and pull requests authored with `GITHUB_TOKEN` do not trigger downstream workflows, so without a PAT the agent's PRs can miss their `check` runs and required checks block merge. If you set it, use a dedicated non-admin machine account's PAT, never a human reviewer's PAT, because the fix-on-request loop resolves the token identity and skips self-authored reviews. The reusable workflow falls back to `github.token` only when no PAT is set; issue #31 replaces this later with an App token.
79+
1. Create and install the GitHub App, `simplycubed-code`.
80+
Repository permissions: `Contents`, `Pull requests`, and `Issues` only.
81+
Do not grant `Workflows`, `Administration`, `Environments`, or `Secrets`.
82+
Disable the App webhook: the App is an identity that mints per-job tokens, and there is no SimplyCubed server to receive deliveries.
83+
Set install visibility to `Any account`.
84+
Install it on the repo.
85+
2. In the adopter repo, run `simplycubed init --workflow`. That writes `.github/simplycubed.yml`, writes `.github/workflows/simplycubed.yml` pinned to a released reusable-workflow tag, and creates the `sc:*` labels through your local `gh` auth.
86+
3. Fill in the real `gate:` in `.github/simplycubed.yml`.
87+
4. Add repository variable `SIMPLYCUBED_GH_APP_ID`, repository secret `SIMPLYCUBED_GH_APP_PRIVATE_KEY`, repository variable `AZURE_OPENAI_ENDPOINT`, and repository secret `AZURE_OPENAI_API_KEY`.
88+
The private key secret must be the full PEM contents, including the `-----BEGIN` and `-----END` lines.
8389
5. Open a setup pull request in the adopter repo and merge it yourself. Setup files are written locally by `simplycubed init` and merged by a human, because the runtime holds no `workflows` permission and cannot add its own workflow files.
8490
6. File an issue and apply `sc:go`. Reviews submitted on the resulting pull request call back into the same reusable workflow for the fix-on-request loop.
8591

92+
Each reusable-workflow job mints its own installation token for the current
93+
repository and asks only for `contents`, `pull requests`, and `issues`. The
94+
workflow then probes an Actions-administration endpoint and expects a denial, so
95+
the run log shows the token does not carry the workflow/admin scope the App was
96+
deliberately denied.
97+
8698
## Configuration
8799

88100
Configuration lives in `.github/simplycubed.yml`. A minimal file looks like this:

‎cmd/simplycubed/main.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,11 +297,14 @@ func initCmd(argv []string, stdout io.Writer) error {
297297
fmt.Fprintf(stdout, "created labels: %s\n", strings.Join(created, ", "))
298298
}
299299
fmt.Fprintln(stdout, "next steps:")
300+
fmt.Fprintln(stdout, " - install the simplycubed-code GitHub App with contents, issues, and pull requests permissions only")
301+
fmt.Fprintln(stdout, " - disable the App webhook, set install visibility to Any account, and install it on the repo")
300302
fmt.Fprintln(stdout, " - write the real gate in .github/simplycubed.yml")
301303
fmt.Fprintln(stdout, " - verify that gate is green on your main branch")
304+
fmt.Fprintln(stdout, " - set the SIMPLYCUBED_GH_APP_ID repo variable")
305+
fmt.Fprintln(stdout, " - add the SIMPLYCUBED_GH_APP_PRIVATE_KEY repo secret with the full PEM, including BEGIN/END lines")
302306
fmt.Fprintln(stdout, " - set the AZURE_OPENAI_ENDPOINT repo variable")
303307
fmt.Fprintln(stdout, " - add the AZURE_OPENAI_API_KEY repo secret")
304-
fmt.Fprintln(stdout, " - optionally add the SIMPLYCUBED_GH_TOKEN repo secret")
305308
fmt.Fprintln(stdout, " - merge the PR containing the config and workflow changes")
306309
fmt.Fprintln(stdout, " - file an issue and apply the sc:go label")
307310
return nil

‎cmd/simplycubed/main_test.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,14 @@ exit 0
116116
for _, want := range []string{
117117
"wrote " + configPath,
118118
"created labels: sc:go, sc:queued, sc:working, sc:review, sc:blocked, sc:done",
119+
"install the simplycubed-code GitHub App with contents, issues, and pull requests permissions only",
120+
"disable the App webhook, set install visibility to Any account, and install it on the repo",
119121
"write the real gate in .github/simplycubed.yml",
120122
"verify that gate is green on your main branch",
123+
"set the SIMPLYCUBED_GH_APP_ID repo variable",
124+
"add the SIMPLYCUBED_GH_APP_PRIVATE_KEY repo secret with the full PEM, including BEGIN/END lines",
121125
"set the AZURE_OPENAI_ENDPOINT repo variable",
122126
"add the AZURE_OPENAI_API_KEY repo secret",
123-
"optionally add the SIMPLYCUBED_GH_TOKEN repo secret",
124127
"merge the PR containing the config and workflow changes",
125128
"file an issue and apply the sc:go label",
126129
} {
@@ -231,7 +234,9 @@ exit 0
231234
}
232235
for _, want := range []string{
233236
"uses: simplycubed/code/.github/workflows/simplycubed.yml@v0.1.0",
237+
"github-app-id: ${{ vars.SIMPLYCUBED_GH_APP_ID }}",
234238
"azure-openai-api-key: ${{ secrets.AZURE_OPENAI_API_KEY }}",
239+
"github-app-private-key: ${{ secrets.SIMPLYCUBED_GH_APP_PRIVATE_KEY }}",
235240
} {
236241
if !strings.Contains(workflow, want) {
237242
t.Fatalf("workflow missing %q:\n%s", want, workflow)

‎cmd/simplycubed/simplycubed-caller.yml.tmpl‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,25 @@ jobs:
1212
uses: simplycubed/code/.github/workflows/simplycubed.yml@__SIMPLYCUBED_TAG__
1313
with:
1414
ref: ${{ github.event.issue.number }}
15+
github-app-id: ${{ vars.SIMPLYCUBED_GH_APP_ID }}
1516
issue-number: ${{ github.event.issue.number }}
1617
actor-login: ${{ github.event.sender.login }}
1718
azure-openai-endpoint: ${{ vars.AZURE_OPENAI_ENDPOINT }}
1819
# model: my-gpt-5-4-deployment
1920
secrets:
2021
azure-openai-api-key: ${{ secrets.AZURE_OPENAI_API_KEY }}
21-
gh-token: ${{ secrets.SIMPLYCUBED_GH_TOKEN }}
22+
github-app-private-key: ${{ secrets.SIMPLYCUBED_GH_APP_PRIVATE_KEY }}
2223

2324
address:
2425
if: ${{ github.event_name == 'pull_request_review' }}
2526
uses: simplycubed/code/.github/workflows/simplycubed.yml@__SIMPLYCUBED_TAG__
2627
with:
2728
ref: ${{ github.event.pull_request.number }}
29+
github-app-id: ${{ vars.SIMPLYCUBED_GH_APP_ID }}
2830
pr-number: ${{ github.event.pull_request.number }}
2931
actor-login: ${{ github.event.review.user.login }}
3032
azure-openai-endpoint: ${{ vars.AZURE_OPENAI_ENDPOINT }}
3133
# model: my-gpt-5-4-deployment
3234
secrets:
3335
azure-openai-api-key: ${{ secrets.AZURE_OPENAI_API_KEY }}
34-
gh-token: ${{ secrets.SIMPLYCUBED_GH_TOKEN }}
36+
github-app-private-key: ${{ secrets.SIMPLYCUBED_GH_APP_PRIVATE_KEY }}

‎docs/decisions/0006-runtime-and-identity.md‎

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
# 0006. Runtime on GitHub Actions, and identity
22

3-
Status: Accepted for the runtime; the one-App-versus-two identity question is
4-
open.
3+
Status: Accepted.
54

65
## Context
76

@@ -29,11 +28,22 @@ contents plus pull requests plus issues, and nothing else. It never receives
2928
permission over workflows, administration, environments, or secrets, so it cannot
3029
edit its own CI gate or reach deploy configuration.
3130

32-
## Open: one App or two
33-
34-
A single App can carry both the implementer and reviewer roles, with the role
35-
shown in the comment header and least privilege achieved by scoping the
36-
per-request token. Two Apps (a worker identity and a reviewer identity) buy
37-
identity-level audit separation at the cost of a second install and a second key.
38-
Per-request token scoping is required either way. This is deferred to when the
39-
Action is built; the loop does not depend on the answer.
31+
## Decision update: one App
32+
33+
Use one App: `simplycubed-code` carries the product's GitHub identity for
34+
commits, pull requests, labels, and comments. Each job mints its own
35+
installation token scoped to the current repository and only the permissions it
36+
needs, preserving least privilege without adding a second principal for the
37+
current loop.
38+
39+
Reason: the automated reviewer role defined in #32 is comment-only by design,
40+
not a formal approval or request-changes reviewer. GitHub allows a COMMENT
41+
review from the PR author's own identity; only APPROVE and REQUEST_CHANGES are
42+
blocked. The current reviewer flow also passes findings in-process from the
43+
reviewer role to the fixer prompt, not through a GitHub review round-trip, so a
44+
separate App adds no capability today. Requiring adopters to install two Apps
45+
for one tool would also be a worse product experience.
46+
47+
If a future automated reviewer needs to publish a formal review state rather
48+
than comments, splitting identities remains an option to revisit. For the
49+
present design, one App is the accepted decision.

‎docs/faq.md‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,20 @@ gate be, and why won't the agent run until it is right?**
66

77
## Do I have to install the GitHub App to try it?
88

9-
No. You can try SimplyCubed Code two ways today:
9+
For the GitHub Actions runtime, yes. For local development, no.
10+
11+
You can use SimplyCubed Code two ways:
1012

1113
- as a local CLI (`simplycubed run` and `simplycubed address`) using your own
1214
`gh` authentication, which reads the repo config, works in a git worktree,
1315
runs your gate, and opens or updates a pull request as you;
1416
- or through the reusable workflow that runs inside your own GitHub Actions, as
15-
described in the README's install section.
17+
described in the README's install section, which now mints a per-job GitHub
18+
App installation token for `simplycubed-code[bot]`.
1619

17-
The GitHub App identity (`simplycubed-code[bot]`) is still the intended audit
18-
signal for the hosted-in-your-GitHub runtime, but the workflow can run today
19-
without a separate App install by using `github.token` or an optional PAT.
20+
The GitHub App is configured with only `contents`, `pull requests`, and
21+
`issues`, and the reusable workflow proves that scope at runtime by expecting an
22+
Actions-administration API call to fail.
2023

2124
## Are the `sc:` labels created for me?
2225

0 commit comments

Comments
 (0)