Skip to content

feat: deterministic enforcement hooks for migration quality - #33

Open
AlexDeMichieli wants to merge 14 commits into
mainfrom
feat/plugin-hooks
Open

feat: deterministic enforcement hooks for migration quality#33
AlexDeMichieli wants to merge 14 commits into
mainfrom
feat/plugin-hooks

Conversation

@AlexDeMichieli

@AlexDeMichieli AlexDeMichieli commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

What this brings

This PR adds deterministic enforcement hooks to the actions-migrator plugin. The hooks run as shell commands outside the model — the agent does not generate them, parse them, or have to remember to run them — and they're registered once in plugin/hooks.json for all three Copilot surfaces (CLI, Cloud agent, and VS Code). On CLI and VS Code, a deny stops the agent because the user is the loop’s pacemaker. On Cloud agent, hooks fire and deny individual tool calls but the autonomous loop may retry through other write tools — see Enforcement strength varies by surface for the full picture and the additionalContext pattern that strengthens denials there.

Hooks overview

Hook Event Behavior
Secret detection preToolUse Denies file writes containing hardcoded secrets; emits an additionalContext policy rule for autonomous surfaces. Forces secrets.NAME references.
Destructive-op guard preToolUse Denies rm, mv, git rm, git mv, unlink, and find -delete outside .github/ci-archive/; emits an additionalContext policy rule for autonomous surfaces. Allows CI-source archival. Blocks path traversal.
Quality check + actionlint postToolUse After each workflow write, injects warnings into agent context (unpinned actions, placeholders, over-broad permissions, missing permissions, actionlint errors) on the same turn.
Quality gate agentStop (CLI) Scans all workflow files when the agent finishes a turn. Blocks completion if any workflow has issues, forcing a fix. 3-attempt safety valve prevents infinite loops.
Migration scorecard sessionEnd (CLI) / Stop (VS Code) Appends an entry to .github/MIGRATION-SCORECARD.md — an audit artifact with session ID, timestamp, completion reason, and per-file quality table. Appends rather than overwrites, so progression across passes is visible.

How enforcement works

Agent writes workflow → postToolUse injects quality warnings (same turn)
Agent finishes turn   → agentStop/Stop blocks if issues remain (forces another turn)
Agent blocked 3x      → safety valve releases
Session ends          → sessionEnd/Stop appends scorecard entry

The hooks run as shell commands invoked by the runtime, not advice the model has to remember. On CLI / VS Code that gives a hard-gate semantic (the agent stops on deny). On Cloud agent the agent may try alternate write tools after a deny — see Enforcement strength varies by surface. The deny outputs include additionalContext carrying a session-level policy so the autonomous loop sees the rule as it picks subsequent tools.

Cross-surface support (CLI + Cloud agent + VS Code)

Hooks must work on every surface a migration can run on. The three surfaces send different payload schemas, which we captured directly from live hook invocations:

Copilot CLI / Cloud agent VS Code Agent Plugins
tool name field toolName tool_name
tool args toolArgs (a JSON string) tool_input (an object)
session field sessionId session_id
tool result toolResult tool_response
tool names bash, create, edit run_in_terminal, create_file, replace_string_in_file
deny output top-level permissionDecision hookSpecificOutput.permissionDecision
lifecycle (gate/scorecard) agentStop + sessionEnd Stop
matchers honored parsed but ignored (every hook runs on every tool)

A single hooks.json adapts to both:

  • Normalized input: reads .toolName // .tool_name; parses args whether .toolArgs is a JSON string (fromjson) or .tool_input is an object; normalizes command, filePath/path/file_path, and content/new_string.
  • Dual output: emits both top-level and hookSpecificOutput shapes so each surface reads the field it expects.
  • Lifecycle under both names: sessionEnd (CLI) and Stop (VS Code) run the same scorecard script; the Stop variant honors stop_hook_active to avoid infinite loops.
  • Self-guarding hooks: because VS Code ignores matchers, each preToolUse hook no-ops when its field is absent.

actionlint auto-install

The three hooks that use actionlint install it automatically if absent:

  • Linux (Cloud agent): downloads pinned v1.7.11 binary from GitHub releases (checksum-verified).
  • macOS (CLI): brew install actionlint.
  • Already installed: skips with zero overhead.

This removes the dependency on the agent remembering to install the linter — the hook handles it deterministically.

Migration scorecard

sessionEnd/Stop appends to .github/MIGRATION-SCORECARD.md after each session. Multiple passes show quality progression with per-file detail:

# Migration Scorecard

## 2026-06-18T18:14:20Z
- Session: 357db4ba-e5b8-4edd-9427-c6ae319fe269
- Reason: complete
- Workflows: 1 total, 1 clean, 0 with issues

| File | Issues |
|------|--------|
| ci.yml | clean |

Each workflow is checked for unpinned actions (@v4 instead of SHA), placeholder text (TODO/FIXME/etc.), over-broad permissions (write-all), missing permissions block, and actionlint errors. A workflow must pass all checks to count as "clean."

Enforcement strength varies by surface

Hooks fire on all three surfaces, but how strongly they enforce depends on the execution model:

Surface Loop Hook deny means
CLI Interactive Hard gate — deny surfaces to the user, agent stops.
VS Code Interactive chat Hard gate — deny surfaces in chat, agent stops.
Cloud agent Autonomous Friction — tool.execution_complete: <tool> success=false is logged, the agent silently retries through other write tools (e.g., falls back from bash to apply_patch).

Verified in AlexDeMichieli/cca-hook-repro-consumer: a hook denying create|edit|write|str_replace|bash got bypassed when the agent routed the same write through apply_patch. Hook fired (success=false on the bash call); enforcement was not hard.

Implication: on Cloud agent, skills are the primary enforcement layer — the agent reading and following the rule in migration-core is more reliable than hooks alone, since hooks raise the retry cost but don't stop the agent. Hooks remain valuable as deterministic backstops on CLI/VS Code and as friction (plus per-call logging) on Cloud agent.

Tests

A committed test suite guards against silent breakage — critical because the surfaces use different schemas and a change that breaks one would otherwise go unnoticed.

  • plugin/hooks.test.sh — 22 contract tests exercising every hook against both CLI and VS Code payloads (deny / allow / quality-context / scorecard / loop-guard).
  • .github/workflows/hooks-test.yml — runs the suite on every PR touching the hooks and validates hooks.json parses. A schema or behavior change that breaks any surface now fails the PR.
  • Negative-tested: deliberately removing the VS Code arg parsing makes the suite fail exactly the 4 VS Code destructive-guard cases and exit non-zero — proving the tests catch this class of regression.

Validation performed

Surface How Result
Contract tests bash plugin/hooks.test.sh 22/22 pass (CLI + VS Code schemas)
VS Code (live) Agent-mode chat, captured raw hook stdin PreToolUse, PostToolUse, UserPromptSubmit, Stop all fire with correct create_file / run_in_terminal payloads
CLI (end-to-end) copilot --allow-all --agent actions-migrator:jenkins-migrator rm README.md blocked; Jenkinsfile migrated to clean ci.yml (12 SHA-pinned actions + least-privilege permissions); original archived via git mv; scorecard = 1 clean, 0 with issues

Issues found and fixed during testing:

  • git mv / mv / find -delete bypass of the original rm-only guard — the guard now covers all destructive verbs while still allowing CI-source archival into .github/ci-archive/.
  • Shell redirects (2>&1, > file) caused false-positive denies — the guard now strips redirect operators before checking targets.
  • Parallel sessions shared one /tmp quality-gate counter — now keyed per sessionId.
  • Cloud agent cwd — lifecycle hooks fall back to $GITHUB_WORKSPACE then $PWD so the scorecard lands in the cloned repo, not the plugin install dir.

Adoption

Consumers enable the plugin on all three surfaces with one committed file — see consumer-template/.github/copilot/settings.json:

{ "enabledPlugins": { "actions-migrator@actions-migrations-via-copilot": true } }

This single declaration drives the CLI auto-install, Cloud agent plugin load, and VS Code workspace recommendation. No personalized paths or per-user setup required.

Skills retained — complementary to hooks

The actionlint skill and the migration-core / platform skill guidance are intentionally kept. The earlier plan was to thin them out once hooks shipped, on the assumption hooks would deterministically replace skill-driven enforcement. End-to-end testing on Cloud agent (above) shows that's not safe today — the autonomous loop routes around hooks via alternate write tools.

So the layering is:

  • Skills = primary enforcement on Cloud agent (the agent following positive rules like "always SHA-pin")
  • Hooks = primary enforcement on CLI / VS Code (hard gate denials), friction + observability layer on Cloud agent
  • Together they form a belt-and-suspenders model across surfaces

No follow-up "skill removal" PR. Future PRs may reword guidance as positive instructions ("always use SHA pinning") rather than "the hook will block this," but the content stays.

Token cost reduction

Each LLM turn re-prompts the model with the full session context (which grows monotonically across the session), so turns are the unit of cost — not just dollars but also wall-clock latency. Hooks move deterministic procedural work out of those turns and into shell scripts that run in milliseconds.

Per workflow file migrated, hooks remove turns the agent would otherwise spend on:

Procedural work Without hooks With hooks
Decide to invoke actionlint, install it, run it, parse output ~2–3 turns 0 turns — postToolUse runs actionlint and injects results as additionalContext on the same turn the agent just wrote the file
Install actionlint on first use ~1 turn 0 turns — hook auto-downloads SHA-pinned binary or uses brew install
Write the migration scorecard ~1 turn 0 turns — sessionEnd / Stop hook appends it deterministically
Re-check workflow quality before declaring done ~1 turn (if the user re-prompts; often skipped, leading to bad output) 0 turns — agentStop gate forces another fix turn if issues remain

Aggregated across a real migration, that's roughly 4–5 turns shifted from model time to script time per workflow file. On a multi-file migration (10+ workflows) the savings compound — each saved turn is also ~5–15s of model latency, so token cost and wall-clock duration both drop.

Scope of the claim

  • No specific dollar / token figure is claimed — exact savings depend on the model in use, context size, retry behavior, and the workflow being migrated. The turn count reduction is the verifiable, model-independent measure.
  • On Cloud agent, preToolUse deny-style hooks (secret detection, destructive guard) are friction rather than hard gates — the autonomous agent may retry through alternate tools (verified in a minimal repro). This partially offsets savings from those specific hooks on that surface.
  • The postToolUse injection (actionlint warnings same-turn) and lifecycle hooks (sessionEnd scorecard, agentStop gate on CLI) save turns on all surfaces regardless of the agent's tool choices, because they run on every relevant event.

How to test it yourself

Contract tests (no Copilot session needed)

git clone https://github.com/github/actions-migrations-via-copilot.git
cd actions-migrations-via-copilot
bash plugin/hooks.test.sh        # 22/22 expected; requires bash + jq only

CLI (end-to-end)

# install the plugin from a local clone
copilot plugin install ./plugin

# in any repo with a CI source file (e.g. a Jenkinsfile):
copilot --allow-all --agent actions-migrator:jenkins-migrator -p \
  "Try to delete README.md via bash rm. Then migrate the Jenkinsfile to \
   .github/workflows/ci.yml with SHA-pinned actions and least-privilege \
   permissions, and archive the original to .github/ci-archive/ via git mv. \
   Report which steps the hook blocked. Do not create a PR."

# verify enforcement fired:
test -f README.md && echo "README intact (delete was blocked)"
grep -c '@[0-9a-f]\{40\}' .github/workflows/ci.yml   # SHA pins
cat .github/MIGRATION-SCORECARD.md                     # scorecard

VS Code Agent Plugins (preview)

// 1. VS Code user settings.json — enable plugins + point at the local clone
//    (For real adoption, users instead commit consumer-template/.github/copilot/settings.json
//     to their repo; the local path here is only for testing an un-published build.)
"chat.plugins.enabled": true,
"chat.pluginLocations": {
  "/absolute/path/to/actions-migrations-via-copilot/plugin": true
}
2. Developer: Reload Window
3. Open a repo containing a Jenkinsfile, open Copilot Chat in Agent mode, send:

   Use the jenkins-migrator agent. Try to delete README.md via the terminal,
   then migrate the Jenkinsfile to .github/workflows/ci.yml with SHA-pinned
   actions and least-privilege permissions, and archive the original to
   .github/ci-archive/ via git mv. Do not create a PR.

4. Expected: the README delete is blocked by the preToolUse hook; the workflow
   is created SHA-pinned with a permissions block; .github/MIGRATION-SCORECARD.md
   is written by the Stop hook.

To inspect raw hook execution in VS Code, open Output → "GitHub Copilot Chat Hooks" — it shows each hook firing with the stdin payload.

Cloud agent (github.com)

# in the target repo, commit the consumer template so the plugin auto-loads:
mkdir -p .github/copilot
cp /path/to/consumer-template/.github/copilot/settings.json .github/copilot/settings.json
git add .github/copilot/settings.json && git commit -m "Enable actions-migrator plugin" && git push
# then open an issue ("Migrate the Jenkinsfile to GitHub Actions") and assign Copilot;
# inspect the resulting PR for the SHA-pinned workflow and MIGRATION-SCORECARD.md.

Where to find hook logs

Hook execution surfaces differently on each surface — useful for debugging:

Surface Where to look
CLI Inline stdout/stderr of the copilot process. Denials print directly.
VS Code Output → "GitHub Copilot Chat Hooks" — shows each hook firing with the full stdin payload.
Cloud agent Two layers:
Action logs (workflow run "Running Copilot cloud agent") show high-level signal: [plugins] Resolved plugin "…" confirms the plugin loaded, and tool.execution_complete: <tool> success=false indicates a hook denied that call.
logs/.copilot/logs-process-X.log inside the action artifacts (surfaced in staging environments) contains the full hook execution detail, including the deny reason string. Confirmed by the CCA team.

