docs(agent-memory): spec L0-L3 tiered memory as a Claude Code hook layer #524
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --- | |
| name: Skill Security Audit | |
| 'on': | |
| pull_request: | |
| types: [opened, synchronize, reopened] | |
| paths: | |
| - 'engineering/**' | |
| - 'engineering-team/**' | |
| - 'business-growth/**' | |
| - 'c-level-advisor/**' | |
| - 'documentation/**' | |
| - 'finance/**' | |
| - 'marketing-skill/**' | |
| - 'product-team/**' | |
| - 'project-management/**' | |
| - 'ra-qm-team/**' | |
| - 'agents/**' | |
| - 'templates/**' | |
| concurrency: | |
| group: security-audit-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| jobs: | |
| detect-changes: | |
| name: Detect changed skills | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| skills: ${{ steps.find.outputs.skills }} | |
| has_skills: ${{ steps.find.outputs.has_skills }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Find changed skill directories | |
| id: find | |
| run: | | |
| # Get list of changed files in PR | |
| CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD 2>/dev/null || echo "") | |
| if [ -z "$CHANGED" ]; then | |
| echo "skills=[]" >> "$GITHUB_OUTPUT" | |
| echo "has_skills=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Extract unique skill root directories (top-level dirs containing SKILL.md) | |
| SKILLS=() | |
| SEEN=() | |
| while IFS= read -r file; do | |
| # Get the top-level directory | |
| dir=$(echo "$file" | cut -d'/' -f1-2) | |
| # Skip non-skill paths | |
| case "$dir" in | |
| .github/*|.claude/*|.codex/*|.gemini/*|docs/*|scripts/*|commands/*|standards/*|eval-workspace/*) continue ;; | |
| */.claude-plugin) continue ;; | |
| esac | |
| # Check if this directory has a SKILL.md (is a skill) | |
| skill_root=$(echo "$file" | cut -d'/' -f1) | |
| # Walk up to find the skill root that contains SKILL.md | |
| for candidate in "$skill_root" "$dir"; do | |
| if [ -f "$candidate/SKILL.md" ] && [[ ! " ${SEEN[*]} " =~ " $candidate " ]]; then | |
| SKILLS+=("$candidate") | |
| SEEN+=("$candidate") | |
| break | |
| fi | |
| done | |
| done <<< "$CHANGED" | |
| if [ ${#SKILLS[@]} -eq 0 ]; then | |
| echo "skills=[]" >> "$GITHUB_OUTPUT" | |
| echo "has_skills=false" >> "$GITHUB_OUTPUT" | |
| else | |
| # Build JSON array | |
| JSON="[" | |
| for i in "${!SKILLS[@]}"; do | |
| [ $i -gt 0 ] && JSON+="," | |
| JSON+="\"${SKILLS[$i]}\"" | |
| done | |
| JSON+="]" | |
| echo "skills=$JSON" >> "$GITHUB_OUTPUT" | |
| echo "has_skills=true" >> "$GITHUB_OUTPUT" | |
| echo "Changed skills: $JSON" | |
| fi | |
| audit: | |
| name: Security audit | |
| needs: detect-changes | |
| if: needs.detect-changes.outputs.has_skills == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Run security auditor on changed skills | |
| id: audit | |
| run: | | |
| AUDITOR="engineering/skills/skill-security-auditor/scripts/skill_security_auditor.py" | |
| SKILLS='${{ needs.detect-changes.outputs.skills }}' | |
| REPORT_FILE=$(mktemp) | |
| OVERALL_EXIT=0 | |
| echo "## 🔒 Skill Security Audit Results" > "$REPORT_FILE" | |
| echo "" >> "$REPORT_FILE" | |
| # Parse JSON array of skill dirs | |
| for skill_dir in $(echo "$SKILLS" | python3 -c "import sys,json; [print(s) for s in json.load(sys.stdin)]"); do | |
| echo "::group::Auditing $skill_dir" | |
| echo "Scanning: $skill_dir" | |
| # Run auditor in strict mode with JSON output | |
| JSON_OUT=$(python3 "$AUDITOR" "$skill_dir" --strict --json 2>&1) && EXIT_CODE=$? || EXIT_CODE=$? | |
| # Try to parse JSON output | |
| VERDICT=$(echo "$JSON_OUT" | python3 -c " | |
| import sys, json | |
| try: | |
| d = json.load(sys.stdin) | |
| v = d.get('verdict', 'UNKNOWN') | |
| c = d.get('summary', {}).get('critical', 0) | |
| h = d.get('summary', {}).get('high', 0) | |
| i = d.get('summary', {}).get('info', 0) | |
| t = d.get('summary', {}).get('total', 0) | |
| print(f'{v}|{c}|{h}|{i}|{t}') | |
| except: | |
| print('ERROR|0|0|0|0') | |
| " 2>/dev/null || echo "ERROR|0|0|0|0") | |
| IFS='|' read -r V CRIT HIGH INFO TOTAL <<< "$VERDICT" | |
| # Map verdict to emoji | |
| case "$V" in | |
| PASS) ICON="✅" ;; | |
| WARN) ICON="⚠️" ;; | |
| FAIL) ICON="❌"; OVERALL_EXIT=1 ;; | |
| *) ICON="❓"; OVERALL_EXIT=1 ;; | |
| esac | |
| echo "### $ICON \`$skill_dir\` — $V" >> "$REPORT_FILE" | |
| echo "" >> "$REPORT_FILE" | |
| if [ "$TOTAL" -gt 0 ]; then | |
| echo "| Severity | Count |" >> "$REPORT_FILE" | |
| echo "|----------|-------|" >> "$REPORT_FILE" | |
| [ "$CRIT" -gt 0 ] && echo "| 🔴 Critical | $CRIT |" >> "$REPORT_FILE" | |
| [ "$HIGH" -gt 0 ] && echo "| 🟡 High | $HIGH |" >> "$REPORT_FILE" | |
| [ "$INFO" -gt 0 ] && echo "| ⚪ Info | $INFO |" >> "$REPORT_FILE" | |
| echo "" >> "$REPORT_FILE" | |
| # Include finding details for WARN/FAIL | |
| if [ "$V" != "PASS" ]; then | |
| echo "<details><summary>Findings detail</summary>" >> "$REPORT_FILE" | |
| echo "" >> "$REPORT_FILE" | |
| echo '```json' >> "$REPORT_FILE" | |
| echo "$JSON_OUT" | python3 -c " | |
| import sys, json | |
| try: | |
| d = json.load(sys.stdin) | |
| findings = d.get('findings', []) | |
| for f in findings: | |
| if f.get('severity') in ('CRITICAL', 'HIGH'): | |
| print(json.dumps(f, indent=2)) | |
| except: | |
| pass | |
| " >> "$REPORT_FILE" | |
| echo '```' >> "$REPORT_FILE" | |
| echo "</details>" >> "$REPORT_FILE" | |
| echo "" >> "$REPORT_FILE" | |
| fi | |
| else | |
| echo "No findings." >> "$REPORT_FILE" | |
| echo "" >> "$REPORT_FILE" | |
| fi | |
| echo "::endgroup::" | |
| done | |
| # Save report for comment step | |
| echo "report_file=$REPORT_FILE" >> "$GITHUB_OUTPUT" | |
| echo "exit_code=$OVERALL_EXIT" >> "$GITHUB_OUTPUT" | |
| - name: Post audit results to job summary | |
| if: always() | |
| run: | | |
| REPORT_FILE="${{ steps.audit.outputs.report_file }}" | |
| if [ -f "$REPORT_FILE" ]; then | |
| cat "$REPORT_FILE" >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Post audit results as PR comment | |
| if: always() | |
| continue-on-error: true # Fork PRs have read-only GITHUB_TOKEN | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const reportFile = '${{ steps.audit.outputs.report_file }}'; | |
| let body = '## 🔒 Skill Security Audit\n\nNo report generated.'; | |
| try { | |
| body = fs.readFileSync(reportFile, 'utf8'); | |
| } catch (e) { | |
| console.log('Could not read report file:', e.message); | |
| } | |
| // Find and update existing comment or create new | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| }); | |
| const marker = '## 🔒 Skill Security Audit'; | |
| const existing = comments.find(c => c.body.startsWith(marker)); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existing.id, | |
| body: body, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body: body, | |
| }); | |
| } | |
| - name: Fail on critical findings | |
| if: steps.audit.outputs.exit_code == '1' | |
| run: | | |
| echo "::error::Security audit found CRITICAL findings. Merge blocked." | |
| exit 1 |