diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 4a84520..2f83a4f 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "Copilot CLI plugins for migrating CI/CD pipelines to GitHub Actions", - "version": "1.3.0" + "version": "1.4.0" }, "plugins": [ { "name": "actions-migrator", "description": "Migrate CI/CD pipelines from Jenkins, Azure DevOps, CircleCI, GitLab, Travis CI, Bamboo, Bitbucket Pipelines, and Drone CI to GitHub Actions. Includes a Reusable Workflow Builder that detects cross-platform patterns across multiple organizations.", - "version": "1.3.0", + "version": "1.4.0", "source": "./plugin" } ] diff --git a/.github/workflows/hooks-test.yml b/.github/workflows/hooks-test.yml new file mode 100644 index 0000000..4cf6cef --- /dev/null +++ b/.github/workflows/hooks-test.yml @@ -0,0 +1,34 @@ +name: Hook Contract Tests + +# Runs the plugin hook regression suite on every change to the hooks so a +# schema or behavior change that would silently break the CLI, Cloud agent, or +# VS Code surface fails the PR instead of shipping unnoticed. + +on: + push: + branches: [main] + paths: + - 'plugin/hooks.json' + - 'plugin/hooks.test.sh' + - '.github/workflows/hooks-test.yml' + pull_request: + paths: + - 'plugin/hooks.json' + - 'plugin/hooks.test.sh' + - '.github/workflows/hooks-test.yml' + +permissions: + contents: read + +jobs: + hook-contract-tests: + runs-on: ubuntu-latest + steps: + # actions/checkout v4.2.2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + + - name: Validate hooks.json is parseable + run: jq empty plugin/hooks.json + + - name: Run hook contract tests (CLI + VS Code schemas) + run: bash plugin/hooks.test.sh diff --git a/consumer-template/.github/copilot/settings.json b/consumer-template/.github/copilot/settings.json new file mode 100644 index 0000000..cd86f8a --- /dev/null +++ b/consumer-template/.github/copilot/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "actions-migrator@actions-migrations-via-copilot": true + } +} diff --git a/consumer-template/README.md b/consumer-template/README.md new file mode 100644 index 0000000..ba35a38 --- /dev/null +++ b/consumer-template/README.md @@ -0,0 +1,34 @@ +# Consumer Template + +Drop the contents of this directory into the **root of any repo** that should +use the `actions-migrator` plugin. + +It enables the plugin (and its hooks) automatically on three surfaces: + +| Surface | What happens | Requires | +| --- | --- | --- | +| Copilot CLI | `enabledPlugins` auto-installs on next `copilot` startup | CLI ≥ 1.0.60 | +| Copilot cloud agent (github.com) | sweagentd loads plugin into the job sandbox; hooks fire | Repo, org, or enterprise scope. `CopilotSWEAgentPluginsEnabled` enabled. | +| VS Code Agent Plugins (preview) | Plugin shows up in `@agentPlugins` recommendations | `chat.plugins.enabled: true` (org policy) | + +## Files + +| Path | Purpose | +| --- | --- | +| `.github/copilot/settings.json` | Declares the plugin in `enabledPlugins`. The single line that wires up all three surfaces. | + +## Optional: org-wide rollout + +Put the same `.github/copilot/settings.json` in your org's `.github` or +`.github-private` repo. Every repo in the org inherits it. Enterprise admins +can do the same in the designated `.github-private` to enforce it across all +orgs. + +Merge precedence in CCA is **enterprise > org > repo**. + +## Sanity check after merging + +```bash +# After a migration session, the consumer repo should have: +test -s .github/MIGRATION-SCORECARD.md && echo SCORECARD_OK +``` diff --git a/plugin/README.md b/plugin/README.md index 0b5facb..90d9730 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -92,6 +92,62 @@ This replaces the previous pattern of agents fetching `knowledge/*.md` files at --- +## Hooks — Deterministic Enforcement + +The plugin includes hooks that run deterministic checks during migrations. Unlike skills and agent instructions (which the model can choose to ignore), hooks execute as shell commands at specific lifecycle points and can **block** operations or **inject warnings** into the agent's context. + +### `hooks.json` + +| Hook | Event | Matcher | What it does | +|------|-------|---------|-------------| +| Secret detection | `preToolUse` | `create\|edit` | Hard-denies file writes containing hardcoded secrets (passwords, tokens, API keys). Forces use of `${{ secrets.NAME }}`. Uses `permissionDecision: "deny"`. | +| File deletion guard | `preToolUse` | `bash` | Hard-denies `rm` operations outside `.github/ci-archive/`. Prevents accidental deletion of application source code. | +| Quality check + actionlint | `postToolUse` | `create\|edit` | After any workflow file write, injects `additionalContext` with: unpinned actions (tag vs SHA), placeholder text (TODO/FIXME), over-broad permissions (`write-all`), missing permissions block, and actionlint errors. The agent sees these on the same turn. | +| **Quality gate** | `agentStop` | — | Scans ALL workflow files when the agent finishes a turn. If any have issues, returns `decision: "block"` forcing the agent to take another turn to fix them. Safety valve releases after 3 attempts to prevent infinite loops. | +| **Migration scorecard** | `sessionEnd` (CLI) / `Stop` (VS Code) | — | Appends an entry to `.github/MIGRATION-SCORECARD.md` with session ID, timestamp, completion reason, and per-file workflow table (total / clean / with-issues). Multiple passes show quality progression. Audit artifact for migration quality tracking. | + +The hooks run on all three Copilot surfaces — **CLI**, **Cloud agent**, and **VS Code Agent Plugins** — from this single `hooks.json`. The surfaces send different payload schemas (e.g. CLI `toolName`/`toolArgs`-string vs VS Code `tool_name`/`tool_input`-object, and CLI `sessionEnd` vs VS Code `Stop`); each hook normalizes its input and emits both output shapes so the same file works everywhere. + +### Why hooks matter + +The `migration-core` skill already contains guardrails as agent instructions. Hooks add a **deterministic layer** on supported tool/event paths. This is the difference between "please don't delete files outside ci-archive" (instruction) and "the system can reject the tool call" (hook). + +**actionlint** is used opportunistically when available in the environment: `postToolUse` gives per-file feedback, `agentStop`/`Stop` enforce the quality gate, and `sessionEnd`/`Stop` append scorecard entries. If actionlint is unavailable, hooks still run static workflow checks and surface those findings. + +**The quality gate** (`agentStop` on CLI/Cloud and `Stop` on VS Code) is the key enforcement mechanism. Instead of only warning after each file write, it scans all workflow files at turn-end and can return a block decision when issues remain. A built-in attempt cap prevents infinite loops. + +### Enabling hooks + +Hooks are installed automatically with the plugin. To verify: + +```bash +copilot +/hooks list +``` + +To disable hooks temporarily (e.g., for debugging): + +```bash +copilot --disable-hooks +``` + +### Testing the hooks + +The hooks are covered by a contract test suite that pins their behavior on **both** the CLI and VS Code payload schemas, so a change that silently breaks one surface fails loudly instead of shipping unnoticed. + +```bash +# from the repo root +bash plugin/hooks.test.sh +``` + +What it checks (22 cases): secret-detection deny/allow, destructive-op guard (rm/mv/git mv/find -delete, path traversal, CI-source archival, shell redirects), workflow quality flags, and scorecard generation including the VS Code `Stop` loop-guard — each exercised against both the CLI (`toolName`/`toolArgs`-string) and VS Code (`tool_name`/`tool_input`-object) shapes. + +**Requirements:** `bash` and `jq` only. The suite is self-contained — it does **not** require `actionlint`, network access, `curl`, or `brew` (workflow-quality checks fall back to static `grep` analysis when `actionlint` is unavailable), it writes nothing to the working tree, and it cleans up its own temp files. If `jq` is missing it exits with a clear `FATAL: jq is required` message. + +**CI:** [`.github/workflows/hooks-test.yml`](../.github/workflows/hooks-test.yml) runs this suite on every pull request that touches `plugin/hooks.json` or `plugin/hooks.test.sh`, and validates that `hooks.json` parses. A regression blocks the PR. + +--- + ## Customizing Skills Customizing skills is the CLI plugin's equivalent of editing the `knowledge/` knowledge base in the [cloud-agent deployment](../docs/deployment.md). Because the plugin ships content **locally**, your edits take effect on the next `copilot plugin install ./plugin`—no `.github-private` push, no MCP round-trip. diff --git a/plugin/hooks.json b/plugin/hooks.json new file mode 100644 index 0000000..35bc2f0 --- /dev/null +++ b/plugin/hooks.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "description": "Block hardcoded secrets in file writes (CLI + VS Code) \u2014 denies with session-level additionalContext per v4 pattern", + "matcher": "create|edit|bash|run_in_terminal", + "timeoutSec": 10, + "bash": "if ! command -v jq >/dev/null 2>&1; then\n cat <<'JSON_EOF'\n{\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Hook enforcement unavailable: jq is not installed. Install jq (brew install jq / apt install jq / choco install jq).\",\"additionalContext\":\"REPOSITORY POLICY: enforcement hooks require jq. Do NOT retry via alternate tools. Install jq and re-run.\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Hook enforcement unavailable: jq is not installed.\"}}\nJSON_EOF\n exit 0\nfi\nINPUT=$(cat)\nTOOL=$(echo \"$INPUT\" | jq -r '.toolName // .tool_name // empty' 2>/dev/null)\nARGS=$(echo \"$INPUT\" | jq -c 'if (.toolArgs|type)==\"string\" then (try (.toolArgs|fromjson) catch {}) elif (.toolArgs|type)==\"object\" then .toolArgs elif (.tool_input|type)==\"object\" then .tool_input else {} end' 2>/dev/null)\nCONTENT=$(echo \"$ARGS\" | jq -r '.content // .new_string // .newString // empty' 2>/dev/null)\nCMD=$(echo \"$ARGS\" | jq -r '.command // empty' 2>/dev/null)\n\nhit_content=0\nif [ -n \"$CONTENT\" ]; then\n hit_content=$(echo \"$CONTENT\" | while IFS= read -r line; do\n echo \"$line\" | grep -qiE '\"?(password|secret|token|api[_-]?key)\"?\\s*[:=]' 2>/dev/null || continue\n echo \"$line\" | sed -E 's/^.*[:=][[:space:]]*//' | grep -qE '^[$][{(]' 2>/dev/null && continue\n echo \"$line\" | grep -qiE '[:=]\\s*.{8,}' 2>/dev/null && echo HIT\n done | grep -c HIT)\nfi\n\nhit_cmd=0\ncase \"$TOOL\" in\n bash|run_in_terminal)\n if [ -n \"$CMD\" ]; then\n # scan line-by-line: only exempt individual lines that themselves use ${...}\n # so a heredoc with a real secret + unrelated ${{ github.ref }} elsewhere cannot bypass\n if echo \"$CMD\" | grep -qE '(>|>>|<<|<<<|cat[[:space:]]|printf[[:space:]]|echo[[:space:]])' 2>/dev/null; then\n hit_cmd=$(echo \"$CMD\" | while IFS= read -r line; do\n echo \"$line\" | grep -qiE '\"?(password|secret|token|api[_-]?key)\"?\\s*[:=]' 2>/dev/null || continue\n echo \"$line\" | sed -E 's/^.*[:=][[:space:]]*//' | grep -qE '^[$][{(]' 2>/dev/null && continue\n echo \"$line\" | grep -qiE '[:=]\\s*[^$[:space:]][^[:space:]]{7,}' 2>/dev/null && echo HIT\n done | grep -c HIT)\n fi\n fi\n ;;\nesac\n\nif [ \"${hit_content:-0}\" -gt 0 ] || [ \"${hit_cmd:-0}\" -gt 0 ]; then\n R='Blocked: hardcoded secret detected. Use GitHub Secrets (${ secrets.NAME }) instead.'\n AC='REPOSITORY POLICY: hardcoded secrets are not permitted in any write path, including shell redirects/heredocs. Do NOT retry through alternate tools. Replace literal secret values with GitHub Actions secrets context references.'\n jq -n --arg r \"$R\" --arg ac \"$AC\" '{permissionDecision:\"deny\",permissionDecisionReason:$r,additionalContext:$ac,hookSpecificOutput:{hookEventName:\"PreToolUse\",permissionDecision:\"deny\",permissionDecisionReason:$r,additionalContext:$ac}}'\nelse\n echo '{}'\nfi" + }, + { + "type": "command", + "description": "Guard destructive ops; allow CI archival (CLI + VS Code) \u2014 denies with session-level additionalContext per v4 pattern", + "matcher": "bash|run_in_terminal", + "timeoutSec": 10, + "bash": "if ! command -v jq >/dev/null 2>&1; then\n cat <<'JSON_EOF'\n{\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Hook enforcement unavailable: jq is not installed. Install jq (brew install jq / apt install jq / choco install jq).\",\"additionalContext\":\"REPOSITORY POLICY: enforcement hooks require jq. Do NOT retry via alternate tools. Install jq and re-run.\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Hook enforcement unavailable: jq is not installed.\"}}\nJSON_EOF\n exit 0\nfi\nINPUT=$(cat)\nARGS=$(echo \"$INPUT\" | jq -c 'if (.toolArgs|type)==\"string\" then (try (.toolArgs|fromjson) catch {}) elif (.toolArgs|type)==\"object\" then .toolArgs elif (.tool_input|type)==\"object\" then .tool_input else {} end' 2>/dev/null)\nCMD=$(echo \"$ARGS\" | jq -r '.command // empty' 2>/dev/null)\n[ -z \"$CMD\" ] && { echo '{}'; exit 0; }\n\nallow_ci_src_regex='^(Jenkinsfile|\\.travis\\.yml|\\.gitlab-ci\\.yml|\\.drone\\.yml|bitbucket-pipelines\\.yml|azure-pipelines\\.yml|bamboo-specs/.+|\\.circleci/.+)$'\ndenied=0\nreason=''\n\nis_ci_archive() {\n # Accept either the relative form or an absolute path anchored at the hook's PWD.\n # Reject arbitrary external directories (e.g. /tmp/x/.github/ci-archive/) that\n # merely happen to end with the same suffix.\n local p=\"${1%/}\"\n case \"$p\" in\n .github/ci-archive|.github/ci-archive/*) return 0 ;;\n esac\n local base=\"${PWD}/.github/ci-archive\"\n case \"$p\" in\n \"$base\"|\"$base\"/*) return 0 ;;\n esac\n return 1\n}\n\nset_deny() {\n denied=1\n reason=\"$1\"\n}\n\nwhile IFS= read -r raw; do\n seg=$(echo \"$raw\" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')\n [ -z \"$seg\" ] && continue\n\n # Bug 8 fix \u2014 unwrap shell-wrapper commands so the destructive-op regex can see the\n # actual payload. bash -c 'rm README.md' would otherwise slip past the (^|space)rm(space|$)\n # anchor because the character before rm is a quote.\n if echo \"$seg\" | grep -qE '(^|[[:space:]])(bash|sh|zsh|dash|ksh|ash)[[:space:]]+(-[a-zA-Z]*c|--command)([[:space:]]|$)'; then\n inner=$(echo \"$seg\" | sed -E \"s/^.*[[:space:]](-[a-zA-Z]*c|--command)[[:space:]]+['\\\"]?(.*)$/\\2/\" | sed -E \"s/['\\\"][[:space:]]*$//\")\n # Additionally split the inner payload on shell operators \u2014 a wrapped multi-command\n # like bash -c 'rm A; mv B' can hide multiple destructive ops behind one segment.\n [ -n \"$inner\" ] && seg=\"$inner\"\n fi\n\n op=''\n echo \"$seg\" | grep -qE '(^|[[:space:]])find[[:space:]].*-delete([[:space:]]|$)' && op='finddelete'\n [ -z \"$op\" ] && echo \"$seg\" | grep -qE '(^|[[:space:]])(sudo[[:space:]]+|command[[:space:]]+|builtin[[:space:]]+|/usr/bin/env[[:space:]]+)*(\\/bin\\/|\\/usr\\/bin\\/)?rm([[:space:]]|$)' && op='rm'\n [ -z \"$op\" ] && echo \"$seg\" | grep -qE '(^|[[:space:]])(sudo[[:space:]]+|command[[:space:]]+|builtin[[:space:]]+|/usr/bin/env[[:space:]]+)*(\\/bin\\/|\\/usr\\/bin\\/)?unlink([[:space:]]|$)' && op='unlink'\n [ -z \"$op\" ] && echo \"$seg\" | grep -qE '(^|[[:space:]])git[[:space:]]+rm([[:space:]]|$)' && op='gitrm'\n [ -z \"$op\" ] && echo \"$seg\" | grep -qE '(^|[[:space:]])git[[:space:]]+mv([[:space:]]|$)' && op='gitmv'\n [ -z \"$op\" ] && echo \"$seg\" | grep -qE '(^|[[:space:]])(sudo[[:space:]]+|command[[:space:]]+|builtin[[:space:]]+|/usr/bin/env[[:space:]]+)*(\\/bin\\/|\\/usr\\/bin\\/)?mv([[:space:]]|$)' && op='mv'\n [ -z \"$op\" ] && continue\n\n seg_clean=$(echo \"$seg\" | sed -E 's/[0-9]?>>?&?[0-9-]+//g; s/[0-9]?>>?[[:space:]]*[^[:space:]]+//g; s/<[[:space:]]*[^[:space:]]+//g')\n toks=$(echo \"$seg_clean\" | tr -s ' ' '\\n' | grep -vE '^(sudo|command|builtin|/usr/bin/env|git|rm|mv|unlink|/bin/rm|/usr/bin/rm|/bin/mv|/usr/bin/mv|find|-delete|-[a-zA-Z]+|--[a-zA-Z-]+|>|>>|<|2>|2>&1|&>)$' | grep -v '^$')\n\n for t in $toks; do\n bare=\"${t#./}\"\n case \"$bare\" in *..*) set_deny 'Blocked: path traversal (..) not allowed in destructive operations.' ;; esac\n [ \"$denied\" -eq 1 ] && break\n done\n [ \"$denied\" -eq 1 ] && break\n\n case \"$op\" in\n rm|unlink|gitrm|finddelete)\n for t in $toks; do\n bare=\"${t#./}\"\n [ \"$bare\" = \".\" ] && continue\n is_ci_archive \"$bare\" && continue\n set_deny \"Blocked: deletion outside .github/ci-archive/ is not allowed (${bare}).\"\n break\n done\n ;;\n mv|gitmv)\n # last operand is destination; ALL preceding tokens are sources.\n # each source must be a recognised CI source (or already under ci-archive).\n dst=$(echo \"$toks\" | tail -1)\n dst=\"${dst#./}\"\n is_ci_archive \"$dst\" || set_deny \"Blocked: move destination must be under .github/ci-archive/ (${dst}).\"\n if [ \"$denied\" -eq 0 ]; then\n src_count=$(echo \"$toks\" | wc -l | tr -d ' ')\n srcs=$(echo \"$toks\" | head -n $((src_count-1)))\n for src in $srcs; do\n src=\"${src#./}\"\n if is_ci_archive \"$src\"; then continue; fi\n if echo \"$src\" | grep -qE \"$allow_ci_src_regex\"; then continue; fi\n set_deny \"Blocked: only CI source files may be moved to archive (${src}).\"\n break\n done\n fi\n ;;\n esac\n\n [ \"$denied\" -eq 1 ] && break\ndone <<< \"$(echo \"$CMD\" | tr ';|&' '\\n')\"\n\nif [ \"$denied\" -eq 1 ]; then\n AC='REPOSITORY POLICY: destructive operations are restricted. Only CI-source archival into .github/ci-archive/ is allowed; standalone deletion outside archive is blocked.'\n jq -n --arg r \"$reason\" --arg ac \"$AC\" '{permissionDecision:\"deny\",permissionDecisionReason:$r,additionalContext:$ac,hookSpecificOutput:{hookEventName:\"PreToolUse\",permissionDecision:\"deny\",permissionDecisionReason:$r,additionalContext:$ac}}'\nelse\n echo '{}'\nfi" + } + ], + "postToolUse": [ + { + "type": "command", + "description": "Quality check + actionlint on workflow writes (CLI + VS Code)", + "matcher": "create|edit", + "timeoutSec": 60, + "bash": "if ! command -v jq >/dev/null 2>&1; then\n echo '{\"additionalContext\":\"WARNING: migration quality check skipped because jq is not installed. Install jq to enable enforcement.\",\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"WARNING: migration quality check skipped because jq is not installed.\"}}'\n exit 0\nfi\nINPUT=$(cat)\nARGS=$(echo \"$INPUT\" | jq -c 'if (.toolArgs|type)==\"string\" then (try (.toolArgs|fromjson) catch {}) elif (.toolArgs|type)==\"object\" then .toolArgs elif (.tool_input|type)==\"object\" then .tool_input else {} end' 2>/dev/null)\nFILE=$(echo \"$ARGS\" | jq -r '.filePath // .path // .file_path // empty' 2>/dev/null)\necho \"$FILE\" | grep -q '.github/workflows/' || { echo '{}'; exit 0; }\n[ -f \"$FILE\" ] || { echo '{}'; exit 0; }\n\nhas_unpinned_external() {\n awk '\n /uses:[[:space:]]*/ {\n ref=$0\n sub(/.*uses:[[:space:]]*/, \"\", ref)\n sub(/[[:space:]]*#.*/, \"\", ref)\n gsub(/[[:space:]]/, \"\", ref)\n if (ref ~ /^\\.?\\//) next\n if (ref ~ /^docker:\\/\\//) next\n if (ref !~ /@/) next\n n=split(ref,a,\"@\"); v=a[n]\n if (v !~ /^[0-9a-fA-F]{40}$/) bad=1\n }\n END { exit bad ? 0 : 1 }\n ' \"$1\"\n}\n\nW=''\nhas_unpinned_external \"$FILE\" && W=\"${W}unpinned-actions; \"\ngrep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$FILE\" 2>/dev/null && W=\"${W}placeholder-text; \"\ngrep -qE 'permissions:\\s*write-all' \"$FILE\" 2>/dev/null && W=\"${W}write-all-permissions; \"\ngrep -qE '^permissions:' \"$FILE\" 2>/dev/null || W=\"${W}missing-permissions-block; \"\n\nL=''\nif command -v actionlint >/dev/null 2>&1; then\n L=$(actionlint \"$FILE\" 2>&1 | head -5 | tr '\\n' ' ')\nfi\n\nif [ -n \"$W\" ] || [ -n \"$L\" ]; then\n MSG=\"MIGRATION QUALITY CHECK ($FILE): ${W}\"\n [ -n \"$L\" ] && MSG=\"${MSG}actionlint: ${L}\"\n jq -n --arg m \"$MSG\" '{additionalContext:$m,hookSpecificOutput:{hookEventName:\"PostToolUse\",additionalContext:$m}}'\nelse\n echo '{}'\nfi" + } + ], + "agentStop": [ + { + "type": "command", + "description": "Migration quality gate (CLI)", + "timeoutSec": 60, + "bash": "if ! command -v jq >/dev/null 2>&1; then\n echo '{\"decision\":\"block\",\"reason\":\"Migration quality gate unavailable: jq is not installed. Install jq and re-run.\",\"hookSpecificOutput\":{\"hookEventName\":\"AgentStop\",\"decision\":\"block\",\"reason\":\"Migration quality gate unavailable: jq is not installed.\"}}'\n exit 0\nfi\nscan_workflows() {\n CWD=\"$1\"\n LA=0\n command -v actionlint >/dev/null 2>&1 && LA=1\n ISSUES=''\n TOTAL=0; CLEAN=0; BAD=0; DETAILS=''\n for f in \"$CWD\"/.github/workflows/*.yml \"$CWD\"/.github/workflows/*.yaml; do\n [ -f \"$f\" ] || continue\n TOTAL=$((TOTAL+1))\n FN=$(basename \"$f\")\n W=''\n awk '\n /uses:[[:space:]]*/ {\n ref=$0\n sub(/.*uses:[[:space:]]*/, \"\", ref)\n sub(/[[:space:]]*#.*/, \"\", ref)\n gsub(/[[:space:]]/, \"\", ref)\n if (ref ~ /^\\.?\\//) next\n if (ref ~ /^docker:\\/\\//) next\n if (ref !~ /@/) next\n n=split(ref,a,\"@\"); v=a[n]\n if (v !~ /^[0-9a-fA-F]{40}$/) bad=1\n }\n END { exit bad ? 0 : 1 }\n ' \"$f\" && W=\"${W}unpinned-actions \"\n grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$f\" 2>/dev/null && W=\"${W}placeholders \"\n grep -qE 'permissions:\\s*write-all' \"$f\" 2>/dev/null && W=\"${W}write-all \"\n grep -qE '^permissions:' \"$f\" 2>/dev/null || W=\"${W}no-permissions \"\n [ \"$LA\" -eq 1 ] && ! actionlint \"$f\" >/dev/null 2>&1 && W=\"${W}actionlint-errors \"\n\n if [ -n \"$W\" ]; then\n BAD=$((BAD+1))\n ISSUES=\"${ISSUES}${FN}: ${W}; \"\n W2=$(echo \"$W\" | sed -E 's/[[:space:]]+$//' | sed 's/ /, /g')\n DETAILS=\"${DETAILS}| ${FN} | ${W2} |\\n\"\n else\n CLEAN=$((CLEAN+1))\n DETAILS=\"${DETAILS}| ${FN} | clean |\\n\"\n fi\n done\n}\n\nINPUT=$(cat)\nSID=$(echo \"$INPUT\" | jq -r '.sessionId // .session_id // \"default\"' 2>/dev/null)\nCWD=$(echo \"$INPUT\" | jq -r '.cwd // \"\"' 2>/dev/null)\n[ -z \"$CWD\" ] && CWD=\"${GITHUB_WORKSPACE:-$PWD}\"\nCF=\"/tmp/.migration-quality-gate-${SID}\"\nCOUNT=$(cat \"$CF\" 2>/dev/null || echo 0)\nCOUNT=$((COUNT+1))\necho \"$COUNT\" > \"$CF\"\nif [ \"$COUNT\" -gt 3 ]; then\n rm -f \"$CF\"\n echo '{}'\n exit 0\nfi\n\nscan_workflows \"$CWD\"\n\nif [ -n \"$ISSUES\" ]; then\n R=\"Migration quality gate FAILED (attempt ${COUNT}/3): ${ISSUES}Fix these before completing.\"\n jq -n --arg r \"$R\" '{decision:\"block\",reason:$r,hookSpecificOutput:{hookEventName:\"AgentStop\",decision:\"block\",reason:$r}}'\nelse\n rm -f \"$CF\"\n echo '{}'\nfi" + } + ], + "sessionEnd": [ + { + "type": "command", + "description": "Append migration scorecard (CLI)", + "timeoutSec": 45, + "bash": "if ! command -v jq >/dev/null 2>&1; then\n echo '{}'\n exit 0\nfi\nscan_workflows() {\n CWD=\"$1\"\n LA=0\n command -v actionlint >/dev/null 2>&1 && LA=1\n ISSUES=''\n TOTAL=0; CLEAN=0; BAD=0; DETAILS=''\n for f in \"$CWD\"/.github/workflows/*.yml \"$CWD\"/.github/workflows/*.yaml; do\n [ -f \"$f\" ] || continue\n TOTAL=$((TOTAL+1))\n FN=$(basename \"$f\")\n W=''\n awk '\n /uses:[[:space:]]*/ {\n ref=$0\n sub(/.*uses:[[:space:]]*/, \"\", ref)\n sub(/[[:space:]]*#.*/, \"\", ref)\n gsub(/[[:space:]]/, \"\", ref)\n if (ref ~ /^\\.?\\//) next\n if (ref ~ /^docker:\\/\\//) next\n if (ref !~ /@/) next\n n=split(ref,a,\"@\"); v=a[n]\n if (v !~ /^[0-9a-fA-F]{40}$/) bad=1\n }\n END { exit bad ? 0 : 1 }\n ' \"$f\" && W=\"${W}unpinned-actions \"\n grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$f\" 2>/dev/null && W=\"${W}placeholders \"\n grep -qE 'permissions:\\s*write-all' \"$f\" 2>/dev/null && W=\"${W}write-all \"\n grep -qE '^permissions:' \"$f\" 2>/dev/null || W=\"${W}no-permissions \"\n [ \"$LA\" -eq 1 ] && ! actionlint \"$f\" >/dev/null 2>&1 && W=\"${W}actionlint-errors \"\n\n if [ -n \"$W\" ]; then\n BAD=$((BAD+1))\n ISSUES=\"${ISSUES}${FN}: ${W}; \"\n W2=$(echo \"$W\" | sed -E 's/[[:space:]]+$//' | sed 's/ /, /g')\n DETAILS=\"${DETAILS}| ${FN} | ${W2} |\\n\"\n else\n CLEAN=$((CLEAN+1))\n DETAILS=\"${DETAILS}| ${FN} | clean |\\n\"\n fi\n done\n}\n\nINPUT=$(cat)\nCWD=$(echo \"$INPUT\" | jq -r '.cwd // \"\"' 2>/dev/null)\n[ -z \"$CWD\" ] && CWD=\"${GITHUB_WORKSPACE:-$PWD}\"\nREASON=$(echo \"$INPUT\" | jq -r '.reason // \"complete\"' 2>/dev/null)\nSESSION=$(echo \"$INPUT\" | jq -r '.sessionId // .session_id // \"unknown\"' 2>/dev/null)\n\nscan_workflows \"$CWD\"\n\nmkdir -p \"$CWD/.github\"\nSC=\"$CWD/.github/MIGRATION-SCORECARD.md\"\nNOW=$(date -u +\"%Y-%m-%dT%H:%M:%SZ\")\n{\n echo \"## ${NOW}\"\n echo \"- session: ${SESSION}\"\n echo \"- reason: ${REASON}\"\n echo \"- workflows: total=${TOTAL}, clean=${CLEAN}, with_issues=${BAD}\"\n echo\n echo \"| workflow | status |\"\n echo \"|---|---|\"\n printf \"%b\" \"$DETAILS\"\n echo\n} >> \"$SC\"\n\nrm -f \"/tmp/.migration-quality-gate-${SESSION}\" /tmp/.migration-quality-gate\nfind /tmp -maxdepth 1 -name '.migration-quality-gate-*' -type f -mmin +240 -delete 2>/dev/null || true\necho '{}' " + } + ], + "Stop": [ + { + "type": "command", + "description": "Migration quality gate + scorecard append (VS Code Stop event)", + "timeoutSec": 45, + "bash": "if ! command -v jq >/dev/null 2>&1; then\n echo '{\"decision\":\"block\",\"reason\":\"Migration scorecard unavailable: jq is not installed. Install jq (brew install jq / apt install jq / choco install jq) and re-run.\",\"hookSpecificOutput\":{\"hookEventName\":\"Stop\",\"decision\":\"block\",\"reason\":\"Migration scorecard unavailable: jq is not installed.\"}}'\n exit 0\nfi\nscan_workflows() {\n CWD=\"$1\"\n LA=0\n command -v actionlint >/dev/null 2>&1 && LA=1\n ISSUES=''\n TOTAL=0; CLEAN=0; BAD=0; DETAILS=''\n for f in \"$CWD\"/.github/workflows/*.yml \"$CWD\"/.github/workflows/*.yaml; do\n [ -f \"$f\" ] || continue\n TOTAL=$((TOTAL+1))\n FN=$(basename \"$f\")\n W=''\n awk '\n /uses:[[:space:]]*/ {\n ref=$0\n sub(/.*uses:[[:space:]]*/, \"\", ref)\n sub(/[[:space:]]*#.*/, \"\", ref)\n gsub(/[[:space:]]/, \"\", ref)\n if (ref ~ /^\\.?\\//) next\n if (ref ~ /^docker:\\/\\//) next\n if (ref !~ /@/) next\n n=split(ref,a,\"@\"); v=a[n]\n if (v !~ /^[0-9a-fA-F]{40}$/) bad=1\n }\n END { exit bad ? 0 : 1 }\n ' \"$f\" && W=\"${W}unpinned-actions \"\n grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$f\" 2>/dev/null && W=\"${W}placeholders \"\n grep -qE 'permissions:\\s*write-all' \"$f\" 2>/dev/null && W=\"${W}write-all \"\n grep -qE '^permissions:' \"$f\" 2>/dev/null || W=\"${W}no-permissions \"\n [ \"$LA\" -eq 1 ] && ! actionlint \"$f\" >/dev/null 2>&1 && W=\"${W}actionlint-errors \"\n\n if [ -n \"$W\" ]; then\n BAD=$((BAD+1))\n ISSUES=\"${ISSUES}${FN}: ${W}; \"\n W2=$(echo \"$W\" | sed -E 's/[[:space:]]+$//' | sed 's/ /, /g')\n DETAILS=\"${DETAILS}| ${FN} | ${W2} |\\n\"\n else\n CLEAN=$((CLEAN+1))\n DETAILS=\"${DETAILS}| ${FN} | clean |\\n\"\n fi\n done\n}\n\nINPUT=$(cat)\nACTIVE=$(echo \"$INPUT\" | jq -r '.stop_hook_active // false' 2>/dev/null)\n[ \"$ACTIVE\" = \"true\" ] && { echo '{}'; exit 0; }\nCWD=$(echo \"$INPUT\" | jq -r '.cwd // \"\"' 2>/dev/null)\n[ -z \"$CWD\" ] && CWD=\"${GITHUB_WORKSPACE:-$PWD}\"\nREASON=$(echo \"$INPUT\" | jq -r '.reason // \"complete\"' 2>/dev/null)\nSESSION=$(echo \"$INPUT\" | jq -r '.sessionId // .session_id // \"unknown\"' 2>/dev/null)\n\nCF=\"/tmp/.migration-quality-gate-${SESSION}\"\nCOUNT=$(cat \"$CF\" 2>/dev/null || echo 0)\nCOUNT=$((COUNT+1))\necho \"$COUNT\" > \"$CF\"\n\nscan_workflows \"$CWD\"\n\nif [ -n \"$ISSUES\" ] && [ \"$COUNT\" -le 3 ]; then\n R=\"Migration quality gate FAILED (attempt ${COUNT}/3): ${ISSUES}Fix these before completing.\"\n jq -n --arg r \"$R\" '{decision:\"block\",reason:$r,hookSpecificOutput:{hookEventName:\"Stop\",decision:\"block\",reason:$r}}'\n exit 0\nfi\n\nmkdir -p \"$CWD/.github\"\nSC=\"$CWD/.github/MIGRATION-SCORECARD.md\"\nNOW=$(date -u +\"%Y-%m-%dT%H:%M:%SZ\")\n{\n echo \"## ${NOW}\"\n echo \"- session: ${SESSION}\"\n echo \"- reason: ${REASON}\"\n echo \"- workflows: total=${TOTAL}, clean=${CLEAN}, with_issues=${BAD}\"\n echo\n echo \"| workflow | status |\"\n echo \"|---|---|\"\n printf \"%b\" \"$DETAILS\"\n echo\n} >> \"$SC\"\n\nif [ \"$COUNT\" -gt 3 ]; then\n rm -f \"$CF\"\nfi\necho '{}' " + } + ] + } +} diff --git a/plugin/hooks.test.sh b/plugin/hooks.test.sh new file mode 100755 index 0000000..07b598b --- /dev/null +++ b/plugin/hooks.test.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +# ============================================================================= +# hooks.test.sh — regression tests for plugin/hooks.json +# +# Verifies every hook behaves correctly against BOTH payload schemas: +# - Copilot CLI / Cloud agent: { "toolName": "...", "toolArgs": "", "sessionId": ... } +# - VS Code Agent Plugins: { "tool_name": "...", "tool_input": { ... }, "session_id": ... } +# +# Why this exists: the two surfaces send different field names, different arg +# encodings (string vs object), different tool names, and expect different +# output shapes. A change that silently breaks one surface would otherwise go +# unnoticed. This test pins the contract. +# +# Run: ./plugin/hooks.test.sh +# Exit: 0 = all pass, 1 = one or more failures +# +# Requires: jq, bash +# ============================================================================= + +set -uo pipefail + +HOOKS_JSON="$(cd "$(dirname "$0")" && pwd)/hooks.json" +PASS=0 +FAIL=0 + +if ! command -v jq >/dev/null 2>&1; then + echo "FATAL: jq is required to run these tests" >&2 + exit 1 +fi +if [ ! -f "$HOOKS_JSON" ]; then + echo "FATAL: hooks.json not found at $HOOKS_JSON" >&2 + exit 1 +fi + +# get_hook -> prints the bash body +get_hook() { jq -r ".hooks.\"$1\"[$2].bash" "$HOOKS_JSON"; } + +# run_case