-
Notifications
You must be signed in to change notification settings - Fork 32
fix: gh-aw skill portability — 3-model audit findings #771
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| # gh-aw Architecture & Security Reference | ||
|
|
||
| Deep reference for gh-aw execution model, security boundaries, fork handling, safe outputs, and troubleshooting. Read this file when the SKILL.md quick-start and common patterns aren't sufficient. | ||
| Deep reference for gh-aw execution model, security boundaries, fork handling, safe outputs, and troubleshooting. Read this file when the quick-start guide's common patterns aren't sufficient. | ||
|
|
||
| ## Execution Model | ||
|
|
||
|
|
@@ -57,7 +57,7 @@ The prompt is built in the **activation job** via `{{#runtime-import .github/wor | |
|
|
||
| By default, `gh aw compile` automatically injects a fork guard into the activation job's `if:` condition: `head.repo.id == repository_id`. This blocks fork PRs on `pull_request` events. | ||
|
|
||
| To **allow fork PRs**, add `forks: ["*"]` to the `pull_request` trigger in the `.md` frontmatter. The compiler removes the auto-injected guard from the compiled `if:` conditions. This is safe when the workflow uses the `Checkout-GhAwPr.ps1` pattern (checkout + trusted-infra restore) and the agent is sandboxed. | ||
| To **allow fork PRs**, add `forks: ["*"]` to the `pull_request` trigger in the `.md` frontmatter. The compiler removes the auto-injected guard from the compiled `if:` conditions. This is safe when the workflow uses a checkout-then-restore pattern (checkout + trusted-infra restore) and the agent is sandboxed. | ||
|
|
||
| --- | ||
|
|
||
|
|
@@ -67,7 +67,7 @@ To **allow fork PRs**, add `forks: ["*"]` to the `pull_request` trigger in the ` | |
|
|
||
| 1. **Never execute untrusted PR code with elevated credentials.** The classic "pwn-request" attack is `pull_request_target` + checkout PR + run build scripts with `GITHUB_TOKEN`. The attack surface includes build scripts (`make`, `build.ps1`), package manager hooks (`npm postinstall`, MSBuild targets), and test runners. | ||
|
|
||
| 2. **Treating PR contents as passive data is safe.** Reading, analyzing, or diffing PR code is fine — the danger is *executing* it. Our gh-aw workflows read code for evaluation; they never build or run it. | ||
| 2. **Treating PR contents as passive data is safe.** Reading, analyzing, or diffing PR code is fine — the danger is *executing* it. gh-aw workflows should read code for evaluation but never build or run it. | ||
|
|
||
| 3. **`pull_request_target` grants write permissions and secrets access.** This is by design — the workflow YAML comes from the base branch (trusted). But any step that checks out and runs fork code in this context creates a vulnerability. | ||
|
|
||
|
|
@@ -122,7 +122,7 @@ tools: | |
| | `none` | All content including `FIRST_TIMER` and no-association users | | ||
| | `blocked` | Users in `blocked-users` — always denied, cannot be promoted | | ||
|
|
||
| **Recommendation for our workflows:** Omit `min-integrity` and rely on the automatic runtime lockdown (see Known Issue above). The `determine-automatic-lockdown` step applies appropriate integrity levels based on event type and actor trust. | ||
| **Recommendation:** Omit `min-integrity` and rely on the automatic runtime lockdown (see Known Issue above). The `determine-automatic-lockdown` step applies appropriate integrity levels based on event type and actor trust. | ||
|
|
||
| ### Protected Files (Auto-Enabled) | ||
|
|
||
|
|
@@ -143,7 +143,7 @@ Configure behavior with `protected-files:` on the safe output: | |
|
|
||
| - ✅ **DO** treat PR contents as passive data (read, analyze, diff) | ||
| - ✅ **DO** run data-gathering scripts in `steps:` (pre-agent, trusted context) not inside the agent | ||
| - ✅ **DO** use `Checkout-GhAwPr.ps1` for `workflow_dispatch` to restore trusted `.github/` from base | ||
| - ✅ **DO** implement a checkout-then-restore step for `workflow_dispatch` to restore trusted `.github/` from base | ||
| - ✅ **DO** narrow `slash_command: events:` to the minimum needed (e.g., `[pull_request_comment]`) | ||
| - ✅ **DO** use `cancel-in-progress: false` for `slash_command:` workflows to prevent non-matching events from killing in-progress agent runs | ||
| - ✅ **DO** prefer `slash_command:` or `schedule` over `pull_request` trigger — `pull_request` causes the "Approve and run" gate that approves ALL workflows with a single click | ||
|
|
@@ -204,7 +204,7 @@ The platform now **automatically preserves `.github/` and `.agents/` from the ba | |
| **Remaining risks (not fixed by #23769):** | ||
| - `steps:` and `pre-agent-steps:` that execute workspace code after checkout still run with `GITHUB_TOKEN` — if they run fork PR scripts, it's a pwn-request | ||
| - The agent container has `COPILOT_TOKEN` in the environment — build commands (`dotnet build`, `npm install`) executed by the agent on fork PR code can read it via build hooks | ||
| - `workflow_dispatch` skips `checkout_pr_branch.cjs` entirely — use `Checkout-GhAwPr.ps1` for defense-in-depth | ||
| - `workflow_dispatch` skips `checkout_pr_branch.cjs` entirely — implement a checkout-then-restore step for defense-in-depth | ||
| - **Multi-repo `push_to_pull_request_branch`** (fixed v0.70.0): Previously, git operations were scoped to the wrong working directory in multi-repo checkout patterns. This is now fixed — side-repo push targets the correct directory automatically. Recompile affected workflows. | ||
|
|
||
| ### Dangerous Triggers Checklist | ||
|
|
@@ -259,29 +259,36 @@ Use this checklist when reviewing any workflow that uses high-risk triggers. The | |
|
|
||
| ### Safe Pattern: Checkout + Restore | ||
|
|
||
| Use the shared `.github/scripts/Checkout-GhAwPr.ps1` script, which implements checkout + restore in a single reusable step: | ||
| Implement a checkout-then-restore step for `workflow_dispatch` workflows that evaluate PR branches. This pattern verifies write access, checks out the PR, and restores trusted agent infrastructure from the base branch: | ||
|
|
||
| ```yaml | ||
| steps: | ||
| - name: Checkout PR and restore agent infrastructure | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} | ||
| run: pwsh .github/scripts/Checkout-GhAwPr.ps1 | ||
| run: | | ||
| # 1. Verify PR author has write access, reject forks | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 MINOR — Comment says "reject forks" but no fork detection exists The code checks the author's collaborator permission (write/admin/maintain) — it does not check This is a documentation accuracy issue, not a security gap — the real defense is step 4 (the restore), and the write-access gate is a valid authorization check on its own. But the misleading comment could confuse security reviewers into believing fork rejection happens here when it doesn't. Suggested fix: Reword the comment: # 1. Verify PR author is a trusted collaborator (requires write+ access)Flagged by: 3/3 reviewers after follow-up adjudication (originally 1/3, confirmed by 2 follow-up reviewers at reduced severity) |
||
| AUTHOR=$(gh pr view "$PR_NUMBER" --json author --jq '.author.login') | ||
| PERM=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$AUTHOR/permission" --jq '.permission') | ||
| if [[ "$PERM" != "admin" && "$PERM" != "write" && "$PERM" != "maintain" ]]; then | ||
| echo "::error::PR author $AUTHOR has $PERM access — requires write+" | ||
| exit 1 | ||
| fi | ||
| # 2. Capture base branch SHA before checkout | ||
| BASE_SHA=$(gh pr view "$PR_NUMBER" --json baseRefOid --jq '.baseRefOid') | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 CRITICAL — If Suggested fix: Add validation immediately after assignment: BASE_SHA=$(gh pr view "$PR_NUMBER" --json baseRefOid --jq '.baseRefOid')
if [[ -z "$BASE_SHA" ]]; then
echo "::error::Could not determine base branch SHA for PR $PR_NUMBER"
exit 1
fiFlagged by: 2/3 reviewers (compounded by Finding 1 — both reviewers who flagged the |
||
| # 3. Check out the PR branch | ||
| gh pr checkout "$PR_NUMBER" | ||
| # 4. Restore trusted agent infrastructure from base branch | ||
| git checkout "$BASE_SHA" -- .github/ .agents/ 2>/dev/null || true | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 CRITICAL — The old Failing scenario: For Suggested fix: Remove git checkout "$BASE_SHA" -- .github/ .agents/If suppressing stderr for legitimately non-existent paths is needed, use a targeted pre-check rather than blanket error suppression. Flagged by: 3/3 reviewers |
||
| ``` | ||
|
Comment on lines
+280
to
284
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 CRITICAL — Silent failure on security-critical restore step The old PowerShell script documented restore as fatal on failure (step 4: "Restores ... from the base branch SHA (fatal on failure)"). The new inline pattern uses: git checkout "$BASE_SHA" -- .github/ .agents/ 2>/dev/null || true
Concrete scenario: An attacker with write access opens a PR that modifies Suggested fix: Split into two operations — one that must succeed ( # MUST succeed — fatal if .github/ can't be restored
git checkout "$BASE_SHA" -- .github/
# Optional — .agents/ may not exist in all repos
git checkout "$BASE_SHA" -- .agents/ 2>/dev/null || true |
||
|
|
||
| The script: | ||
| 1. Verifies the PR author has write access and rejects fork PRs | ||
| 2. Captures the base branch SHA before checkout | ||
| 3. Checks out the PR branch via `gh pr checkout` | ||
| 4. Restores `.github/skills/`, `.github/instructions/`, and `.github/copilot-instructions.md` from the base branch SHA (fatal on failure) | ||
|
|
||
| **Behavior by trigger:** | ||
| - **`workflow_dispatch`**: Platform checkout is skipped, so the script's restore IS the final workspace state (trusted files from base branch) | ||
| - **`slash_command`** (same-repo): Platform's `checkout_pr_branch.cjs` handles checkout. Skill files typically match main unless the PR modified them. | ||
| - **`slash_command`** (fork): Platform restores `.github/` from base branch artifact after checkout — fork cannot inject modified skills/instructions | ||
| - **`workflow_dispatch`**: Platform checkout is skipped, so the restore step IS the final workspace state (trusted files from base branch) | ||
| - **`slash_command`** (same-repo): Platform's `checkout_pr_branch.cjs` handles checkout. Agent infrastructure typically matches main unless the PR modified it. | ||
| - **`slash_command`** (fork): Platform restores `.github/` from base branch artifact after checkout — fork cannot inject modified agent infrastructure | ||
|
|
||
| **Note:** While the platform now handles `.github/` restore automatically for fork PRs, our `Checkout-GhAwPr.ps1` script still provides defense-in-depth for `workflow_dispatch` triggers (where platform checkout is skipped) and adds the write-access check that the platform doesn't enforce. | ||
| **Note:** While the platform now handles `.github/` restore automatically for fork PRs, a checkout-then-restore step still provides defense-in-depth for `workflow_dispatch` triggers (where platform checkout is skipped) and adds the write-access check that the platform doesn't enforce. | ||
|
|
||
| ### Anti-Patterns | ||
|
|
||
|
|
@@ -304,7 +311,7 @@ Skipping checkout means the agent evaluates the wrong files. The correct approac | |
| - name: Checkout PR | ||
| run: gh pr checkout "$PR_NUMBER" ... | ||
| - name: Run analysis | ||
| run: pwsh .github/skills/some-script.ps1 | ||
| run: pwsh .github/scripts/some-script.ps1 | ||
| ``` | ||
|
|
||
| If you need to run scripts, either: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 MODERATE — Comment claims "reject forks" but code doesn't
The comment says
# 1. Verify PR author has write access, reject forksbut the code only checks the permission level. The old script explicitly rejected fork PRs as a separate check. The write-access check does not fully subsume fork rejection: a collaborator with write access can still submit a PR from their personal fork, and that PR's.github/would be fork-controlled until the restore step runs.This is partially mitigated by the restore in step 4 (which overwrites
.github/from the base branch), but if the restore silently fails (see the|| truefinding above), the fork-controlled.github/survives.Suggested fix: Add an explicit fork check before the permission check:
Or remove "reject forks" from the comment to avoid being misleading.