Files changed

  • plugin/hooks.json — cross-surface enforcement hooks (secret detection, destructive guard, quality check, quality gate, scorecard) with auto-install and dual-schema input/output.
  • plugin/hooks.test.sh — 22 contract tests across CLI and VS Code schemas.
  • .github/workflows/hooks-test.yml — CI that runs the suite on every hooks change.
  • consumer-template/ — drop-in settings.json + README for enabling the plugin across surfaces.
  • plugin/README.md — hooks documentation.

@AlexDeMichieli
AlexDeMichieli force-pushed the feat/plugin-hooks branch 4 times, most recently from c99e748 to e18c746 Compare June 3, 2026 15:58
@AlexDeMichieli
AlexDeMichieli marked this pull request as ready for review June 4, 2026 20:50
@AlexDeMichieli
AlexDeMichieli requested a review from antgrutta as a code owner June 4, 2026 20:50
Copilot AI balanced review requested due to automatic review settings June 4, 2026 20:50
@AlexDeMichieli
AlexDeMichieli requested a review from ssulei7 as a code owner June 4, 2026 20:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a plugin/hooks.json hook pack for the Copilot CLI plugin to deterministically enforce migration-quality constraints (block/deny unsafe tool calls, inject workflow quality feedback, and produce an audit scorecard), and documents the new enforcement model in plugin/README.md.

Changes:

  • Adds deterministic enforcement hooks (preToolUse, postToolUse, agentStop, sessionEnd) in plugin/hooks.json.
  • Implements workflow “quality” detection (unpinned actions, placeholders, write-all, missing permissions, actionlint) and a blocking quality gate with a 3-attempt safety valve.
  • Documents hook behavior, rationale, and how to enable/disable hooks in plugin/README.md.
Show a summary per file
File Description
plugin/hooks.json Adds 5 hooks to block hardcoded secrets and unsafe deletions, lint/check workflows, enforce a blocking quality gate, and append a migration scorecard.
plugin/README.md Documents the hooks, their lifecycle events, and how to verify/disable them.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 7

Comment thread plugin/hooks.json Outdated
Comment thread plugin/hooks.json Outdated
Comment thread plugin/hooks.json Outdated
Comment thread plugin/hooks.json Outdated
Comment thread plugin/hooks.json Outdated
Comment thread plugin/README.md Outdated
Comment thread plugin/hooks.json Outdated
@antgrutta

Copy link
Copy Markdown
Collaborator

@AlexDeMichieli, please set up a meeting with @ssulei7 and myself for a quick demo. This is good stuff and we're excited to rubber duck a couple things with you.

GitHub Advanced Security started work on behalf of AlexDeMichieli June 11, 2026 19:55 View session
GitHub Advanced Security finished work on behalf of AlexDeMichieli June 11, 2026 19:56
GitHub Advanced Security started work on behalf of AlexDeMichieli June 11, 2026 19:59 View session
GitHub Advanced Security finished work on behalf of AlexDeMichieli June 11, 2026 20:01
… actionlint

Rebuild hooks.json with correct Copilot hooks API:
- preToolUse (matcher: create|edit): secret detection with permissionDecision deny
- preToolUse (matcher: bash): rm guard blocks deletion outside ci-archive
- postToolUse (matcher: create|edit): quality check + actionlint per workflow file
- agentStop: quality gate blocks agent completion until workflows pass (3-attempt safety valve)
- sessionEnd: generates MIGRATION-SCORECARD.md with session stats

Key changes from previous version:
- Use permissionDecision/permissionDecisionReason (not decision/reason)
- Add matcher filtering (no more shell-level tool name checks)
- agentStop replaces postToolUse-only approach — actually blocks completion
- sessionEnd provides audit artifact for migration quality tracking
- actionlint runs in 3 hooks: postToolUse, agentStop, sessionEnd
- Test harness with 21 passing tests included
GitHub Advanced Security started work on behalf of AlexDeMichieli June 11, 2026 20:09 View session
GitHub Advanced Security finished work on behalf of AlexDeMichieli June 11, 2026 20:11
Convert declarative Jenkinsfile with Build, Test, and Deploy stages
to a GitHub Actions CI workflow with pinned action SHAs and
least-privilege permissions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@AlexDeMichieli

Copy link
Copy Markdown
Collaborator Author

🚀 Jenkins to GitHub Actions Migration Report

📊 Migration Overview

Metric Before (Jenkins) After (GitHub Actions)
Pipeline Files 1 file 1 workflow
Pipeline Stages 3 stages 3 jobs
Pipeline Steps 3 steps 3 steps
Shared Libraries 0 libraries N/A
Credentials 0 credentials 0 secrets/variables

🔄 Conversion Diagram

graph LR
    A[Jenkins Pipeline] --> B[GitHub Actions Workflow]

    subgraph "Jenkins Structure"
        D1[Stage: Build]
        D2[Stage: Test]
        D3[Stage: Deploy]
    end

    subgraph "GitHub Actions Structure"
        G1[Job: build]
        G2[Job: test]
        G3[Job: deploy]
    end

    D1 --> G1
    D2 --> G2
    D3 --> G3
Loading

🔧 Key Transformations

Stage and Step Conversions

  • agent anyruns-on: ubuntu-latest
  • Jenkins sequential stages → GitHub Actions jobs with needs: dependencies
  • sh 'npm ci'run: npm ci
  • Added actions/checkout (not implicit in GitHub Actions unlike Jenkins SCM checkout)
  • Added actions/setup-node with npm caching for faster builds

Trigger Mapping

  • Jenkins pipeline (typically triggered by SCM polling or webhooks) → on: push and on: pull_request on main branch

✅ Validation Results

Linting Results

$ actionlint .github/workflows/ci.yml
(no output — zero errors)

Manual Verification Checklist

  • YAML syntax validated
  • All actions properly versioned and pinned to SHAs
  • Job dependencies verified (build → test → deploy)
  • Environment variables migrated (none required)
  • Triggers match original behavior
  • Least-privilege permissions applied

🔐 Security Improvements

  • Implemented least-privilege permissions: contents: read
  • All actions pinned to commit SHAs to prevent supply-chain attacks
  • Only verified marketplace actions used (actions/checkout, actions/setup-node)

🔗 Variable and Secret Requirements

Required GitHub Secrets

None required for this pipeline.

Required GitHub Variables

None required for this pipeline.

🎯 Next Steps

  1. Test the workflow by pushing to a feature branch
  2. Adjust Node.js version if your project requires a different version than 20
  3. Enhance the deploy job with actual deployment steps and environment protection rules
  4. Add branch protection rules to require CI to pass before merging

📁 Original Jenkins Files

The original Jenkins pipeline file has been archived:

📚 Migration Notes

  • The Jenkins pipeline used agent any which maps to ubuntu-latest as the default GitHub-hosted runner.
  • Each stage was converted to a separate job with sequential dependencies to preserve the original execution order.
  • actions/setup-node with npm caching was added to optimize install times since Jenkins environments typically have Node.js pre-installed globally.

Migration completed by GitHub Copilot Jenkins Migration Agent

GitHub Advanced Security started work on behalf of AlexDeMichieli June 11, 2026 20:14 View session
GitHub Advanced Security finished work on behalf of AlexDeMichieli June 11, 2026 20:15
…nter

Plugin manifest:
- plugin/plugin.json: declare hooks field, bump to 1.4.0
- .github/plugin/marketplace.json: sync to 1.4.0

