fix(ci): harden self-hosted workflow trust boundaries

Keep active CCS workflows on self-hosted runners, gate self-hosted PR execution to trusted authors, and scope privileged release credentials to the exact git operations that need them.
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-12 12:34:15 -04:00
committed by GitHub
parent d86032ed0a
commit 2273757c83
17 changed files with 210 additions and 29 deletions
+5 -1
View File
@@ -74,10 +74,14 @@ jobs:
if: >-
github.event_name != 'workflow_dispatch' &&
(
github.event_name == 'pull_request_target' ||
(
github.event_name == 'pull_request_target' &&
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
) ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.issue.author_association) &&
github.event.comment.body == '/review' &&
(
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.comment.author_association) ||
+6
View File
@@ -16,6 +16,8 @@ concurrency:
jobs:
validate:
if: >-
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
runs-on: [self-hosted, linux, x64]
env:
# Keep Bun cache isolated per job workspace so parallel self-hosted runs
@@ -58,6 +60,8 @@ jobs:
run: ${{ matrix.check.cmd }}
build:
if: >-
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
runs-on: [self-hosted, linux, x64]
name: build
env:
@@ -98,6 +102,8 @@ jobs:
if-no-files-found: error
test:
if: >-
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
runs-on: [self-hosted, linux, x64]
name: test
needs: [build]
+4 -1
View File
@@ -21,7 +21,10 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
if: >-
github.event_name != 'pull_request' ||
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
runs-on: [self-hosted, linux, x64]
name: Deploy ccs-installer to CloudFlare
steps:
+11 -5
View File
@@ -50,9 +50,7 @@ jobs:
with:
fetch-depth: 0
ref: dev
# PAT_TOKEN required: dev-release pushes version bump commits to dev,
# and protected branches reject github.token writes once status checks are required.
token: ${{ secrets.PAT_TOKEN }}
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -93,8 +91,16 @@ jobs:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
# Use custom dev release script instead of semantic-release
# This ensures dev versions follow {stable}-dev.{N} pattern
auth_header=$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')
echo "::add-mask::${auth_header}"
export GIT_CONFIG_COUNT=2
export GIT_CONFIG_KEY_0=http.https://github.com/kaitranntt/ccs.extraheader
export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth_header}"
export GIT_CONFIG_KEY_1=http.https://github.com/kaitranntt/ccs.git.extraheader
export GIT_CONFIG_VALUE_1="AUTHORIZATION: basic ${auth_header}"
# Use custom dev release script instead of semantic-release.
# This ensures dev versions follow {stable}-dev.{N} pattern.
chmod +x scripts/dev-release.sh
./scripts/dev-release.sh
+1 -1
View File
@@ -18,7 +18,7 @@ concurrency:
jobs:
publish:
if: ${{ github.event_name != 'release' || !github.event.release.prerelease }}
runs-on: ubuntu-latest
runs-on: [self-hosted, linux, x64]
permissions:
contents: read
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
label:
runs-on: ubuntu-latest
runs-on: [self-hosted, linux, x64]
permissions:
issues: write
pull-requests: read
+2 -2
View File
@@ -7,7 +7,7 @@ on:
jobs:
publish:
runs-on: ubuntu-latest
runs-on: [self-hosted, linux, x64]
permissions:
contents: write # For creating GitHub releases
@@ -66,4 +66,4 @@ jobs:
## What's Changed
See [CHANGELOG.md](https://github.com/kaitranntt/ccs/blob/main/CHANGELOG.md)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+10 -4
View File
@@ -21,9 +21,7 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
# PAT_TOKEN required: semantic-release pushes version bump commits to main,
# which needs branch protection bypass. github.token cannot push to protected branches.
token: ${{ secrets.PAT_TOKEN }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -58,12 +56,20 @@ jobs:
env:
HUSKY: 0
# PAT_TOKEN bypasses branch protection so semantic-release can push
# version bump + CHANGELOG commits directly to main
# version bump + CHANGELOG commits directly to main.
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
GH_TOKEN: ${{ secrets.PAT_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
auth_header=$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')
echo "::add-mask::${auth_header}"
export GIT_CONFIG_COUNT=2
export GIT_CONFIG_KEY_0=http.https://github.com/kaitranntt/ccs.extraheader
export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth_header}"
export GIT_CONFIG_KEY_1=http.https://github.com/kaitranntt/ccs.git.extraheader
export GIT_CONFIG_VALUE_1="AUTHORIZATION: basic ${auth_header}"
set +e
OUTPUT=$(bun x semantic-release 2>&1)
STATUS=$?
@@ -22,7 +22,7 @@ permissions:
jobs:
sync-project:
runs-on: ubuntu-latest
runs-on: [self-hosted, linux, x64]
steps:
- name: Checkout
uses: actions/checkout@v4
+19 -5
View File
@@ -10,16 +10,20 @@ jobs:
runs-on: [self-hosted, linux, x64]
permissions:
contents: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# PAT_TOKEN is required because this pushes to protected dev and must
# trigger follow-up Push CI / Dev Release workflows.
token: ${{ secrets.PAT_TOKEN }}
persist-credentials: false
- name: Harden Git workspace
run: |
rm -rf .git/hooks
mkdir -p .git/hooks
git config --local core.hooksPath /dev/null
- name: Configure Git
run: |
@@ -47,4 +51,14 @@ jobs:
git commit --no-edit
fi
git push origin dev
# PAT_TOKEN is required because this pushes to protected dev and must
# trigger follow-up Push CI / Dev Release workflows. Keep it scoped to
# the final push rather than persisting it in the checkout config.
auth_header=$(printf 'x-access-token:%s' "$PAT_TOKEN" | base64 | tr -d '\n')
echo "::add-mask::${auth_header}"
git -c core.hooksPath=/dev/null \
-c "http.https://github.com/kaitranntt/ccs.extraheader=AUTHORIZATION: basic ${auth_header}" \
-c "http.https://github.com/kaitranntt/ccs.git.extraheader=AUTHORIZATION: basic ${auth_header}" \
push origin dev
env:
PAT_TOKEN: ${{ secrets.PAT_TOKEN }}
+7
View File
@@ -50,6 +50,13 @@ AI MUST NOT declare a task done, close a session, or move to the next task while
- Verified on `2026-04-22` via `gh api repos/kaitranntt/ccs/branches/dev/protection`: `dev` currently requires `typecheck`, `lint`, `format`, `build`, and `test`, has no branch restrictions, and has no required PR-review gate.
- `dev-release.yml` currently pushes with `PAT_TOKEN` because `dev` is protected by those required status checks. Do not switch it back to `github.token` unless branch protection changes with it.
### Self-Hosted Runner Policy
- Keep active CCS workflows on local self-hosted runners by default. Do **not** move jobs to `ubuntu-latest`, `macos-latest`, or other GitHub-hosted runners as the first security fix.
- For this public repo, standard GitHub-hosted runners may not consume billable minutes, but the project policy is still local-runner-first to keep compute predictable and avoid future quota surprises across repos.
- Make self-hosted use secure by gating untrusted PR triggers, avoiding checkout of untrusted code in privileged `pull_request_target` jobs, setting `persist-credentials: false`, and scoping PAT-backed credentials to the exact release/sync push step.
- If a workflow truly needs GitHub-hosted runners, document the exception in this file and add a regression test for the exception.
## Core Function
Multi-provider profile and runtime manager for Claude Code, Factory Droid,
@@ -7,7 +7,7 @@ function resolvePath(relativePath: string) {
}
describe('PR-Agent review lane migration', () => {
test('keeps ai-review.yml as the PR-Agent workflow on the self-hosted cliproxy runner', () => {
test('keeps ai-review.yml as the PR-Agent workflow on the self-hosted cliproxy runner with trusted PR gating', () => {
const workflowPath = resolvePath('../../../../.github/workflows/ai-review.yml');
const prAgentConfigPath = resolvePath('../../../../.pr_agent.toml');
@@ -23,6 +23,14 @@ describe('PR-Agent review lane migration', () => {
expect(workflow).toContain('uses: qodo-ai/pr-agent');
expect(appTokenUsages).toHaveLength(2);
expect(workflow).toContain('OPENAI.API_BASE');
expect(workflow).toContain("github.event_name == 'pull_request_target' &&");
expect(workflow).toContain(
'contains(fromJSON(\'["COLLABORATOR","MEMBER","OWNER"]\'), github.event.pull_request.author_association)'
);
expect(workflow).toContain(
'contains(fromJSON(\'["COLLABORATOR","MEMBER","OWNER"]\'), github.event.issue.author_association)'
);
expect(workflow).toContain('github.event.issue.author_association');
expect(workflow).toContain('OPENAI_KEY');
expect(workflow).toContain('vars.AI_REVIEW_BASE_URL');
expect(workflow).toContain('vars.AI_REVIEW_MODEL');
@@ -9,6 +9,8 @@ function resolvePath(relativePath: string) {
describe('pr ci workflow', () => {
test('keeps full coverage on pull requests', () => {
const workflowPath = resolvePath('../../../../.github/workflows/ci.yml');
const trustedAuthorGate =
'contains(fromJSON(\'["COLLABORATOR","MEMBER","OWNER"]\'), github.event.pull_request.author_association)';
expect(fs.existsSync(workflowPath)).toBe(true);
@@ -17,6 +19,7 @@ describe('pr ci workflow', () => {
expect(workflow).toContain('name: CI');
expect(workflow).toContain('pull_request:');
expect(workflow).toContain('branches: [main, dev]');
expect(workflow.split(trustedAuthorGate).length - 1).toBe(3);
expect(workflow).toContain('group: ci-${{ github.ref }}');
expect(workflow).toContain('cancel-in-progress: true');
expect(workflow).toContain('fail-fast: false');
@@ -26,8 +26,15 @@ describe('dev release workflow', () => {
expect(workflow).toContain('branches: [dev]');
expect(workflow.match(/runs-on: \[self-hosted, linux, x64\]/g)).toHaveLength(2);
expect(workflow).not.toContain('runs-on: ubuntu-latest');
expect(checkoutSection).toContain("token: ${{ secrets.PAT_TOKEN }}");
expect(checkoutSection).toContain('persist-credentials: false');
expect(checkoutSection).not.toContain('token: ${{ secrets.PAT_TOKEN }}');
expect(checkoutSection).not.toContain('token: ${{ github.token }}');
expect(releaseSection).toContain('echo "::add-mask::${auth_header}"');
expect(releaseSection).toContain('GIT_CONFIG_COUNT=2');
expect(releaseSection).toContain('GIT_CONFIG_KEY_0=http.https://github.com/kaitranntt/ccs.extraheader');
expect(releaseSection).toContain('GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth_header}"');
expect(releaseSection).toContain('GIT_CONFIG_KEY_1=http.https://github.com/kaitranntt/ccs.git.extraheader');
expect(releaseSection).toContain('GIT_CONFIG_VALUE_1="AUTHORIZATION: basic ${auth_header}"');
expect(releaseSection).toContain('GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}');
expect(releaseSection).toContain('GH_TOKEN: ${{ secrets.PAT_TOKEN }}');
expect(releaseSection).not.toContain('GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}');
@@ -7,7 +7,7 @@ function resolvePath(relativePath: string) {
}
describe('stable release workflow', () => {
test('uses the protected-branch runner and release token path', () => {
test('uses the self-hosted runner and scoped release token path', () => {
const workflowPath = resolvePath('../../../../.github/workflows/release.yml');
expect(fs.existsSync(workflowPath)).toBe(true);
@@ -26,7 +26,14 @@ describe('stable release workflow', () => {
expect(workflow).toContain('branches: [main]');
expect(workflow).toContain('runs-on: [self-hosted, linux, x64]');
expect(workflow).not.toContain('runs-on: ubuntu-latest');
expect(checkoutSection).toContain('token: ${{ secrets.PAT_TOKEN }}');
expect(checkoutSection).toContain('persist-credentials: false');
expect(checkoutSection).not.toContain('token: ${{ secrets.PAT_TOKEN }}');
expect(releaseSection).toContain('echo "::add-mask::${auth_header}"');
expect(releaseSection).toContain('GIT_CONFIG_COUNT=2');
expect(releaseSection).toContain('GIT_CONFIG_KEY_0=http.https://github.com/kaitranntt/ccs.extraheader');
expect(releaseSection).toContain('GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth_header}"');
expect(releaseSection).toContain('GIT_CONFIG_KEY_1=http.https://github.com/kaitranntt/ccs.git.extraheader');
expect(releaseSection).toContain('GIT_CONFIG_VALUE_1="AUTHORIZATION: basic ${auth_header}"');
expect(releaseSection).toContain('GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}');
expect(releaseSection).toContain('GH_TOKEN: ${{ secrets.PAT_TOKEN }}');
expect(releaseSection).toContain('NPM_TOKEN: ${{ secrets.NPM_TOKEN }}');
@@ -0,0 +1,104 @@
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
function workflowsDir() {
return path.resolve(import.meta.dir, '../../../../.github/workflows');
}
describe('self-hosted runner policy', () => {
test('keeps active workflows on local runners', () => {
const hostedRunnerLabels = [
'ubuntu-latest',
'ubuntu-24.04',
'ubuntu-22.04',
'macos-latest',
'windows-latest',
];
const workflowFiles = fs
.readdirSync(workflowsDir())
.filter((file) => file.endsWith('.yml') || file.endsWith('.yaml'));
expect(workflowFiles.length).toBeGreaterThan(0);
for (const file of workflowFiles) {
const workflow = fs.readFileSync(path.join(workflowsDir(), file), 'utf8');
for (const label of hostedRunnerLabels) {
expect(workflow, `${file} must not use GitHub-hosted runner ${label}`).not.toContain(
`runs-on: ${label}`
);
}
expect(workflow, `${file} must target a self-hosted runner`).toContain('self-hosted');
}
});
test('gates pull-request self-hosted worker deploys to trusted authors', () => {
const workflow = fs.readFileSync(path.join(workflowsDir(), 'deploy-ccs-worker.yml'), 'utf8');
expect(workflow).toContain("github.event_name != 'pull_request'");
expect(workflow).toContain(
'contains(fromJSON(\'["COLLABORATOR","MEMBER","OWNER"]\'), github.event.pull_request.author_association)'
);
});
test('gates pull-request workflows that check out code on self-hosted runners', () => {
const trustedAuthorGate =
'contains(fromJSON(\'["COLLABORATOR","MEMBER","OWNER"]\'), github.event.pull_request.author_association)';
const workflowFiles = fs
.readdirSync(workflowsDir())
.filter((file) => file.endsWith('.yml') || file.endsWith('.yaml'));
for (const file of workflowFiles) {
const workflow = fs.readFileSync(path.join(workflowsDir(), file), 'utf8');
if (workflow.includes('pull_request_target:')) {
expect(workflow, `${file} must not check out code from pull_request_target`).not.toContain(
'uses: actions/checkout'
);
}
if (
workflow.includes('pull_request:') &&
workflow.includes('self-hosted') &&
workflow.includes('uses: actions/checkout')
) {
expect(workflow, `${file} must gate self-hosted PR checkout to trusted authors`).toContain(
trustedAuthorGate
);
}
}
});
test('scoped PAT headers match repository URL forms used by git', () => {
const header = 'AUTHORIZATION: basic test-token';
const env = {
...process.env,
GIT_CONFIG_COUNT: '2',
GIT_CONFIG_KEY_0: 'http.https://github.com/kaitranntt/ccs.extraheader',
GIT_CONFIG_VALUE_0: header,
GIT_CONFIG_KEY_1: 'http.https://github.com/kaitranntt/ccs.git.extraheader',
GIT_CONFIG_VALUE_1: header,
};
for (const url of ['https://github.com/kaitranntt/ccs', 'https://github.com/kaitranntt/ccs.git']) {
const result = spawnSync('git', ['config', '--get-urlmatch', 'http.extraheader', url], {
env,
encoding: 'utf8',
});
expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim()).toBe(header);
}
for (const file of ['release.yml', 'dev-release.yml', 'sync-dev-after-release.yml']) {
const workflow = fs.readFileSync(path.join(workflowsDir(), file), 'utf8');
expect(workflow).toContain('echo "::add-mask::${auth_header}"');
expect(workflow).toContain('http.https://github.com/kaitranntt/ccs.extraheader');
expect(workflow).toContain('http.https://github.com/kaitranntt/ccs.git.extraheader');
}
});
});
@@ -7,7 +7,7 @@ function resolvePath(relativePath: string) {
}
describe('sync dev after release workflow', () => {
test('uses protected-branch runner and token settings', () => {
test('keeps protected-branch sync on self-hosted runners with scoped push credentials', () => {
const workflowPath = resolvePath('../../../../.github/workflows/sync-dev-after-release.yml');
expect(fs.existsSync(workflowPath)).toBe(true);
@@ -22,12 +22,18 @@ describe('sync dev after release workflow', () => {
expect(workflow).toContain('name: Sync Dev After Main Release');
expect(workflow).toContain('runs-on: [self-hosted, linux, x64]');
expect(workflow).not.toContain('runs-on: ubuntu-latest');
expect(checkoutSection).toContain('token: ${{ secrets.PAT_TOKEN }}');
expect(checkoutSection).not.toContain('token: ${{ github.token }}');
expect(checkoutSection).toContain('persist-credentials: false');
expect(checkoutSection).not.toContain('token: ${{ secrets.PAT_TOKEN }}');
expect(syncSection).toContain(
'git merge origin/main --no-edit -m "chore(sync): merge main into dev after release"'
);
expect(syncSection).not.toContain('[skip ci]');
expect(syncSection).toContain('git push origin dev');
expect(workflow).toContain('git config --local core.hooksPath /dev/null');
expect(syncSection).toContain('PAT_TOKEN: ${{ secrets.PAT_TOKEN }}');
expect(syncSection).toContain('echo "::add-mask::${auth_header}"');
expect(syncSection).toContain('http.https://github.com/kaitranntt/ccs.extraheader');
expect(syncSection).toContain('http.https://github.com/kaitranntt/ccs.git.extraheader');
expect(syncSection).toContain('git -c core.hooksPath=/dev/null');
expect(syncSection).toContain('push origin dev');
});
});