Skip to content

Commit 6cfb1ba

Browse files
authored
Merge pull request #11 from awslabs/ayn-builds-patch-pr-guardrails
feat: add PR guardrails workflow
2 parents 54b5f6c + 73d872f commit 6cfb1ba

1 file changed

Lines changed: 169 additions & 0 deletions

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
name: PR Guardrails
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
guardrails:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
15+
with:
16+
fetch-depth: 0
17+
18+
- name: Get changed files
19+
id: changed
20+
run: |
21+
git diff --name-only origin/main...HEAD > /tmp/changed_files.txt
22+
if [ -s /tmp/changed_files.txt ]; then
23+
echo "has_changes=true" >> "$GITHUB_OUTPUT"
24+
fi
25+
26+
- name: Block OWNERS.yaml changes
27+
if: steps.changed.outputs.has_changes == 'true'
28+
run: |
29+
# Match OWNERS.yaml or OWNERS.yml at any path depth (case-insensitive)
30+
OWNERS_CHANGED=$(grep -iE "(^|/)OWNERS\.ya?ml$" /tmp/changed_files.txt || true)
31+
if [ -n "$OWNERS_CHANGED" ]; then
32+
echo "::error::OWNERS.yaml/yml files cannot be modified without admin approval:"
33+
echo "$OWNERS_CHANGED"
34+
exit 1
35+
fi
36+
37+
- name: Block plugin identity changes
38+
if: steps.changed.outputs.has_changes == 'true'
39+
run: |
40+
# Protected JSON fields in plugin.json and marketplace.json:
41+
# name – prevents rebranding / impersonation
42+
# author – prevents credit reassignment
43+
# description and version are intentionally not protected so contributors
44+
# can update them without admin approval.
45+
while IFS= read -r f; do
46+
case "$f" in
47+
.claude-plugin/marketplace.json|*/.claude-plugin/marketplace.json|*/plugin.json|plugin.json) ;;
48+
*) continue ;;
49+
esac
50+
if [ -f "$f" ]; then
51+
DIFF=$(git diff origin/main...HEAD -- "$f")
52+
CHANGED_FIELD=$(echo "$DIFF" | grep -oP '^\-\s*"\K(name|author)(?="\s*:)' | head -1 || true)
53+
if [ -n "$CHANGED_FIELD" ]; then
54+
echo "::error::Plugin '$CHANGED_FIELD' in $f cannot be changed without admin approval"
55+
exit 1
56+
fi
57+
fi
58+
done < /tmp/changed_files.txt
59+
60+
- name: Block SKILL.md identity changes
61+
if: steps.changed.outputs.has_changes == 'true'
62+
run: |
63+
# Protected YAML frontmatter fields in SKILL.md:
64+
# name – prevents in-place skill rebranding
65+
# author – prevents credit reassignment
66+
# We compare the frontmatter between origin/main and HEAD to avoid false
67+
# positives from `name:` or `author:` lines that may appear in the
68+
# markdown body. Newly-added SKILL.md files (not present in origin/main)
69+
# are allowed through this check.
70+
extract_field() {
71+
# $1 = field name (read content from stdin)
72+
# Reads only the YAML frontmatter (between the first two `---` lines)
73+
# and returns the trimmed value (with surrounding quotes stripped).
74+
awk -v field="$1" '
75+
/^---[[:space:]]*$/ { c++; if (c==2) exit; next }
76+
c==1 {
77+
if (match($0, "^[[:space:]]*" field "[[:space:]]*:[[:space:]]*")) {
78+
v = substr($0, RLENGTH+1)
79+
sub(/[[:space:]]+$/, "", v)
80+
# Strip surrounding double or single quotes if present
81+
if (v ~ /^".*"$/ || v ~ /^'\''.*'\''$/) {
82+
v = substr(v, 2, length(v)-2)
83+
}
84+
print v
85+
exit
86+
}
87+
}
88+
'
89+
}
90+
91+
while IFS= read -r f; do
92+
case "$f" in */SKILL.md|SKILL.md) ;; *) continue ;; esac
93+
[ -f "$f" ] || continue
94+
# Skip newly-added skills (no base version to compare against)
95+
if ! git cat-file -e "origin/main:$f" 2>/dev/null; then
96+
continue
97+
fi
98+
for field in name author; do
99+
BASE_VAL=$(git show "origin/main:$f" | extract_field "$field")
100+
HEAD_VAL=$(extract_field "$field" < "$f")
101+
if [ "$BASE_VAL" != "$HEAD_VAL" ]; then
102+
echo "::error::Skill '$field' changed in $f (was: '$BASE_VAL', now: '$HEAD_VAL') — requires admin approval"
103+
exit 1
104+
fi
105+
done
106+
done < /tmp/changed_files.txt
107+
108+
- name: Block skill directory renames
109+
if: steps.changed.outputs.has_changes == 'true'
110+
run: |
111+
# Use --name-status so we can inspect both source and destination paths.
112+
# Matches D (deleted) and R### (renamed) entries where either the source
113+
# or destination path includes SKILL.md.
114+
AFFECTED=$(git diff --diff-filter=DR --name-status --find-renames origin/main...HEAD \
115+
| awk '/SKILL\.md/' || true)
116+
if [ -n "$AFFECTED" ]; then
117+
echo "::error::Skill files cannot be renamed or deleted without admin approval:"
118+
echo "$AFFECTED"
119+
exit 1
120+
fi
121+
122+
- name: Enforce folder scoping
123+
if: steps.changed.outputs.has_changes == 'true'
124+
run: |
125+
# Get top-level folders touched (excluding dotfiles/dotfolders and root-level files)
126+
FOLDERS=$(grep "/" /tmp/changed_files.txt | cut -d'/' -f1 | sort -u | grep -v '^\.' || true)
127+
# Contributors should only touch ONE team folder per PR
128+
if [ -z "$FOLDERS" ]; then
129+
FOLDER_COUNT=0
130+
else
131+
FOLDER_COUNT=$(echo "$FOLDERS" | wc -l)
132+
fi
133+
if [ "$FOLDER_COUNT" -gt 1 ]; then
134+
echo "::error::PR touches multiple top-level folders. Each PR should be scoped to a single team folder:"
135+
echo "$FOLDERS"
136+
exit 1
137+
fi
138+
# Block changes to root-level files (admins only via CODEOWNERS)
139+
# Allowlist: files contributors may legitimately touch
140+
ALLOWED_ROOT="^(CONTRIBUTING\.md|CODE_OF_CONDUCT\.md)$"
141+
ROOT_FILES=$(grep -v "/" /tmp/changed_files.txt | grep -v '^\.' | grep . || true)
142+
if [ -n "$ROOT_FILES" ]; then
143+
BLOCKED=$(echo "$ROOT_FILES" | grep -vE "$ALLOWED_ROOT" || true)
144+
if [ -n "$BLOCKED" ]; then
145+
echo "::error::Root-level files cannot be modified by contributors:"
146+
echo "$BLOCKED"
147+
exit 1
148+
fi
149+
fi
150+
151+
- name: Check for risky code patterns
152+
if: steps.changed.outputs.has_changes == 'true'
153+
run: |
154+
FOUND=0
155+
while IFS= read -r f; do
156+
[ -f "$f" ] || continue
157+
# Skip config and documentation files
158+
case "$f" in *.md|*.json|*.yaml|*.yml|*.toml|*.txt|*.csv) continue ;; esac
159+
ISSUES=$(grep -nE "(^|[^A-Za-z0-9_])(eval|exec|os\.system)\(|subprocess[^)]*shell\s*=\s*True|(curl|wget)\s+[^\|]*\|\s*(sh|bash)" "$f" 2>/dev/null || true)
160+
if [ -n "$ISSUES" ]; then
161+
echo "::warning::Risky patterns in $f:"
162+
echo "$ISSUES"
163+
FOUND=1
164+
fi
165+
done < /tmp/changed_files.txt
166+
if [ "$FOUND" -eq 1 ]; then
167+
echo "::error::Risky code patterns detected — requires admin review"
168+
exit 1
169+
fi

0 commit comments

Comments
 (0)