Hook fixes:
- preToolUse rm guard: extend to rm, mv, unlink, find -delete, git rm, git mv;
  strip shell redirect operators (2>&1, >/tmp/x, etc.) before tokenizing targets
  so legitimate commands with redirects are not denied (regression).
  Allowlist: targets inside .github/ci-archive/ OR CI source files at repo root
  (Jenkinsfile, .travis.yml, .gitlab-ci.yml, .drone.yml, bitbucket-pipelines.yml,
  azure-pipelines.yml, bamboo-specs/*, .circleci/*).
- agentStop quality-gate: per-session counter (${sessionId}) instead of a
  global /tmp file (parallel sessions no longer collide).
- agentStop & sessionEnd: fall back to $GITHUB_WORKSPACE then $PWD when input
  cwd is missing/'/root' (CCA sandbox).
- sessionEnd: prune stale per-session counters > 60 min as garbage collection.

Consumer adoption:
- consumer-template/.github/copilot/settings.json with enabledPlugins entry
- consumer-template/README.md explaining surfaces (CLI, CCA, VS Code Agent
  Plugins preview).

Closes Sully + Anthony feedback (2026-06-11): toolArgs parsing, scorecard
per-file detail, custom-agent hook firing, plus newly found:
- destructive-op bypass via git mv / mv / find -delete
- false-positive denies on commands with shell redirects
- shared /tmp counter across parallel sessions
- agent narrating fake delete after hook denial

Tested:
- 13 unit tests against the rm guard (all pass)
- End-to-end CLI run with --agent actions-migrator:jenkins-migrator
  on alexdemichieli-migrations/jenkins-migration-test:
  * README protected (hook denied bash rm)
  * Jenkinsfile migrated to clean .github/workflows/ci.yml (10 SHA pins, perms)
  * Original archived via git mv into .github/ci-archive/
  * Scorecard shows: 1 total, 1 clean, 0 with issues
GitHub Advanced Security started work on behalf of AlexDeMichieli June 16, 2026 20:45 View session
GitHub Advanced Security finished work on behalf of AlexDeMichieli June 16, 2026 20:48
…ract tests

Problem
  Hooks only understood the Copilot CLI / Cloud agent payload schema. In VS
  Code Agent Plugins (preview) the same hooks ran but read empty fields, so
  enforcement silently no-opped (audit logged tool:"null"; the README 'block'
  users saw was VS Code's own terminal safety, not our hook).

Root cause (captured from live payloads on both surfaces)
  CLI:     { toolName, toolArgs:<json string>, sessionId, toolResult }
  VS Code: { tool_name, tool_input:<object>, session_id, tool_response }
  - field names differ (camelCase vs snake_case)
  - args differ: CLI sends a JSON *string*; VS Code sends an *object*
  - tool names differ: bash/create/edit vs run_in_terminal/create_file/...
  - deny output differs: top-level permissionDecision vs hookSpecificOutput
  - lifecycle events differ: CLI agentStop+sessionEnd vs VS Code Stop
  - VS Code ignores matchers (every preToolUse hook runs on every tool)

Fix (plugin/hooks.json) — one file, self-adapting on all surfaces
  - Normalize input: read .toolName // .tool_name; parse args whether
    .toolArgs is a string (fromjson) or .tool_input is an object.
  - Normalize fields: command, filePath//path//file_path, content//new_string.
  - Emit BOTH output shapes (top-level + hookSpecificOutput) so each surface
    finds the field it expects.
  - Register lifecycle under both names: sessionEnd (CLI) and Stop (VS Code)
    run the same scorecard script; Stop honors stop_hook_active to avoid loops.
  - Each preToolUse hook self-guards (no-op when its field is absent) since
    VS Code ignores matchers.

Tests (NEW — guards against silent breakage)
  - plugin/hooks.test.sh: 22 contract tests exercising every hook against BOTH
    CLI and VS Code payloads (deny/allow/context/scorecard/loop-guard).
  - .github/workflows/hooks-test.yml: runs the suite on every PR touching the
    hooks; validates hooks.json parses; fails the PR on regression.
  - Negative-tested: sabotaging the VS Code arg parsing makes the suite fail
    exactly the 4 VS Code destructive-guard cases and exit non-zero.

Validation performed
  - 22/22 contract tests pass (CLI + VS Code schemas).
  - VS Code live capture: PreToolUse/PostToolUse/UserPromptSubmit/Stop all fire
    with correct payloads (create_file + run_in_terminal).
  - CLI end-to-end (--agent actions-migrator:jenkins-migrator): README delete
    blocked; Jenkinsfile migrated to clean ci.yml (12 SHA pins + permissions);
    archived via git mv; scorecard = 1 clean, 0 issues.
Copilot AI review requested due to automatic review settings July 21, 2026 18:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Comments suppressed due to low confidence (5)

plugin/hooks.json:26

  • If the actionlint download, checksum, extraction, or install fails, command -v actionlint remains false and L stays empty. A workflow that passes the grep checks but has actionlint errors then produces {} with no warning, so installation failure is silently treated as successful linting. Emit explicit unavailable context after the install attempt.
        "bash": "INPUT=$(cat); TOOL=$(echo \"$INPUT\" | jq -r '.toolName // .tool_name // empty' 2>/dev/null); ARGS=$(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); FILE=$(echo \"$ARGS\" | jq -r '.filePath // .path // .file_path // empty' 2>/dev/null); echo \"$FILE\" | grep -q '.github/workflows/' || { echo '{}'; exit 0; }; [ -f \"$FILE\" ] || { echo '{}'; exit 0; }; if ! command -v actionlint >/dev/null 2>&1; then if [ \"$(uname)\" = 'Linux' ]; then V='1.7.11'; EXPECTED='900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a'; TGZ=\"/tmp/actionlint_${V}_linux_amd64.tar.gz\"; curl -fsSL -o \"$TGZ\" \"https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz\" 2>/dev/null; ACTUAL=$(sha256sum \"$TGZ\" 2>/dev/null | awk '{print $1}'); if [ \"$ACTUAL\" != \"$EXPECTED\" ]; then rm -f \"$TGZ\"; fi; tar xz -C /tmp -f \"$TGZ\" actionlint 2>/dev/null && install -m 755 /tmp/actionlint /usr/local/bin/actionlint 2>/dev/null; rm -f \"$TGZ\"; elif command -v brew >/dev/null 2>&1; then brew install actionlint 2>/dev/null; fi; fi; W=''; grep -qE 'uses:\\s+[^@]+@v[0-9]' \"$FILE\" 2>/dev/null && W=\"${W}unpinned-actions; \"; grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$FILE\" 2>/dev/null && W=\"${W}placeholder-text; \"; grep -qE 'permissions:\\s*write-all' \"$FILE\" 2>/dev/null && W=\"${W}write-all-permissions; \"; grep -qE '^permissions:' \"$FILE\" 2>/dev/null || W=\"${W}missing-permissions-block; \"; L=''; command -v actionlint >/dev/null 2>&1 && L=$(actionlint \"$FILE\" 2>&1 | head -5 | tr '\\n' ' '); if [ -n \"$W\" ] || [ -n \"$L\" ]; then MSG=\"MIGRATION QUALITY CHECK ($FILE): ${W}\"; [ -n \"$L\" ] && MSG=\"${MSG}actionlint: ${L}\"; jq -n --arg m \"$MSG\" '{additionalContext:$m,hookSpecificOutput:{hookEventName:\"PostToolUse\",additionalContext:$m}}'; else echo '{}'; fi"

plugin/hooks.json:34

  • The quality gate silently disables actionlint when installation fails: LA remains 0, the lint check is skipped, and a statically clean but actionlint-invalid workflow can pass the gate. Re-check availability after installation and add actionlint-unavailable to ISSUES so completion remains blocked as documented.
        "bash": "INPUT=$(cat); SID=$(echo \"$INPUT\" | jq -r '.sessionId // .session_id // \"default\"' 2>/dev/null); CWD=$(echo \"$INPUT\" | jq -r '.cwd // \"\"' 2>/dev/null); [ -z \"$CWD\" ] && CWD=\"${GITHUB_WORKSPACE:-$PWD}\"; CF=\"/tmp/.migration-quality-gate-${SID}\"; COUNT=$(cat \"$CF\" 2>/dev/null || echo 0); COUNT=$((COUNT+1)); echo \"$COUNT\" > \"$CF\"; if [ \"$COUNT\" -gt 3 ]; then rm -f \"$CF\"; echo '{}'; exit 0; fi; if ! command -v actionlint >/dev/null 2>&1; then if [ \"$(uname)\" = 'Linux' ]; then V='1.7.11'; EXPECTED='900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a'; TGZ=\"/tmp/actionlint_${V}_linux_amd64.tar.gz\"; curl -fsSL -o \"$TGZ\" \"https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz\" 2>/dev/null; ACTUAL=$(sha256sum \"$TGZ\" 2>/dev/null | awk '{print $1}'); if [ \"$ACTUAL\" != \"$EXPECTED\" ]; then rm -f \"$TGZ\"; fi; tar xz -C /tmp -f \"$TGZ\" actionlint 2>/dev/null && install -m 755 /tmp/actionlint /usr/local/bin/actionlint 2>/dev/null; rm -f \"$TGZ\"; elif command -v brew >/dev/null 2>&1; then brew install actionlint 2>/dev/null; fi; fi; LA=0; command -v actionlint >/dev/null 2>&1 && LA=1; ISSUES=''; for f in \"$CWD\"/.github/workflows/*.yml \"$CWD\"/.github/workflows/*.yaml; do [ -f \"$f\" ] || continue; FN=$(basename \"$f\"); W=''; grep -qE 'uses:\\s+[^@]+@v[0-9]' \"$f\" 2>/dev/null && W=\"${W}unpinned-actions \"; grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$f\" 2>/dev/null && W=\"${W}placeholders \"; grep -qE 'permissions:\\s*write-all' \"$f\" 2>/dev/null && W=\"${W}write-all \"; grep -qE '^permissions:' \"$f\" 2>/dev/null || W=\"${W}no-permissions \"; [ \"$LA\" -eq 1 ] && ! actionlint \"$f\" >/dev/null 2>&1 && W=\"${W}actionlint-errors \"; [ -n \"$W\" ] && ISSUES=\"${ISSUES}${FN}: ${W}; \"; done; if [ -n \"$ISSUES\" ]; then R=\"Migration quality gate FAILED (attempt ${COUNT}/3): ${ISSUES}Fix these before completing.\"; jq -n --arg r \"$R\" '{decision:\"block\",reason:$r}'; else rm -f \"$CF\"; echo '{}'; fi"

plugin/hooks.json:42

  • When actionlint installation fails, LA=0 causes linting to be skipped and the CLI scorecard can label an actionlint-invalid workflow clean. Record actionlint-unavailable as an issue instead of producing a false clean audit result.
        "bash": "INPUT=$(cat); CWD=$(echo \"$INPUT\" | jq -r '.cwd // \"\"' 2>/dev/null); [ -z \"$CWD\" ] && CWD=\"${GITHUB_WORKSPACE:-$PWD}\"; REASON=$(echo \"$INPUT\" | jq -r '.reason // \"complete\"' 2>/dev/null); SESSION=$(echo \"$INPUT\" | jq -r '.sessionId // .session_id // \"unknown\"' 2>/dev/null); if ! command -v actionlint >/dev/null 2>&1; then if [ \"$(uname)\" = 'Linux' ]; then V='1.7.11'; EXPECTED='900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a'; TGZ=\"/tmp/actionlint_${V}_linux_amd64.tar.gz\"; curl -fsSL -o \"$TGZ\" \"https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz\" 2>/dev/null; ACTUAL=$(sha256sum \"$TGZ\" 2>/dev/null | awk '{print $1}'); if [ \"$ACTUAL\" != \"$EXPECTED\" ]; then rm -f \"$TGZ\"; fi; tar xz -C /tmp -f \"$TGZ\" actionlint 2>/dev/null && install -m 755 /tmp/actionlint /usr/local/bin/actionlint 2>/dev/null; rm -f \"$TGZ\"; elif command -v brew >/dev/null 2>&1; then brew install actionlint 2>/dev/null; fi; fi; LA=0; command -v actionlint >/dev/null 2>&1 && LA=1; TOTAL=0; CLEAN=0; BAD=0; DETAILS=''; for f in \"$CWD\"/.github/workflows/*.yml \"$CWD\"/.github/workflows/*.yaml; do [ -f \"$f\" ] || continue; TOTAL=$((TOTAL+1)); FN=$(basename \"$f\"); W=''; grep -qE 'uses:\\s+[^@]+@v[0-9]' \"$f\" 2>/dev/null && W=\"${W}unpinned-actions, \"; grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$f\" 2>/dev/null && W=\"${W}placeholders, \"; grep -qE 'permissions:\\s*write-all' \"$f\" 2>/dev/null && W=\"${W}write-all, \"; grep -qE '^permissions:' \"$f\" 2>/dev/null || W=\"${W}no-permissions, \"; [ \"$LA\" -eq 1 ] && ! actionlint \"$f\" >/dev/null 2>&1 && W=\"${W}actionlint-errors, \"; if [ -n \"$W\" ]; then BAD=$((BAD+1)); W=$(echo \"$W\" | sed 's/, $//'); DETAILS=\"${DETAILS}| ${FN} | ${W} |\\n\"; else CLEAN=$((CLEAN+1)); DETAILS=\"${DETAILS}| ${FN} | clean |\\n\"; fi; done; rm -f \"/tmp/.migration-quality-gate-${SESSION}\" /tmp/.migration-quality-gate; find /tmp -maxdepth 1 -name '.migration-quality-gate-*' -mmin +60 -delete 2>/dev/null; SC=\"$CWD/.github/MIGRATION-SCORECARD.md\"; [ -f \"$SC\" ] || printf '# Migration Scorecard\\n' > \"$SC\" 2>/dev/null; printf '\\n## %s\\n- Session: %s\\n- Reason: %s\\n- Workflows: %d total, %d clean, %d with issues\\n\\n| File | Issues |\\n|------|--------|\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$SESSION\" \"$REASON\" \"$TOTAL\" \"$CLEAN\" \"$BAD\" >> \"$SC\" 2>/dev/null; printf \"$DETAILS\" >> \"$SC\" 2>/dev/null; echo '{}'"

plugin/hooks.json:50

  • When actionlint installation fails, LA=0 causes linting to be skipped and the VS Code scorecard can label an actionlint-invalid workflow clean. Record actionlint-unavailable as an issue instead of producing a false clean audit result.
        "bash": "INPUT=$(cat); SHA=$(echo \"$INPUT\" | jq -r '.stop_hook_active // false' 2>/dev/null); [ \"$SHA\" = \"true\" ] && { echo '{}'; exit 0; }; CWD=$(echo \"$INPUT\" | jq -r '.cwd // \"\"' 2>/dev/null); [ -z \"$CWD\" ] && CWD=\"${GITHUB_WORKSPACE:-$PWD}\"; REASON=$(echo \"$INPUT\" | jq -r '.reason // \"complete\"' 2>/dev/null); SESSION=$(echo \"$INPUT\" | jq -r '.sessionId // .session_id // \"unknown\"' 2>/dev/null); if ! command -v actionlint >/dev/null 2>&1; then if [ \"$(uname)\" = 'Linux' ]; then V='1.7.11'; EXPECTED='900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a'; TGZ=\"/tmp/actionlint_${V}_linux_amd64.tar.gz\"; curl -fsSL -o \"$TGZ\" \"https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz\" 2>/dev/null; ACTUAL=$(sha256sum \"$TGZ\" 2>/dev/null | awk '{print $1}'); if [ \"$ACTUAL\" != \"$EXPECTED\" ]; then rm -f \"$TGZ\"; fi; tar xz -C /tmp -f \"$TGZ\" actionlint 2>/dev/null && install -m 755 /tmp/actionlint /usr/local/bin/actionlint 2>/dev/null; rm -f \"$TGZ\"; elif command -v brew >/dev/null 2>&1; then brew install actionlint 2>/dev/null; fi; fi; LA=0; command -v actionlint >/dev/null 2>&1 && LA=1; TOTAL=0; CLEAN=0; BAD=0; DETAILS=''; for f in \"$CWD\"/.github/workflows/*.yml \"$CWD\"/.github/workflows/*.yaml; do [ -f \"$f\" ] || continue; TOTAL=$((TOTAL+1)); FN=$(basename \"$f\"); W=''; grep -qE 'uses:\\s+[^@]+@v[0-9]' \"$f\" 2>/dev/null && W=\"${W}unpinned-actions, \"; grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$f\" 2>/dev/null && W=\"${W}placeholders, \"; grep -qE 'permissions:\\s*write-all' \"$f\" 2>/dev/null && W=\"${W}write-all, \"; grep -qE '^permissions:' \"$f\" 2>/dev/null || W=\"${W}no-permissions, \"; [ \"$LA\" -eq 1 ] && ! actionlint \"$f\" >/dev/null 2>&1 && W=\"${W}actionlint-errors, \"; if [ -n \"$W\" ]; then BAD=$((BAD+1)); W=$(echo \"$W\" | sed 's/, $//'); DETAILS=\"${DETAILS}| ${FN} | ${W} |\\n\"; else CLEAN=$((CLEAN+1)); DETAILS=\"${DETAILS}| ${FN} | clean |\\n\"; fi; done; rm -f \"/tmp/.migration-quality-gate-${SESSION}\" /tmp/.migration-quality-gate; find /tmp -maxdepth 1 -name '.migration-quality-gate-*' -mmin +60 -delete 2>/dev/null; SC=\"$CWD/.github/MIGRATION-SCORECARD.md\"; [ -f \"$SC\" ] || printf '# Migration Scorecard\\n' > \"$SC\" 2>/dev/null; printf '\\n## %s\\n- Session: %s\\n- Reason: %s\\n- Workflows: %d total, %d clean, %d with issues\\n\\n| File | Issues |\\n|------|--------|\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$SESSION\" \"$REASON\" \"$TOTAL\" \"$CLEAN\" \"$BAD\" >> \"$SC\" 2>/dev/null; printf \"$DETAILS\" >> \"$SC\" 2>/dev/null; echo '{}'"

plugin/hooks.json:50

  • The VS Code scorecard only identifies @vN refs as unpinned, so workflows using mutable refs such as @main, @master, or non-v tags can be reported as clean. Determine cleanliness by requiring an exact 40-character SHA for every external uses: ref.
        "bash": "INPUT=$(cat); SHA=$(echo \"$INPUT\" | jq -r '.stop_hook_active // false' 2>/dev/null); [ \"$SHA\" = \"true\" ] && { echo '{}'; exit 0; }; CWD=$(echo \"$INPUT\" | jq -r '.cwd // \"\"' 2>/dev/null); [ -z \"$CWD\" ] && CWD=\"${GITHUB_WORKSPACE:-$PWD}\"; REASON=$(echo \"$INPUT\" | jq -r '.reason // \"complete\"' 2>/dev/null); SESSION=$(echo \"$INPUT\" | jq -r '.sessionId // .session_id // \"unknown\"' 2>/dev/null); if ! command -v actionlint >/dev/null 2>&1; then if [ \"$(uname)\" = 'Linux' ]; then V='1.7.11'; EXPECTED='900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a'; TGZ=\"/tmp/actionlint_${V}_linux_amd64.tar.gz\"; curl -fsSL -o \"$TGZ\" \"https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz\" 2>/dev/null; ACTUAL=$(sha256sum \"$TGZ\" 2>/dev/null | awk '{print $1}'); if [ \"$ACTUAL\" != \"$EXPECTED\" ]; then rm -f \"$TGZ\"; fi; tar xz -C /tmp -f \"$TGZ\" actionlint 2>/dev/null && install -m 755 /tmp/actionlint /usr/local/bin/actionlint 2>/dev/null; rm -f \"$TGZ\"; elif command -v brew >/dev/null 2>&1; then brew install actionlint 2>/dev/null; fi; fi; LA=0; command -v actionlint >/dev/null 2>&1 && LA=1; TOTAL=0; CLEAN=0; BAD=0; DETAILS=''; for f in \"$CWD\"/.github/workflows/*.yml \"$CWD\"/.github/workflows/*.yaml; do [ -f \"$f\" ] || continue; TOTAL=$((TOTAL+1)); FN=$(basename \"$f\"); W=''; grep -qE 'uses:\\s+[^@]+@v[0-9]' \"$f\" 2>/dev/null && W=\"${W}unpinned-actions, \"; grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$f\" 2>/dev/null && W=\"${W}placeholders, \"; grep -qE 'permissions:\\s*write-all' \"$f\" 2>/dev/null && W=\"${W}write-all, \"; grep -qE '^permissions:' \"$f\" 2>/dev/null || W=\"${W}no-permissions, \"; [ \"$LA\" -eq 1 ] && ! actionlint \"$f\" >/dev/null 2>&1 && W=\"${W}actionlint-errors, \"; if [ -n \"$W\" ]; then BAD=$((BAD+1)); W=$(echo \"$W\" | sed 's/, $//'); DETAILS=\"${DETAILS}| ${FN} | ${W} |\\n\"; else CLEAN=$((CLEAN+1)); DETAILS=\"${DETAILS}| ${FN} | clean |\\n\"; fi; done; rm -f \"/tmp/.migration-quality-gate-${SESSION}\" /tmp/.migration-quality-gate; find /tmp -maxdepth 1 -name '.migration-quality-gate-*' -mmin +60 -delete 2>/dev/null; SC=\"$CWD/.github/MIGRATION-SCORECARD.md\"; [ -f \"$SC\" ] || printf '# Migration Scorecard\\n' > \"$SC\" 2>/dev/null; printf '\\n## %s\\n- Session: %s\\n- Reason: %s\\n- Workflows: %d total, %d clean, %d with issues\\n\\n| File | Issues |\\n|------|--------|\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$SESSION\" \"$REASON\" \"$TOTAL\" \"$CLEAN\" \"$BAD\" >> \"$SC\" 2>/dev/null; printf \"$DETAILS\" >> \"$SC\" 2>/dev/null; echo '{}'"
  • Files reviewed: 8/8 changed files
  • Comments generated: 10
  • Review effort level: Medium

Comment thread plugin/hooks.json Outdated
Comment thread plugin/hooks.json Outdated
Comment thread plugin/hooks.test.sh Outdated
Comment thread plugin/hooks.json Outdated
Comment thread plugin/hooks.json Outdated
Comment thread plugin/hooks.json Outdated
Comment thread plugin/hooks.json Outdated
Comment thread plugin/hooks.test.sh
Comment thread consumer-template/README.md Outdated
Comment thread plugin/README.md Outdated
@AlexDeMichieli
AlexDeMichieli requested a review from dhruvg20 July 24, 2026 13:41
@antgrutta

Copy link
Copy Markdown
Collaborator

@AlexDeMichieli, very much love the work you're doing here. Now that the nuance of the CCA when using hooks is clearier we are definately in the right place. I cleaned up the outdated copilot recomendations, take a look at the remaining, resolve them, and we will be in a position to move forward.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (17)

plugin/hooks.json:10

  • This hook only receives create|edit calls and only inspects a content/new_string argument. A CLI write such as bash -c 'cat > file <<EOF ...' or a redirect is never checked, so the advertised hard secret gate can be bypassed without first triggering the denial or its additionalContext. Cover terminal/bash write paths (and other supported write tools) or narrow the enforcement claim.
        "bash": "INPUT=$(cat); TOOL=$(echo \"$INPUT\" | jq -r '.toolName // .tool_name // empty' 2>/dev/null); ARGS=$(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); CONTENT=$(echo \"$ARGS\" | jq -r '.content // .new_string // .newString // empty' 2>/dev/null); [ -z \"$CONTENT\" ] && { echo '{}'; exit 0; }; HIT=$(echo \"$CONTENT\" | while IFS= read -r line; do echo \"$line\" | grep -qiE '(password|secret|token|api[_-]?key)\\s*[:=]' 2>/dev/null || continue; echo \"$line\" | grep -qF '${' 2>/dev/null && continue; echo \"$line\" | grep -qiE '[:=]\\s*.{8,}' 2>/dev/null && echo HIT; done | grep -c HIT); if [ \"${HIT:-0}\" -gt 0 ]; then R='Blocked: hardcoded secret detected. Use GitHub Secrets (${{ secrets.NAME }}) instead.'; jq -n --arg r \"$R\" --arg ac \"REPOSITORY POLICY: hardcoded secrets are not permitted in any file written to this repository. Do NOT attempt the same write through any alternative tool (bash via heredoc/redirect, MCP server tools like github-mcp-server-create_or_update_file or github-mcp-server-push_files, apply_patch, or any other write mechanism). Use GitHub Actions secrets context references (the secrets.NAME interpolation) instead. If the user requested a write containing a literal secret value, report to the user that policy blocks the write and ask them to provide a secret reference.\" '{permissionDecision:\"deny\",permissionDecisionReason:$r,additionalContext:$ac,hookSpecificOutput:{hookEventName:\"PreToolUse\",permissionDecision:\"deny\",permissionDecisionReason:$r,additionalContext:$ac}}'; else echo '{}'; fi"

plugin/hooks.json:17

  • The destructive-command detection only recognizes segments beginning with the literal command names. Common equivalent invocations such as sudo rm README.md, /bin/rm README.md, command rm README.md, or sh -c 'rm README.md' pass through unchanged, so this is not a reliable deletion guard. Normalize supported wrappers/executable paths or enforce deletion at the file-operation layer.
        "bash": "INPUT=$(cat); TOOL=$(echo \"$INPUT\" | jq -r '.toolName // .tool_name // empty' 2>/dev/null); ARGS=$(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); CMD=$(echo \"$ARGS\" | jq -r '.command // empty' 2>/dev/null); [ -z \"$CMD\" ] && { echo '{}'; exit 0; }; DENY_FILE=$(mktemp); echo \"$CMD\" | tr ';|&' '\\n' | while IFS= read -r seg; do seg=$(echo \"$seg\" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g'); [ -z \"$seg\" ] && continue; is_dest=0; case \"$seg\" in rm*|unlink*|\"git rm \"*|\"git mv \"*|\"mv \"*) is_dest=1 ;; esac; echo \"$seg\" | grep -qE 'find[[:space:]]+.*-delete' && is_dest=1; [ \"$is_dest\" -eq 0 ] && continue; seg_clean=$(echo \"$seg\" | sed -E 's/[0-9]?>>?&?[0-9-]+//g; s/[0-9]?>>?[[:space:]]*[^[:space:]]+//g; s/<[[:space:]]*[^[:space:]]+//g'); for t in $(echo \"$seg_clean\" | tr -s ' ' '\\n' | grep -vE '^(rm|mv|unlink|git|find|-[a-zA-Z]+|--[a-zA-Z-]+|>|>>|<|2>|2>&1|&>)$' | grep -v '^$'); do case \"$t\" in *..*) echo 'Blocked: path traversal (..) not allowed in delete/move operations.' > \"$DENY_FILE\"; exit ;; esac; bare=\"${t#./}\"; case \"$bare\" in .github/ci-archive|.github/ci-archive/*|*/.github/ci-archive|*/.github/ci-archive/*) continue ;; esac; echo \"$bare\" | grep -qE '^(Jenkinsfile|\\.travis\\.yml|\\.gitlab-ci\\.yml|\\.drone\\.yml|bitbucket-pipelines\\.yml|azure-pipelines\\.yml|bamboo-specs/.+|\\.circleci/.+)$' && continue; echo \"Blocked: file delete/move not allowed for ${bare}. Permitted: targets inside .github/ci-archive/ or CI source files (Jenkinsfile, .travis.yml, .gitlab-ci.yml, .drone.yml, bitbucket-pipelines.yml, azure-pipelines.yml, bamboo-specs/*, .circleci/*) at repo root.\" > \"$DENY_FILE\"; exit; done; done; if [ -s \"$DENY_FILE\" ]; then R=$(cat \"$DENY_FILE\"); rm -f \"$DENY_FILE\"; jq -n --arg r \"$R\" --arg ac \"REPOSITORY POLICY: file deletion and movement outside .github/ci-archive/ is not permitted in this repository. Do NOT attempt the same operation through any alternative tool (MCP server tools like github-mcp-server-delete_file, apply_patch with delete semantics, or any other deletion mechanism). Allowed paths: archival of CI source files into .github/ci-archive/ only. Report to the user that policy blocks the operation and do not search for workarounds.\" '{permissionDecision:\"deny\",permissionDecisionReason:$r,additionalContext:$ac,hookSpecificOutput:{hookEventName:\"PreToolUse\",permissionDecision:\"deny\",permissionDecisionReason:$r,additionalContext:$ac}}'; else rm -f \"$DENY_FILE\"; echo '{}'; fi"

plugin/hooks.json:34

  • If download, extraction, or /usr/local/bin installation fails, LA remains 0 and the actionlint condition is skipped. A workflow that passes the grep heuristics then makes the quality gate return {}, despite potentially having lint errors. Treat actionlint unavailability as a gate issue and surface the installation failure instead of silently disabling validation.
        "bash": "INPUT=$(cat); SID=$(echo \"$INPUT\" | jq -r '.sessionId // .session_id // \"default\"' 2>/dev/null); CWD=$(echo \"$INPUT\" | jq -r '.cwd // \"\"' 2>/dev/null); [ -z \"$CWD\" ] && CWD=\"${GITHUB_WORKSPACE:-$PWD}\"; CF=\"/tmp/.migration-quality-gate-${SID}\"; COUNT=$(cat \"$CF\" 2>/dev/null || echo 0); COUNT=$((COUNT+1)); echo \"$COUNT\" > \"$CF\"; if [ \"$COUNT\" -gt 3 ]; then rm -f \"$CF\"; echo '{}'; exit 0; fi; if ! command -v actionlint >/dev/null 2>&1; then if [ \"$(uname)\" = 'Linux' ]; then V='1.7.11'; EXPECTED='900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a'; TGZ=\"/tmp/actionlint_${V}_linux_amd64.tar.gz\"; curl -fsSL -o \"$TGZ\" \"https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz\" 2>/dev/null; ACTUAL=$(sha256sum \"$TGZ\" 2>/dev/null | awk '{print $1}'); if [ \"$ACTUAL\" != \"$EXPECTED\" ]; then rm -f \"$TGZ\"; fi; tar xz -C /tmp -f \"$TGZ\" actionlint 2>/dev/null && install -m 755 /tmp/actionlint /usr/local/bin/actionlint 2>/dev/null; rm -f \"$TGZ\"; elif command -v brew >/dev/null 2>&1; then brew install actionlint 2>/dev/null; fi; fi; LA=0; command -v actionlint >/dev/null 2>&1 && LA=1; ISSUES=''; for f in \"$CWD\"/.github/workflows/*.yml \"$CWD\"/.github/workflows/*.yaml; do [ -f \"$f\" ] || continue; FN=$(basename \"$f\"); W=''; grep -qE 'uses:\\s+[^@]+@v[0-9]' \"$f\" 2>/dev/null && W=\"${W}unpinned-actions \"; grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$f\" 2>/dev/null && W=\"${W}placeholders \"; grep -qE 'permissions:\\s*write-all' \"$f\" 2>/dev/null && W=\"${W}write-all \"; grep -qE '^permissions:' \"$f\" 2>/dev/null || W=\"${W}no-permissions \"; [ \"$LA\" -eq 1 ] && ! actionlint \"$f\" >/dev/null 2>&1 && W=\"${W}actionlint-errors \"; [ -n \"$W\" ] && ISSUES=\"${ISSUES}${FN}: ${W}; \"; done; if [ -n \"$ISSUES\" ]; then R=\"Migration quality gate FAILED (attempt ${COUNT}/3): ${ISSUES}Fix these before completing.\"; jq -n --arg r \"$R\" '{decision:\"block\",reason:$r}'; else rm -f \"$CF\"; echo '{}'; fi"

plugin/hooks.json:26

  • The unpinned-action check only catches refs beginning with v plus a digit. Mutable refs such as actions/checkout@main, @master, or @release pass this check (and the identical gate/scorecard checks) even though they are not 40-character SHAs. Detect every external uses: ref that is not a full commit SHA.
        "bash": "INPUT=$(cat); TOOL=$(echo \"$INPUT\" | jq -r '.toolName // .tool_name // empty' 2>/dev/null); ARGS=$(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); FILE=$(echo \"$ARGS\" | jq -r '.filePath // .path // .file_path // empty' 2>/dev/null); echo \"$FILE\" | grep -q '.github/workflows/' || { echo '{}'; exit 0; }; [ -f \"$FILE\" ] || { echo '{}'; exit 0; }; if ! command -v actionlint >/dev/null 2>&1; then if [ \"$(uname)\" = 'Linux' ]; then V='1.7.11'; EXPECTED='900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a'; TGZ=\"/tmp/actionlint_${V}_linux_amd64.tar.gz\"; curl -fsSL -o \"$TGZ\" \"https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz\" 2>/dev/null; ACTUAL=$(sha256sum \"$TGZ\" 2>/dev/null | awk '{print $1}'); if [ \"$ACTUAL\" != \"$EXPECTED\" ]; then rm -f \"$TGZ\"; fi; tar xz -C /tmp -f \"$TGZ\" actionlint 2>/dev/null && install -m 755 /tmp/actionlint /usr/local/bin/actionlint 2>/dev/null; rm -f \"$TGZ\"; elif command -v brew >/dev/null 2>&1; then brew install actionlint 2>/dev/null; fi; fi; W=''; grep -qE 'uses:\\s+[^@]+@v[0-9]' \"$FILE\" 2>/dev/null && W=\"${W}unpinned-actions; \"; grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$FILE\" 2>/dev/null && W=\"${W}placeholder-text; \"; grep -qE 'permissions:\\s*write-all' \"$FILE\" 2>/dev/null && W=\"${W}write-all-permissions; \"; grep -qE '^permissions:' \"$FILE\" 2>/dev/null || W=\"${W}missing-permissions-block; \"; L=''; command -v actionlint >/dev/null 2>&1 && L=$(actionlint \"$FILE\" 2>&1 | head -5 | tr '\\n' ' '); if [ -n \"$W\" ] || [ -n \"$L\" ]; then MSG=\"MIGRATION QUALITY CHECK ($FILE): ${W}\"; [ -n \"$L\" ] && MSG=\"${MSG}actionlint: ${L}\"; jq -n --arg m \"$MSG\" '{additionalContext:$m,hookSpecificOutput:{hookEventName:\"PostToolUse\",additionalContext:$m}}'; else echo '{}'; fi"

plugin/hooks.test.sh:68

  • The suite never loads or invokes the new agentStop hook, so its block response, per-session retry counter, clean reset, and three-attempt safety valve can regress while all 22 tests still pass. Add CLI/Cloud payload cases that exercise both failing and clean workflows and verify the release behavior.
SECRET=$(get_hook preToolUse 0)
DESTRUCTIVE=$(get_hook preToolUse 1)
QUALITY=$(get_hook postToolUse 0)

plugin/README.md:117

  • These hard-enforcement claims contradict the PR's documented Cloud-agent behavior: the autonomous loop can route around denied calls, and VS Code has no agentStop quality-gate hook here (its Stop hook only writes the scorecard). Document the per-surface limits instead of saying the agent cannot bypass hooks or that lint completion is always blocked.
The `migration-core` skill already contains guardrails as agent instructions. Hooks add a **deterministic layer** — the agent can't bypass them. This is the difference between "please don't delete files outside ci-archive" (instruction) and "the system will reject the tool call" (hook).

**actionlint** runs in three hooks: `postToolUse` (per-file, immediate feedback), `agentStop` (all files, blocks completion), and `sessionEnd` (final scorecard counts). The agent cannot skip or ignore lint errors — the quality gate blocks completion until they're fixed.

**The quality gate** (`agentStop`) is the key enforcement mechanism. Instead of just warning after each file write, it checks all workflows at the end of every agent turn and forces continuation until they pass. This works in both CLI interactive mode and cloud agent jobs.

consumer-template/README.md:35

  • This sanity check references .github/ci-archive/migration-audit.jsonl, but no changed hook or existing repository code creates that file. Consumers following the template will therefore get a jq file-not-found error after an otherwise successful migration. Remove the check or add the missing audit-artifact implementation.
jq -r '.tool' .github/ci-archive/migration-audit.jsonl | sort -u
# Expect: bash, create, edit, view  (NOT "null")

plugin/hooks.json:42

  • When actionlint cannot be installed, LA=0 causes this scorecard to omit lint status and potentially count an invalid workflow as clean. That makes the audit artifact inaccurate. Record actionlint-unavailable as an issue (and do the same in the duplicated VS Code Stop implementation).
        "bash": "INPUT=$(cat); CWD=$(echo \"$INPUT\" | jq -r '.cwd // \"\"' 2>/dev/null); [ -z \"$CWD\" ] && CWD=\"${GITHUB_WORKSPACE:-$PWD}\"; REASON=$(echo \"$INPUT\" | jq -r '.reason // \"complete\"' 2>/dev/null); SESSION=$(echo \"$INPUT\" | jq -r '.sessionId // .session_id // \"unknown\"' 2>/dev/null); if ! command -v actionlint >/dev/null 2>&1; then if [ \"$(uname)\" = 'Linux' ]; then V='1.7.11'; EXPECTED='900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a'; TGZ=\"/tmp/actionlint_${V}_linux_amd64.tar.gz\"; curl -fsSL -o \"$TGZ\" \"https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz\" 2>/dev/null; ACTUAL=$(sha256sum \"$TGZ\" 2>/dev/null | awk '{print $1}'); if [ \"$ACTUAL\" != \"$EXPECTED\" ]; then rm -f \"$TGZ\"; fi; tar xz -C /tmp -f \"$TGZ\" actionlint 2>/dev/null && install -m 755 /tmp/actionlint /usr/local/bin/actionlint 2>/dev/null; rm -f \"$TGZ\"; elif command -v brew >/dev/null 2>&1; then brew install actionlint 2>/dev/null; fi; fi; LA=0; command -v actionlint >/dev/null 2>&1 && LA=1; TOTAL=0; CLEAN=0; BAD=0; DETAILS=''; for f in \"$CWD\"/.github/workflows/*.yml \"$CWD\"/.github/workflows/*.yaml; do [ -f \"$f\" ] || continue; TOTAL=$((TOTAL+1)); FN=$(basename \"$f\"); W=''; grep -qE 'uses:\\s+[^@]+@v[0-9]' \"$f\" 2>/dev/null && W=\"${W}unpinned-actions, \"; grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$f\" 2>/dev/null && W=\"${W}placeholders, \"; grep -qE 'permissions:\\s*write-all' \"$f\" 2>/dev/null && W=\"${W}write-all, \"; grep -qE '^permissions:' \"$f\" 2>/dev/null || W=\"${W}no-permissions, \"; [ \"$LA\" -eq 1 ] && ! actionlint \"$f\" >/dev/null 2>&1 && W=\"${W}actionlint-errors, \"; if [ -n \"$W\" ]; then BAD=$((BAD+1)); W=$(echo \"$W\" | sed 's/, $//'); DETAILS=\"${DETAILS}| ${FN} | ${W} |\\n\"; else CLEAN=$((CLEAN+1)); DETAILS=\"${DETAILS}| ${FN} | clean |\\n\"; fi; done; rm -f \"/tmp/.migration-quality-gate-${SESSION}\" /tmp/.migration-quality-gate; find /tmp -maxdepth 1 -name '.migration-quality-gate-*' -mmin +60 -delete 2>/dev/null; SC=\"$CWD/.github/MIGRATION-SCORECARD.md\"; [ -f \"$SC\" ] || printf '# Migration Scorecard\\n' > \"$SC\" 2>/dev/null; printf '\\n## %s\\n- Session: %s\\n- Reason: %s\\n- Workflows: %d total, %d clean, %d with issues\\n\\n| File | Issues |\\n|------|--------|\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$SESSION\" \"$REASON\" \"$TOTAL\" \"$CLEAN\" \"$BAD\" >> \"$SC\" 2>/dev/null; printf \"$DETAILS\" >> \"$SC\" 2>/dev/null; echo '{}'"

plugin/hooks.json:34

  • The gate's @v[0-9] predicate misses mutable non-version refs such as @main, @master, and @release, allowing completion without SHA pinning. Reject every external action ref that is not exactly a 40-character hexadecimal commit SHA.
        "bash": "INPUT=$(cat); SID=$(echo \"$INPUT\" | jq -r '.sessionId // .session_id // \"default\"' 2>/dev/null); CWD=$(echo \"$INPUT\" | jq -r '.cwd // \"\"' 2>/dev/null); [ -z \"$CWD\" ] && CWD=\"${GITHUB_WORKSPACE:-$PWD}\"; CF=\"/tmp/.migration-quality-gate-${SID}\"; COUNT=$(cat \"$CF\" 2>/dev/null || echo 0); COUNT=$((COUNT+1)); echo \"$COUNT\" > \"$CF\"; if [ \"$COUNT\" -gt 3 ]; then rm -f \"$CF\"; echo '{}'; exit 0; fi; if ! command -v actionlint >/dev/null 2>&1; then if [ \"$(uname)\" = 'Linux' ]; then V='1.7.11'; EXPECTED='900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a'; TGZ=\"/tmp/actionlint_${V}_linux_amd64.tar.gz\"; curl -fsSL -o \"$TGZ\" \"https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz\" 2>/dev/null; ACTUAL=$(sha256sum \"$TGZ\" 2>/dev/null | awk '{print $1}'); if [ \"$ACTUAL\" != \"$EXPECTED\" ]; then rm -f \"$TGZ\"; fi; tar xz -C /tmp -f \"$TGZ\" actionlint 2>/dev/null && install -m 755 /tmp/actionlint /usr/local/bin/actionlint 2>/dev/null; rm -f \"$TGZ\"; elif command -v brew >/dev/null 2>&1; then brew install actionlint 2>/dev/null; fi; fi; LA=0; command -v actionlint >/dev/null 2>&1 && LA=1; ISSUES=''; for f in \"$CWD\"/.github/workflows/*.yml \"$CWD\"/.github/workflows/*.yaml; do [ -f \"$f\" ] || continue; FN=$(basename \"$f\"); W=''; grep -qE 'uses:\\s+[^@]+@v[0-9]' \"$f\" 2>/dev/null && W=\"${W}unpinned-actions \"; grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$f\" 2>/dev/null && W=\"${W}placeholders \"; grep -qE 'permissions:\\s*write-all' \"$f\" 2>/dev/null && W=\"${W}write-all \"; grep -qE '^permissions:' \"$f\" 2>/dev/null || W=\"${W}no-permissions \"; [ \"$LA\" -eq 1 ] && ! actionlint \"$f\" >/dev/null 2>&1 && W=\"${W}actionlint-errors \"; [ -n \"$W\" ] && ISSUES=\"${ISSUES}${FN}: ${W}; \"; done; if [ -n \"$ISSUES\" ]; then R=\"Migration quality gate FAILED (attempt ${COUNT}/3): ${ISSUES}Fix these before completing.\"; jq -n --arg r \"$R\" '{decision:\"block\",reason:$r}'; else rm -f \"$CF\"; echo '{}'; fi"

plugin/hooks.json:42

  • This scorecard uses the same narrow @v[0-9] check, so workflows using mutable refs such as @main can be reported as clean. Classify every external action ref that is not a full 40-character SHA as unpinned.
        "bash": "INPUT=$(cat); CWD=$(echo \"$INPUT\" | jq -r '.cwd // \"\"' 2>/dev/null); [ -z \"$CWD\" ] && CWD=\"${GITHUB_WORKSPACE:-$PWD}\"; REASON=$(echo \"$INPUT\" | jq -r '.reason // \"complete\"' 2>/dev/null); SESSION=$(echo \"$INPUT\" | jq -r '.sessionId // .session_id // \"unknown\"' 2>/dev/null); if ! command -v actionlint >/dev/null 2>&1; then if [ \"$(uname)\" = 'Linux' ]; then V='1.7.11'; EXPECTED='900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a'; TGZ=\"/tmp/actionlint_${V}_linux_amd64.tar.gz\"; curl -fsSL -o \"$TGZ\" \"https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz\" 2>/dev/null; ACTUAL=$(sha256sum \"$TGZ\" 2>/dev/null | awk '{print $1}'); if [ \"$ACTUAL\" != \"$EXPECTED\" ]; then rm -f \"$TGZ\"; fi; tar xz -C /tmp -f \"$TGZ\" actionlint 2>/dev/null && install -m 755 /tmp/actionlint /usr/local/bin/actionlint 2>/dev/null; rm -f \"$TGZ\"; elif command -v brew >/dev/null 2>&1; then brew install actionlint 2>/dev/null; fi; fi; LA=0; command -v actionlint >/dev/null 2>&1 && LA=1; TOTAL=0; CLEAN=0; BAD=0; DETAILS=''; for f in \"$CWD\"/.github/workflows/*.yml \"$CWD\"/.github/workflows/*.yaml; do [ -f \"$f\" ] || continue; TOTAL=$((TOTAL+1)); FN=$(basename \"$f\"); W=''; grep -qE 'uses:\\s+[^@]+@v[0-9]' \"$f\" 2>/dev/null && W=\"${W}unpinned-actions, \"; grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$f\" 2>/dev/null && W=\"${W}placeholders, \"; grep -qE 'permissions:\\s*write-all' \"$f\" 2>/dev/null && W=\"${W}write-all, \"; grep -qE '^permissions:' \"$f\" 2>/dev/null || W=\"${W}no-permissions, \"; [ \"$LA\" -eq 1 ] && ! actionlint \"$f\" >/dev/null 2>&1 && W=\"${W}actionlint-errors, \"; if [ -n \"$W\" ]; then BAD=$((BAD+1)); W=$(echo \"$W\" | sed 's/, $//'); DETAILS=\"${DETAILS}| ${FN} | ${W} |\\n\"; else CLEAN=$((CLEAN+1)); DETAILS=\"${DETAILS}| ${FN} | clean |\\n\"; fi; done; rm -f \"/tmp/.migration-quality-gate-${SESSION}\" /tmp/.migration-quality-gate; find /tmp -maxdepth 1 -name '.migration-quality-gate-*' -mmin +60 -delete 2>/dev/null; SC=\"$CWD/.github/MIGRATION-SCORECARD.md\"; [ -f \"$SC\" ] || printf '# Migration Scorecard\\n' > \"$SC\" 2>/dev/null; printf '\\n## %s\\n- Session: %s\\n- Reason: %s\\n- Workflows: %d total, %d clean, %d with issues\\n\\n| File | Issues |\\n|------|--------|\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$SESSION\" \"$REASON\" \"$TOTAL\" \"$CLEAN\" \"$BAD\" >> \"$SC\" 2>/dev/null; printf \"$DETAILS\" >> \"$SC\" 2>/dev/null; echo '{}'"

plugin/hooks.json:50

  • The VS Code scorecard also misses mutable refs that do not start with v plus a digit, such as @main, and can report them as clean. Detect every external action ref that is not a full 40-character SHA.
        "bash": "INPUT=$(cat); SHA=$(echo \"$INPUT\" | jq -r '.stop_hook_active // false' 2>/dev/null); [ \"$SHA\" = \"true\" ] && { echo '{}'; exit 0; }; CWD=$(echo \"$INPUT\" | jq -r '.cwd // \"\"' 2>/dev/null); [ -z \"$CWD\" ] && CWD=\"${GITHUB_WORKSPACE:-$PWD}\"; REASON=$(echo \"$INPUT\" | jq -r '.reason // \"complete\"' 2>/dev/null); SESSION=$(echo \"$INPUT\" | jq -r '.sessionId // .session_id // \"unknown\"' 2>/dev/null); if ! command -v actionlint >/dev/null 2>&1; then if [ \"$(uname)\" = 'Linux' ]; then V='1.7.11'; EXPECTED='900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a'; TGZ=\"/tmp/actionlint_${V}_linux_amd64.tar.gz\"; curl -fsSL -o \"$TGZ\" \"https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz\" 2>/dev/null; ACTUAL=$(sha256sum \"$TGZ\" 2>/dev/null | awk '{print $1}'); if [ \"$ACTUAL\" != \"$EXPECTED\" ]; then rm -f \"$TGZ\"; fi; tar xz -C /tmp -f \"$TGZ\" actionlint 2>/dev/null && install -m 755 /tmp/actionlint /usr/local/bin/actionlint 2>/dev/null; rm -f \"$TGZ\"; elif command -v brew >/dev/null 2>&1; then brew install actionlint 2>/dev/null; fi; fi; LA=0; command -v actionlint >/dev/null 2>&1 && LA=1; TOTAL=0; CLEAN=0; BAD=0; DETAILS=''; for f in \"$CWD\"/.github/workflows/*.yml \"$CWD\"/.github/workflows/*.yaml; do [ -f \"$f\" ] || continue; TOTAL=$((TOTAL+1)); FN=$(basename \"$f\"); W=''; grep -qE 'uses:\\s+[^@]+@v[0-9]' \"$f\" 2>/dev/null && W=\"${W}unpinned-actions, \"; grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$f\" 2>/dev/null && W=\"${W}placeholders, \"; grep -qE 'permissions:\\s*write-all' \"$f\" 2>/dev/null && W=\"${W}write-all, \"; grep -qE '^permissions:' \"$f\" 2>/dev/null || W=\"${W}no-permissions, \"; [ \"$LA\" -eq 1 ] && ! actionlint \"$f\" >/dev/null 2>&1 && W=\"${W}actionlint-errors, \"; if [ -n \"$W\" ]; then BAD=$((BAD+1)); W=$(echo \"$W\" | sed 's/, $//'); DETAILS=\"${DETAILS}| ${FN} | ${W} |\\n\"; else CLEAN=$((CLEAN+1)); DETAILS=\"${DETAILS}| ${FN} | clean |\\n\"; fi; done; rm -f \"/tmp/.migration-quality-gate-${SESSION}\" /tmp/.migration-quality-gate; find /tmp -maxdepth 1 -name '.migration-quality-gate-*' -mmin +60 -delete 2>/dev/null; SC=\"$CWD/.github/MIGRATION-SCORECARD.md\"; [ -f \"$SC\" ] || printf '# Migration Scorecard\\n' > \"$SC\" 2>/dev/null; printf '\\n## %s\\n- Session: %s\\n- Reason: %s\\n- Workflows: %d total, %d clean, %d with issues\\n\\n| File | Issues |\\n|------|--------|\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$SESSION\" \"$REASON\" \"$TOTAL\" \"$CLEAN\" \"$BAD\" >> \"$SC\" 2>/dev/null; printf \"$DETAILS\" >> \"$SC\" 2>/dev/null; echo '{}'"

plugin/hooks.json:50

  • This duplicated scorecard implementation also appends the header and details in separate unlocked writes. Concurrent VS Code sessions can interleave entries and associate details with the wrong session; append a complete entry under a cross-process lock.
        "bash": "INPUT=$(cat); SHA=$(echo \"$INPUT\" | jq -r '.stop_hook_active // false' 2>/dev/null); [ \"$SHA\" = \"true\" ] && { echo '{}'; exit 0; }; CWD=$(echo \"$INPUT\" | jq -r '.cwd // \"\"' 2>/dev/null); [ -z \"$CWD\" ] && CWD=\"${GITHUB_WORKSPACE:-$PWD}\"; REASON=$(echo \"$INPUT\" | jq -r '.reason // \"complete\"' 2>/dev/null); SESSION=$(echo \"$INPUT\" | jq -r '.sessionId // .session_id // \"unknown\"' 2>/dev/null); if ! command -v actionlint >/dev/null 2>&1; then if [ \"$(uname)\" = 'Linux' ]; then V='1.7.11'; EXPECTED='900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a'; TGZ=\"/tmp/actionlint_${V}_linux_amd64.tar.gz\"; curl -fsSL -o \"$TGZ\" \"https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz\" 2>/dev/null; ACTUAL=$(sha256sum \"$TGZ\" 2>/dev/null | awk '{print $1}'); if [ \"$ACTUAL\" != \"$EXPECTED\" ]; then rm -f \"$TGZ\"; fi; tar xz -C /tmp -f \"$TGZ\" actionlint 2>/dev/null && install -m 755 /tmp/actionlint /usr/local/bin/actionlint 2>/dev/null; rm -f \"$TGZ\"; elif command -v brew >/dev/null 2>&1; then brew install actionlint 2>/dev/null; fi; fi; LA=0; command -v actionlint >/dev/null 2>&1 && LA=1; TOTAL=0; CLEAN=0; BAD=0; DETAILS=''; for f in \"$CWD\"/.github/workflows/*.yml \"$CWD\"/.github/workflows/*.yaml; do [ -f \"$f\" ] || continue; TOTAL=$((TOTAL+1)); FN=$(basename \"$f\"); W=''; grep -qE 'uses:\\s+[^@]+@v[0-9]' \"$f\" 2>/dev/null && W=\"${W}unpinned-actions, \"; grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$f\" 2>/dev/null && W=\"${W}placeholders, \"; grep -qE 'permissions:\\s*write-all' \"$f\" 2>/dev/null && W=\"${W}write-all, \"; grep -qE '^permissions:' \"$f\" 2>/dev/null || W=\"${W}no-permissions, \"; [ \"$LA\" -eq 1 ] && ! actionlint \"$f\" >/dev/null 2>&1 && W=\"${W}actionlint-errors, \"; if [ -n \"$W\" ]; then BAD=$((BAD+1)); W=$(echo \"$W\" | sed 's/, $//'); DETAILS=\"${DETAILS}| ${FN} | ${W} |\\n\"; else CLEAN=$((CLEAN+1)); DETAILS=\"${DETAILS}| ${FN} | clean |\\n\"; fi; done; rm -f \"/tmp/.migration-quality-gate-${SESSION}\" /tmp/.migration-quality-gate; find /tmp -maxdepth 1 -name '.migration-quality-gate-*' -mmin +60 -delete 2>/dev/null; SC=\"$CWD/.github/MIGRATION-SCORECARD.md\"; [ -f \"$SC\" ] || printf '# Migration Scorecard\\n' > \"$SC\" 2>/dev/null; printf '\\n## %s\\n- Session: %s\\n- Reason: %s\\n- Workflows: %d total, %d clean, %d with issues\\n\\n| File | Issues |\\n|------|--------|\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$SESSION\" \"$REASON\" \"$TOTAL\" \"$CLEAN\" \"$BAD\" >> \"$SC\" 2>/dev/null; printf \"$DETAILS\" >> \"$SC\" 2>/dev/null; echo '{}'"

plugin/hooks.json:17

  • The allowlist permits rm Jenkinsfile and git rm Jenkinsfile, which destroys the migration source without preserving it. The repository archival protocol requires moving source CI files into .github/ci-archive/ (plugin/skills/migration-core/SKILL.md:108-121); only a move whose destination is inside that directory should be allowed.
        "bash": "INPUT=$(cat); TOOL=$(echo \"$INPUT\" | jq -r '.toolName // .tool_name // empty' 2>/dev/null); ARGS=$(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); CMD=$(echo \"$ARGS\" | jq -r '.command // empty' 2>/dev/null); [ -z \"$CMD\" ] && { echo '{}'; exit 0; }; DENY_FILE=$(mktemp); echo \"$CMD\" | tr ';|&' '\\n' | while IFS= read -r seg; do seg=$(echo \"$seg\" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g'); [ -z \"$seg\" ] && continue; is_dest=0; case \"$seg\" in rm*|unlink*|\"git rm \"*|\"git mv \"*|\"mv \"*) is_dest=1 ;; esac; echo \"$seg\" | grep -qE 'find[[:space:]]+.*-delete' && is_dest=1; [ \"$is_dest\" -eq 0 ] && continue; seg_clean=$(echo \"$seg\" | sed -E 's/[0-9]?>>?&?[0-9-]+//g; s/[0-9]?>>?[[:space:]]*[^[:space:]]+//g; s/<[[:space:]]*[^[:space:]]+//g'); for t in $(echo \"$seg_clean\" | tr -s ' ' '\\n' | grep -vE '^(rm|mv|unlink|git|find|-[a-zA-Z]+|--[a-zA-Z-]+|>|>>|<|2>|2>&1|&>)$' | grep -v '^$'); do case \"$t\" in *..*) echo 'Blocked: path traversal (..) not allowed in delete/move operations.' > \"$DENY_FILE\"; exit ;; esac; bare=\"${t#./}\"; case \"$bare\" in .github/ci-archive|.github/ci-archive/*|*/.github/ci-archive|*/.github/ci-archive/*) continue ;; esac; echo \"$bare\" | grep -qE '^(Jenkinsfile|\\.travis\\.yml|\\.gitlab-ci\\.yml|\\.drone\\.yml|bitbucket-pipelines\\.yml|azure-pipelines\\.yml|bamboo-specs/.+|\\.circleci/.+)$' && continue; echo \"Blocked: file delete/move not allowed for ${bare}. Permitted: targets inside .github/ci-archive/ or CI source files (Jenkinsfile, .travis.yml, .gitlab-ci.yml, .drone.yml, bitbucket-pipelines.yml, azure-pipelines.yml, bamboo-specs/*, .circleci/*) at repo root.\" > \"$DENY_FILE\"; exit; done; done; if [ -s \"$DENY_FILE\" ]; then R=$(cat \"$DENY_FILE\"); rm -f \"$DENY_FILE\"; jq -n --arg r \"$R\" --arg ac \"REPOSITORY POLICY: file deletion and movement outside .github/ci-archive/ is not permitted in this repository. Do NOT attempt the same operation through any alternative tool (MCP server tools like github-mcp-server-delete_file, apply_patch with delete semantics, or any other deletion mechanism). Allowed paths: archival of CI source files into .github/ci-archive/ only. Report to the user that policy blocks the operation and do not search for workarounds.\" '{permissionDecision:\"deny\",permissionDecisionReason:$r,additionalContext:$ac,hookSpecificOutput:{hookEventName:\"PreToolUse\",permissionDecision:\"deny\",permissionDecisionReason:$r,additionalContext:$ac}}'; else rm -f \"$DENY_FILE\"; echo '{}'; fi"

plugin/hooks.test.sh:116

  • Invoking QUALITY here runs its actionlint auto-installer before the static checks. On a machine without actionlint, this test therefore downloads from the network and attempts to modify /usr/local/bin, contradicting the suite's self-contained bash + jq contract and making CI network-dependent. Put a no-op actionlint executable first on PATH before invoking the hook.
run_case "CLI  flags dirty workflow"  '{"toolName":"create","toolArgs":"{\"path\":\"'"$WORKDIR"'/.github/workflows/dirty.yml\"}"}' "$QUALITY" context

plugin/hooks.json:42

  • A scorecard entry is emitted with two separate appends (header, then DETAILS) and no lock. Concurrent sessions in the same checkout can interleave as header A, header B, details A, details B, attaching each table to the wrong session and corrupting the audit trail. Build each entry in a temporary file and append it under flock (or another cross-process lock); apply the same fix to the Stop hook.

This issue also appears on line 50 of the same file.

        "bash": "INPUT=$(cat); CWD=$(echo \"$INPUT\" | jq -r '.cwd // \"\"' 2>/dev/null); [ -z \"$CWD\" ] && CWD=\"${GITHUB_WORKSPACE:-$PWD}\"; REASON=$(echo \"$INPUT\" | jq -r '.reason // \"complete\"' 2>/dev/null); SESSION=$(echo \"$INPUT\" | jq -r '.sessionId // .session_id // \"unknown\"' 2>/dev/null); if ! command -v actionlint >/dev/null 2>&1; then if [ \"$(uname)\" = 'Linux' ]; then V='1.7.11'; EXPECTED='900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a'; TGZ=\"/tmp/actionlint_${V}_linux_amd64.tar.gz\"; curl -fsSL -o \"$TGZ\" \"https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz\" 2>/dev/null; ACTUAL=$(sha256sum \"$TGZ\" 2>/dev/null | awk '{print $1}'); if [ \"$ACTUAL\" != \"$EXPECTED\" ]; then rm -f \"$TGZ\"; fi; tar xz -C /tmp -f \"$TGZ\" actionlint 2>/dev/null && install -m 755 /tmp/actionlint /usr/local/bin/actionlint 2>/dev/null; rm -f \"$TGZ\"; elif command -v brew >/dev/null 2>&1; then brew install actionlint 2>/dev/null; fi; fi; LA=0; command -v actionlint >/dev/null 2>&1 && LA=1; TOTAL=0; CLEAN=0; BAD=0; DETAILS=''; for f in \"$CWD\"/.github/workflows/*.yml \"$CWD\"/.github/workflows/*.yaml; do [ -f \"$f\" ] || continue; TOTAL=$((TOTAL+1)); FN=$(basename \"$f\"); W=''; grep -qE 'uses:\\s+[^@]+@v[0-9]' \"$f\" 2>/dev/null && W=\"${W}unpinned-actions, \"; grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$f\" 2>/dev/null && W=\"${W}placeholders, \"; grep -qE 'permissions:\\s*write-all' \"$f\" 2>/dev/null && W=\"${W}write-all, \"; grep -qE '^permissions:' \"$f\" 2>/dev/null || W=\"${W}no-permissions, \"; [ \"$LA\" -eq 1 ] && ! actionlint \"$f\" >/dev/null 2>&1 && W=\"${W}actionlint-errors, \"; if [ -n \"$W\" ]; then BAD=$((BAD+1)); W=$(echo \"$W\" | sed 's/, $//'); DETAILS=\"${DETAILS}| ${FN} | ${W} |\\n\"; else CLEAN=$((CLEAN+1)); DETAILS=\"${DETAILS}| ${FN} | clean |\\n\"; fi; done; rm -f \"/tmp/.migration-quality-gate-${SESSION}\" /tmp/.migration-quality-gate; find /tmp -maxdepth 1 -name '.migration-quality-gate-*' -mmin +60 -delete 2>/dev/null; SC=\"$CWD/.github/MIGRATION-SCORECARD.md\"; [ -f \"$SC\" ] || printf '# Migration Scorecard\\n' > \"$SC\" 2>/dev/null; printf '\\n## %s\\n- Session: %s\\n- Reason: %s\\n- Workflows: %d total, %d clean, %d with issues\\n\\n| File | Issues |\\n|------|--------|\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$SESSION\" \"$REASON\" \"$TOTAL\" \"$CLEAN\" \"$BAD\" >> \"$SC\" 2>/dev/null; printf \"$DETAILS\" >> \"$SC\" 2>/dev/null; echo '{}'"

plugin/hooks.json:26

  • The immediate-check hook also treats a failed actionlint installation as success: after the install attempt it simply omits L when the binary is unavailable and may emit {}. This removes the promised same-turn lint warning. Add an explicit additionalContext warning for installation/checksum/extraction/install failures.

This issue also appears in the following locations of the same file:

  • line 34
  • line 42
        "bash": "INPUT=$(cat); TOOL=$(echo \"$INPUT\" | jq -r '.toolName // .tool_name // empty' 2>/dev/null); ARGS=$(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); FILE=$(echo \"$ARGS\" | jq -r '.filePath // .path // .file_path // empty' 2>/dev/null); echo \"$FILE\" | grep -q '.github/workflows/' || { echo '{}'; exit 0; }; [ -f \"$FILE\" ] || { echo '{}'; exit 0; }; if ! command -v actionlint >/dev/null 2>&1; then if [ \"$(uname)\" = 'Linux' ]; then V='1.7.11'; EXPECTED='900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a'; TGZ=\"/tmp/actionlint_${V}_linux_amd64.tar.gz\"; curl -fsSL -o \"$TGZ\" \"https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz\" 2>/dev/null; ACTUAL=$(sha256sum \"$TGZ\" 2>/dev/null | awk '{print $1}'); if [ \"$ACTUAL\" != \"$EXPECTED\" ]; then rm -f \"$TGZ\"; fi; tar xz -C /tmp -f \"$TGZ\" actionlint 2>/dev/null && install -m 755 /tmp/actionlint /usr/local/bin/actionlint 2>/dev/null; rm -f \"$TGZ\"; elif command -v brew >/dev/null 2>&1; then brew install actionlint 2>/dev/null; fi; fi; W=''; grep -qE 'uses:\\s+[^@]+@v[0-9]' \"$FILE\" 2>/dev/null && W=\"${W}unpinned-actions; \"; grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$FILE\" 2>/dev/null && W=\"${W}placeholder-text; \"; grep -qE 'permissions:\\s*write-all' \"$FILE\" 2>/dev/null && W=\"${W}write-all-permissions; \"; grep -qE '^permissions:' \"$FILE\" 2>/dev/null || W=\"${W}missing-permissions-block; \"; L=''; command -v actionlint >/dev/null 2>&1 && L=$(actionlint \"$FILE\" 2>&1 | head -5 | tr '\\n' ' '); if [ -n \"$W\" ] || [ -n \"$L\" ]; then MSG=\"MIGRATION QUALITY CHECK ($FILE): ${W}\"; [ -n \"$L\" ] && MSG=\"${MSG}actionlint: ${L}\"; jq -n --arg m \"$MSG\" '{additionalContext:$m,hookSpecificOutput:{hookEventName:\"PostToolUse\",additionalContext:$m}}'; else echo '{}'; fi"

plugin/hooks.json:50

  • The VS Code scorecard silently sets LA=0 when actionlint installation fails, then may label workflows clean without linting them. Record actionlint-unavailable as an issue rather than treating absence as a successful check.
        "bash": "INPUT=$(cat); SHA=$(echo \"$INPUT\" | jq -r '.stop_hook_active // false' 2>/dev/null); [ \"$SHA\" = \"true\" ] && { echo '{}'; exit 0; }; CWD=$(echo \"$INPUT\" | jq -r '.cwd // \"\"' 2>/dev/null); [ -z \"$CWD\" ] && CWD=\"${GITHUB_WORKSPACE:-$PWD}\"; REASON=$(echo \"$INPUT\" | jq -r '.reason // \"complete\"' 2>/dev/null); SESSION=$(echo \"$INPUT\" | jq -r '.sessionId // .session_id // \"unknown\"' 2>/dev/null); if ! command -v actionlint >/dev/null 2>&1; then if [ \"$(uname)\" = 'Linux' ]; then V='1.7.11'; EXPECTED='900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a'; TGZ=\"/tmp/actionlint_${V}_linux_amd64.tar.gz\"; curl -fsSL -o \"$TGZ\" \"https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz\" 2>/dev/null; ACTUAL=$(sha256sum \"$TGZ\" 2>/dev/null | awk '{print $1}'); if [ \"$ACTUAL\" != \"$EXPECTED\" ]; then rm -f \"$TGZ\"; fi; tar xz -C /tmp -f \"$TGZ\" actionlint 2>/dev/null && install -m 755 /tmp/actionlint /usr/local/bin/actionlint 2>/dev/null; rm -f \"$TGZ\"; elif command -v brew >/dev/null 2>&1; then brew install actionlint 2>/dev/null; fi; fi; LA=0; command -v actionlint >/dev/null 2>&1 && LA=1; TOTAL=0; CLEAN=0; BAD=0; DETAILS=''; for f in \"$CWD\"/.github/workflows/*.yml \"$CWD\"/.github/workflows/*.yaml; do [ -f \"$f\" ] || continue; TOTAL=$((TOTAL+1)); FN=$(basename \"$f\"); W=''; grep -qE 'uses:\\s+[^@]+@v[0-9]' \"$f\" 2>/dev/null && W=\"${W}unpinned-actions, \"; grep -qiE '(TODO|FIXME|CHANGEME|PLACEHOLDER|XXX)' \"$f\" 2>/dev/null && W=\"${W}placeholders, \"; grep -qE 'permissions:\\s*write-all' \"$f\" 2>/dev/null && W=\"${W}write-all, \"; grep -qE '^permissions:' \"$f\" 2>/dev/null || W=\"${W}no-permissions, \"; [ \"$LA\" -eq 1 ] && ! actionlint \"$f\" >/dev/null 2>&1 && W=\"${W}actionlint-errors, \"; if [ -n \"$W\" ]; then BAD=$((BAD+1)); W=$(echo \"$W\" | sed 's/, $//'); DETAILS=\"${DETAILS}| ${FN} | ${W} |\\n\"; else CLEAN=$((CLEAN+1)); DETAILS=\"${DETAILS}| ${FN} | clean |\\n\"; fi; done; rm -f \"/tmp/.migration-quality-gate-${SESSION}\" /tmp/.migration-quality-gate; find /tmp -maxdepth 1 -name '.migration-quality-gate-*' -mmin +60 -delete 2>/dev/null; SC=\"$CWD/.github/MIGRATION-SCORECARD.md\"; [ -f \"$SC\" ] || printf '# Migration Scorecard\\n' > \"$SC\" 2>/dev/null; printf '\\n## %s\\n- Session: %s\\n- Reason: %s\\n- Workflows: %d total, %d clean, %d with issues\\n\\n| File | Issues |\\n|------|--------|\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$SESSION\" \"$REASON\" \"$TOTAL\" \"$CLEAN\" \"$BAD\" >> \"$SC\" 2>/dev/null; printf \"$DETAILS\" >> \"$SC\" 2>/dev/null; echo '{}'"
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Copilot AI review requested due to automatic review settings August 3, 2026 19:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (6)

plugin/hooks.json:10

  • The value-length check still rejects valid reusable-workflow syntax such as secrets: inherit: \s* can match zero characters, allowing .{8,} to count the leading space plus the seven letters in inherit. Extract and trim the value before applying the minimum length, and add this exact case to the contract tests.
        "bash": "INPUT=$(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\" | grep -qF '${' 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      if echo \"$CMD\" | grep -qiE '(password|secret|token|api[_-]?key)\\s*[:=]\\s*[^$[:space:]][^[:space:]]{7,}' 2>/dev/null \\\n         && ! echo \"$CMD\" | grep -qF '${' 2>/dev/null \\\n         && echo \"$CMD\" | grep -qE '(>|>>|<<|<<<|cat[[:space:]]|printf[[:space:]]|echo[[:space:]])' 2>/dev/null; then\n        hit_cmd=1\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"

plugin/hooks.json:17

  • The archival allowlist omits bamboo-specs.yml, even though the repository's archival protocol explicitly maps that source file in plugin/skills/migration-core/SKILL.md:119. As a result, the required git mv bamboo-specs.yml .github/ci-archive/bamboo-specs.yml is denied. Include this filename in the allowlist and add a contract case.
        "bash": "INPUT=$(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  case \"$1\" in\n    .github/ci-archive|.github/ci-archive/*|*/.github/ci-archive|*/.github/ci-archive/*) 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  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      paths=$(echo \"$toks\" | tail -2)\n      src=$(echo \"$paths\" | head -1)\n      dst=$(echo \"$paths\" | tail -1)\n      src=\"${src#./}\"; 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        if is_ci_archive \"$src\"; then :\n        elif echo \"$src\" | grep -qE \"$allow_ci_src_regex\"; then :\n        else set_deny \"Blocked: only CI source files may be moved to archive (${src}).\"; fi\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"

plugin/README.md:143

  • The suite currently records 33 cases, not 22: 29 run_case invocations plus four manual scorecard/loop assertions. Keeping the documented count at 22 makes the validation instructions and PR claim inaccurate.
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.

plugin/hooks.json:34

  • These hooks only run actionlint when it is already on PATH; none installs it or reports its absence. Consequently, a workflow with an actionlint-only error can pass the gate and be scored clean, contradicting the PR's actionlint auto-install guarantee. Restore the checked cross-platform installer and make unavailability a gate issue, with contract coverage.
        "bash": "scan_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"

plugin/hooks.json:17

  • Valid archive-scoped find commands with predicates are denied. For example, find .github/ci-archive -type f -delete leaves f in toks, and the deletion loop treats it as an outside-archive path. Parse only find's search roots as paths (not predicate arguments) and add an allowed archive case.
        "bash": "INPUT=$(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  case \"$1\" in\n    .github/ci-archive|.github/ci-archive/*|*/.github/ci-archive|*/.github/ci-archive/*) 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  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      paths=$(echo \"$toks\" | tail -2)\n      src=$(echo \"$paths\" | head -1)\n      dst=$(echo \"$paths\" | tail -1)\n      src=\"${src#./}\"; 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        if is_ci_archive \"$src\"; then :\n        elif echo \"$src\" | grep -qE \"$allow_ci_src_regex\"; then :\n        else set_deny \"Blocked: only CI source files may be moved to archive (${src}).\"; fi\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"

plugin/hooks.json:26

  • The post-write check is not triggered for every workflow write on surfaces that honor matchers: its matcher only includes create|edit, so workflows written through bash, run_in_terminal, or the Cloud agent's documented apply_patch fallback receive no same-turn quality context. Expand the matcher/input normalization to all supported write tools and add contract cases for them.
        "matcher": "create|edit",
        "timeoutSec": 60,
        "bash": "INPUT=$(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"
  • Files reviewed: 8/8 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread plugin/hooks.json Outdated
Comment thread plugin/hooks.json Outdated
…jq is missing

Addresses two Copilot review findings on plugin/hooks.json:

Bug 1 (secret-detection heredoc bypass):
Previously the CMD branch exempted the ENTIRE command from secret
detection if it contained '${' anywhere. A heredoc like
  cat <<EOF > .env
    password: SuperSecret12345
    blob: ${{ github.ref }}
  EOF
had one legitimate ${{ }} expression, so the whole command was skipped
and the hardcoded password reached disk silently.

Fix: switch the CMD branch to a line-by-line scan mirroring the CONTENT
branch — only exempt individual lines that themselves use ${...}. The
legitimate github.ref line is now exempted while the password line still
matches the secret regex and triggers deny.

Bug 2 (jq fail-open on macOS):
Every hook starts with 'jq -r ... 2>/dev/null' and jq is not on the
default macOS PATH. Missing jq made all parses silently return empty,
every hook emitted '{}', and enforcement was silently disabled. CLI
users had no visible signal.

Fix: preflight each hook with 'command -v jq'. If missing, emit a
schema-appropriate response instead of allowing:
  preToolUse  -> deny with actionable install hint
  postToolUse -> additionalContext advisory
  agentStop   -> block with actionable install hint
  sessionEnd, Stop -> silent {} (scorecard is best-effort anyway)

Test coverage:
  - existing 33 contract tests unchanged, all pass
  - 6 new regression tests:
    * CLI/VSC heredoc secret + unrelated ${} nearby (both deny)
    * preToolUse secret hook without jq (deny)
    * preToolUse destructive hook without jq (deny)
    * postToolUse quality hook without jq (advisory context)
    * agentStop quality gate without jq (block)

Total: 39 tests, all pass locally.
Copilot AI review requested due to automatic review settings August 4, 2026 14:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (6)

plugin/hooks.json:17

  • The CI-source allowlist accepts bamboo-specs/... but not bamboo-specs.yml, even though the repository's archival protocol explicitly maps bamboo-specs.yml into the archive (plugin/skills/migration-core/SKILL.md:119). As a result, the hook blocks a required Bamboo migration archival. Add bamboo-specs\.yml to the allowlist and cover that archival command in the contract tests.
        "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  case \"$1\" in\n    .github/ci-archive|.github/ci-archive/*|*/.github/ci-archive|*/.github/ci-archive/*) 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  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      paths=$(echo \"$toks\" | tail -2)\n      src=$(echo \"$paths\" | head -1)\n      dst=$(echo \"$paths\" | tail -1)\n      src=\"${src#./}\"; 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        if is_ci_archive \"$src\"; then :\n        elif echo \"$src\" | grep -qE \"$allow_ci_src_regex\"; then :\n        else set_deny \"Blocked: only CI source files may be moved to archive (${src}).\"; fi\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"

plugin/hooks.json:17

  • Whitespace tokenization leaves shell quotes attached to paths, so ordinary safe commands such as git mv "Jenkinsfile" ".github/ci-archive/Jenkinsfile" are denied: the quoted destination no longer matches is_ci_archive. Use shell-aware argument parsing (or safely normalize matching quote pairs) before validation and add quoted archival cases for both schemas.
        "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  case \"$1\" in\n    .github/ci-archive|.github/ci-archive/*|*/.github/ci-archive|*/.github/ci-archive/*) 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  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      paths=$(echo \"$toks\" | tail -2)\n      src=$(echo \"$paths\" | head -1)\n      dst=$(echo \"$paths\" | tail -1)\n      src=\"${src#./}\"; 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        if is_ci_archive \"$src\"; then :\n        elif echo \"$src\" | grep -qE \"$allow_ci_src_regex\"; then :\n        else set_deny \"Blocked: only CI source files may be moved to archive (${src}).\"; fi\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"

plugin/README.md:143

  • This count is stale: the suite currently increments PASS for 39 checks (34 through the gate section plus five scorecard/Stop checks), not 22. Update the documented count here and in the PR description so test output matches the documentation.
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.

plugin/README.md:104

  • The hook table is stale relative to hooks.json: secret detection also matches shell tools, while the destructive guard covers moves, git rm, unlink, and find -delete, not only rm. Documenting the narrower behavior can mislead users about which terminal calls will be denied.
| 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. |

plugin/hooks.json:26

  • The advertised actionlint auto-install is absent here: this hook only runs actionlint when it is already on PATH, and the gate/scorecard scanners likewise set LA=0 and can mark a workflow clean without linting it. This contradicts the PR's pinned Linux/macOS auto-install guarantee and reintroduces the previously addressed silent-skip behavior. Restore the checksum-verified installer before these scans and make an unavailable installer/actionlint result an explicit quality issue.
        "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"

plugin/hooks.json:34

  • The quality scanner assumes unquoted YAML scalars. A valid pinned step such as uses: "actions/checkout@<40-char-sha>" is flagged as unpinned because the closing quote remains in v, while valid permissions: "write-all" bypasses the broad-permissions check. Normalize matching YAML quotes (or parse YAML structurally) in this scanner and the duplicated postToolUse/sessionEnd/Stop scanners so quoted and unquoted forms enforce identically.
        "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"
  • Files reviewed: 8/8 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread plugin/hooks.json Outdated
Comment thread plugin/hooks.json Outdated
Addresses two more Copilot review findings on plugin/hooks.json:

Bug 3 (destructive guard: multi-source mv bypass):
The mv/gitmv branch parsed only 'tail -2' as source+destination.
Command 'git mv README.md Jenkinsfile .github/ci-archive/' therefore
looked valid — Jenkinsfile is a recognised CI source and the
destination is under ci-archive — but README.md was silently moved
away from repo root. Fix: parse the LAST operand as destination and
validate EVERY preceding token against the CI-source allowlist. A
single unapproved source now denies the whole command.

Bug 4 (secret detection: same-line ${} bypass):
The line-by-line exempt previously matched ${} anywhere on the line.
A line like 'token: hardcoded-secret-123 # ${{ github.ref }}' had a
legitimate expression in a trailing comment, so the whole line was
exempted and the hardcoded secret reached disk. Fix: apply the sed
strip AFTER the ':' or '=' and only exempt when the VALUE itself is a
${...} or $(...) expression. A trailing-comment expression no longer
covers a hardcoded literal on the same line.

Test coverage:
  - existing 39 tests unchanged, all pass
  - 4 new regression tests:
    * same-line hardcoded value w/ trailing comment expr (CLI+VSC: deny)
    * multi-source git mv with unapproved source (deny)
    * multi-source git mv all-approved sources (allow)

Total: 43 tests, all pass locally.
Copilot AI review requested due to automatic review settings August 4, 2026 15:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (8)

plugin/hooks.json:34

  • The gate's copied uses parser treats a valid quoted SHA pin such as uses: 'actions/checkout@<40-char-sha>' as unpinned because the closing quote remains in v. This causes repeated false blocks; normalize quoted YAML scalars before applying the 40-character check.
        "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"

plugin/hooks.json:42

  • The scorecard scanner retains surrounding quotes in a quoted uses scalar, so a genuinely SHA-pinned action is recorded as unpinned-actions. Normalize the scalar before splitting on @ so valid quoted workflow syntax is scored accurately.
        "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 '{}' "

plugin/hooks.json:50

  • The VS Code scanner also rejects valid quoted SHA pins because the trailing YAML quote is included in the version tested against the SHA regex. Strip matching quotes or parse YAML before validation to avoid blocking clean workflows.
        "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)\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 '{}' "

plugin/hooks.json:50

  • The VS Code attempt counter is only removed after attempt 4, not after a clean scan. Because a chat session can handle multiple successful requests, clean Stop events accumulate toward the cap; once the counter reaches 3, the next dirty workflow bypasses the gate immediately. Remove CF whenever the scan is clean, as the CLI gate does, and test a clean Stop followed by a dirty Stop in the same session.
        "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)\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 '{}' "

plugin/hooks.json:17

  • The allowlist omits the repository's documented Bamboo source filename: plugin/skills/migration-core/SKILL.md:119 archives bamboo-specs.yml, but this regex only accepts paths below bamboo-specs/. A normal Bamboo migration therefore has its required archive move denied; include bamboo-specs.yml in the allowlist.
        "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  case \"$1\" in\n    .github/ci-archive|.github/ci-archive/*|*/.github/ci-archive|*/.github/ci-archive/*) 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  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"

plugin/hooks.json:26

  • Valid quoted uses values are falsely reported as unpinned. After parsing uses: "actions/checkout@<40-char-sha>", the extracted version retains the trailing quote, so it fails the SHA regex. Strip matching YAML quotes (or use a YAML-aware parser) before validating, and cover both quote styles.

This issue also appears in the following locations of the same file:

  • line 34
  • line 42
  • line 50
        "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"

plugin/hooks.json:26

  • This implementation contradicts the PR's actionlint auto-install guarantee: it only runs actionlint when already present and otherwise silently performs static checks. A syntactically invalid workflow can therefore pass the gate and score as clean on a fresh environment. Restore the pinned, checksum-verified installation/failure reporting in every quality hook, or revise the PR scope and claims accordingly.
        "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"

plugin/README.md:143

  • The suite currently records 43 checks, not 22 (38 through the gate section plus five scorecard/Stop assertions). Keeping this fixed count makes the documented validation result inaccurate; update it to 43 or avoid hard-coding the count.
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.
  • Files reviewed: 8/8 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread plugin/hooks.json Outdated
Comment thread plugin/hooks.json Outdated
Comment thread plugin/hooks.json Outdated
Bug 5 — destructive guard: is_ci_archive accepted any path ending in
'.github/ci-archive'. 'git mv Jenkinsfile /tmp/x/.github/ci-archive/' was
therefore allowed even though the destination is entirely outside the
repo. Fix: an absolute path is only accepted when it starts with the
hook's own $PWD/.github/ci-archive. Relative form '.github/ci-archive[/...]'
still works as before.

Bug 6 — secret detection: the key regex required the identifier to be
followed immediately by whitespace and ':='. A JSON literal like
'{"password":"SuperSecret12345"}' therefore slipped through because
the closing quote intervened. Fix: accept an optional double-quote around
the key (JSON style), covering the common credential-file shape.

Bug 7 — VS Code Stop hook: unlike the CLI agentStop and the pre-tool
guards, the Stop hook's jq preflight returned '{}' when jq was missing,
silently disabling the scorecard step. Fix: emit a schema-correct
decision:'block' with an actionable install message, matching the
agentStop behaviour.

Test coverage:
  - existing 43 tests unchanged, all pass
  - 5 new regression tests:
    * CLI/VSC deny quoted JSON key with hardcoded value (Bug 6)
    * VSC deny mv to external /tmp/.../.github/ci-archive (Bug 5)
    * VSC deny rm under external .github/ci-archive (Bug 5)
    * no-jq VS Code Stop hook fails closed with decision:block (Bug 7)

Total: 48 tests, all pass locally.
@antgrutta
antgrutta removed the request for review from dhruvg20 August 6, 2026 14:25
@AlexDeMichieli
AlexDeMichieli requested a balanced review from Copilot August 6, 2026 14:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (9)

plugin/hooks.json:34

  • A valid quoted action reference such as uses: "actions/checkout@11bd…" is classified as unpinned because the scanner leaves the closing quote in v, so it cannot match the 40-hex regex. Normalize surrounding YAML quotes before splitting the ref; otherwise the CLI gate blocks valid SHA-pinned workflows.
        "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"

plugin/hooks.json:42

  • The scorecard scanner also leaves YAML quotes around uses: values, causing valid quoted 40-character SHA refs to be recorded as unpinned-actions and counted as dirty. Strip matching surrounding quotes before validating the ref.
        "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 '{}' "

plugin/hooks.json:50

  • The VS Code gate has the same quoted-scalar false positive: uses: 'owner/action@<40-hex-sha>' retains the trailing quote and is blocked as unpinned. Normalize quoted YAML scalars before the SHA check.
        "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 '{}' "

plugin/hooks.json:10

  • secrets: inherit is still denied. After trimming, the value is inherit, but the final regex runs against the original line; the leading space plus seven-letter value satisfies .{8,}. This is valid reusable-workflow syntax, so explicitly exempt secrets: inherit or measure the trimmed value rather than counting indentation.
        "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"

plugin/hooks.json:17

  • The allowlist omits bamboo-specs.yml, so the hook blocks the archival command prescribed by plugin/skills/migration-core/SKILL.md:119. Add the file form to allow_ci_src_regex (the current bamboo-specs/.+ only permits a directory layout) and cover it with a contract test.
        "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  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"

plugin/hooks.json:34

  • No hook in this file installs actionlint, and this gate treats its absence as success by setting LA=0 and skipping linting. On a fresh runner, actionlint-only errors can therefore pass the deterministic gate, contradicting the PR's auto-install claim and reintroducing the previously addressed unavailable-linter gap. Restore the verified installer and make installation failure an explicit gate issue, or revise the stated behavior.
        "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"

plugin/hooks.test.sh:45

  • These assertions accept either output shape for every case. A VS Code case therefore still passes if hookSpecificOutput is removed, and a CLI case passes if the top-level field is removed—the exact cross-surface regression this suite claims to prevent. Make each case assert the surface-specific JSON path explicitly.
      decision=$(printf '%s' "$out" | jq -r '.permissionDecision // .hookSpecificOutput.permissionDecision // "none"' 2>/dev/null)

plugin/README.md:143

  • The suite currently executes 47 cases, not 22, so this documented count is stale.
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.

plugin/hooks.json:26

  • The post-write scanner leaves surrounding YAML quotes in a uses: value, so a valid uses: "owner/action@<40-hex-sha>" emits an unpinned-actions warning. Strip matching leading/trailing quotes before validating the ref.

This issue also appears in the following locations of the same file:

  • line 34
  • line 42
  • line 50
        "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"
  • Files reviewed: 8/8 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread plugin/hooks.json Outdated
AlexDeMichieli and others added 2 commits August 6, 2026 10:45
Bug 8 — 'bash -c "rm README.md"' slipped past the destructive-op
guard: the (^|space)rm(space|$) anchors required a whitespace or
line-start character adjacent to 'rm', but a quote intervened so the
regex never matched and the hook returned allow.

Fix: before running the destructive-op regexes on each segment, detect
shell wrappers ({bash,sh,zsh,dash,ksh,ash} {-c,--command}), strip the
wrapper and surrounding quotes, and replace the segment with the inner
payload so the existing regex logic sees the actual command. This
naturally handles all downstream checks — the ci-archive allowlist,
path-traversal denial, sudo/env prefixes, and multi-source mv checks —
without a second parser path.

Test coverage:
  - existing 48 tests unchanged, all pass
  - 4 new regression tests (Bug 8):
    * CLI  deny bash -c 'rm README.md'
    * VSC  deny bash -c 'rm README.md'
    * VSC  deny sh -c 'rm Jenkinsfile'
    * VSC  allow bash -c 'git mv Jenkinsfile .github/ci-archive/...' (verifies
       the ci-archive allowlist still passes through the unwrapper cleanly)

Total: 52 tests, all pass locally.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants