feat: add hooks authoring instruction 🤖🤖🤖 (#1507)

* feat: add hooks authoring instruction

Add hooks.instructions.md with portable guidance for writing GitHub Copilot hooks:
- Folder structure, config schema, and all field documentation
- Script contract: stdin JSON, exit codes, stdout/stderr channels
- Payload schemas for all common events (sessionStart, sessionEnd, userPromptSubmitted, preToolUse, postToolUse, errorOccurred, agentStop)
- Per-event deny mechanisms (structured JSON for preToolUse, non-zero exit for others)
- Matcher support for host-level tool filtering
- Three impactful examples: commit gate, auto-format, dangerous command blocker
- Bash and PowerShell templates
- Cross-platform guidance (Python through both entries)
- Anti-patterns, design rules, and portability notes (GitHub Copilot vs Claude Code)

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

* fix: address PR review comments

- Document both toolArgs (JSON string) and tool_input/toolInput (object) variants with defensive parsing
- Update sessionStart stdout to reflect additionalContext injection support
- Document preToolUse stdout output fields: modifiedArgs/updatedInput, additionalContext
- Add matcher field note about local verification
- Remove undocumented notification event from event table

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

* fix: address round 2 review comments

- Defensive toolArgs parsing in all 3 example scripts (handle toolArgs string, tool_input, toolInput)
- PowerShell type check: test -is [string] before ConvertFrom-Json, init to null
- commit-gate.sh: add package.json existence guard before jq
- block-dangerous.sh: truncate command in deny reason and stderr to avoid leaking secrets
- Consistent defensive parsing helper across all Bash examples

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

* fix: handle toolArgs as string or object in jq, fix short_cmd scoping

- All 3 Bash example jq blocks now check toolArgs type before fromjson
- short_cmd defined before the deny/log branch to avoid set -u error
- Consistent defensive pattern across commit-gate, format-on-save, block-dangerous

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

* fix: remove accidentally committed agency.toml

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

* fix: expand Portability Note into CLI vs VS Code vs Cloud Agent section

Address Aaron's review: replace the thin portability bullet points with
a concrete comparison table covering what is the same (config schema,
event name casing, fields) and what differs (hook loading, shell
environment, tool argument field names). Add a 'How to write portable
hooks' checklist. Separate Claude Code into its own subsection.

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

* fix: simplify to toolArgs-only parsing, clean portability section

- toolArgs is the documented contract — remove tool_input/toolInput defensive
  fallbacks from all examples and the Script Contract template
- Simplify portability section: same config everywhere, one-line note about
  cloud agent requiring default branch
- All 3 example scripts now use simple toolArgs pipe parsing

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Gordon Lam
2026-04-30 08:03:51 +08:00
committed by GitHub
parent 114aeac806
commit 5c0a993189
2 changed files with 598 additions and 0 deletions

View File

@@ -0,0 +1,597 @@
---
description: 'Portable guidance for authoring safe, fast, and clear hooks and reusable hook examples'
applyTo: '.github/hooks/**, hooks/**'
---
# Hook Authoring Guidelines
Hooks are **small, deterministic commands or scripts** that run at specific lifecycle events.
An awesome hook does one clear job, runs quickly, and makes its side effects explicit.
## Folder Structure
A GitHub Copilot hook lives in `.github/hooks/` inside your repository:
```text
.github/
└── hooks/
├── block-dangerous-commands.json ← hook config (which event, which script, options)
└── scripts/
├── block-dangerous-commands.sh ← Bash implementation
└── block-dangerous-commands.ps1 ← PowerShell implementation (optional if Bash-only)
```
You can have multiple `.json` files — each one registers hooks for one or more events. The host loads all of them.
## The Config File
Each `.json` file maps events to an array of hook entries.
- **Command hooks** (`type: "command"`): run a local script. The host passes event JSON on stdin, your script responds through exit code and stdout.
### Config example
```json
{
"version": 1,
"hooks": {
"preToolUse": [
{
"matcher": "bash",
"type": "command",
"bash": "./.github/hooks/scripts/block-dangerous-commands.sh",
"powershell": "./.github/hooks/scripts/block-dangerous-commands.ps1",
"cwd": ".",
"timeoutSec": 5,
"env": {
"BLOCK_MODE": "deny"
}
}
]
}
}
```
### Config fields
| Field | Required | What it does |
| ---- | ---- | ---- |
| `type` | yes | `"command"` for scripts |
| `matcher` | no | Host-level filter — hook only fires when the tool name matches this value (e.g. `"bash"`, `"powershell"`, `"edit"`, `"create"`). Locally verified working in Copilot CLI v1.0.36; not yet used in repo hook samples. |
| `bash` | one or both | Command line invoked on Unix / Bash-capable hosts |
| `powershell` | one or both | Command line invoked on Windows / PowerShell-capable hosts |
| `cwd` | no | Working directory, relative to repo root |
| `timeoutSec` | no | Max seconds before the host kills the process (default 30) |
| `env` | no | Extra process environment variables passed to the script |
### Why matchers matter
Without a matcher, every `preToolUse` hook fires on **every** tool call. Your script starts with boilerplate like:
```bash
tool_name="$(printf '%s' "$payload" | jq -r '.toolName')"
[[ "$tool_name" != "bash" ]] && exit 0
```
With a matcher, the host does this filtering for you — no boilerplate, no process spawn for irrelevant tools. This will likely become the standard pattern once the feature stabilizes.
If your hooks must work on both the CLI and the cloud agent (or on older CLI versions), keep the in-script filtering as a fallback even when using matchers.
### `env` — static configuration for your script
`env` is a **standard host field**. The keys inside it are **author-defined variables** — you choose the names and values.
They arrive as **process environment variables**, not inside the stdin JSON payload. Use them for static configuration that should not be hardcoded:
| Pattern | Example |
| ---- | ---- |
| Mode flag | `"BLOCK_MODE": "deny"` — same script logs in one repo, blocks in another |
| Threshold | `"MAX_CHANGED_FILES": "20"` |
| Path | `"AUDIT_LOG_PATH": ".github/logs/hooks.log"` |
| Feature toggle | `"ENABLE_NOTIFICATIONS": "false"` |
### `bash` and `powershell` — when to provide one or both
The host picks whichever entry matches the current environment. It does not run both, and does not fall back from one to the other.
| Situation | Provide |
| ---- | ---- |
| Private hook, one known platform | Only that platform's entry |
| Published hook claiming cross-platform support | Both entries |
| Single cross-platform runtime (Python, Node, pwsh) | Expose the same script through both entries |
| Bash-only dependency | `bash` only |
| Windows-only dependency | `powershell` only |
Cross-platform example using Python through both entries:
```json
{
"type": "command",
"bash": "python3 ./.github/hooks/scripts/check.py",
"powershell": "python .\\.github\\hooks\\scripts\\check.py"
}
```
## The Script Contract
Every hook script follows the same basic contract: read JSON from stdin, do work, and respond through exit code, stdout, and stderr.
**Important**: `toolArgs` is a **JSON string**, not a nested object. You must parse it a second time to access its fields.
### Reading stdin and responding — Bash and PowerShell
**Bash**:
```bash
#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
tool_name="$(printf '%s' "$payload" | jq -r '.toolName')"
tool_args="$(printf '%s' "$payload" | jq -r '.toolArgs')"
command="$(printf '%s' "$tool_args" | jq -r '.command // ""')"
```
**PowerShell**:
```powershell
Set-StrictMode -Version Latest
$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
$toolArgs = $payload.toolArgs | ConvertFrom-Json
$command = $toolArgs.command
```
To deny in `preToolUse` (PowerShell):
```powershell
@{ permissionDecision = 'deny'; permissionDecisionReason = 'Blocked by policy' } |
ConvertTo-Json -Compress
exit 0
```
### What the script receives
| Input | What it carries |
| ---- | ---- |
| `stdin` | One JSON payload describing the current event |
| process environment | Normal env vars plus any you defined under `env` in the config |
| working directory | `cwd` from the config, or the host default |
### How the script responds
| Channel | Purpose |
| ---- | ---- |
| exit `0` | Script succeeded — host continues unless stdout carried a structured deny |
| non-zero exit | **Blocks the triggering action** and signals hook failure |
| `stdout` | Structured machine-readable output — only for events that document a stdout schema (like `preToolUse`) |
| `stderr` | Human-readable diagnostics for logs |
### Exit codes and deny: the full picture
The deny mechanism **depends on the event**:
| Event type | How to allow | How to deny / block |
| ---- | ---- | ---- |
| `preToolUse` | exit `0`, empty or `{"permissionDecision":"allow"}` on stdout | **Preferred**: exit `0` + `{"permissionDecision":"deny","permissionDecisionReason":"..."}` on stdout — gives the host a reason to show. **Also works**: non-zero exit blocks the tool call, but without a structured reason. |
| `userPromptSubmitted` | exit `0` | Non-zero exit blocks the prompt (stdout is ignored for this event) |
| `agentStop` | exit `0` | Non-zero exit blocks the action |
| Other events (`sessionStart`, `sessionEnd`, `postToolUse`, `errorOccurred`) | exit `0` | Non-zero exit signals failure; the host may skip subsequent hooks for that event |
**Rule of thumb**: if the event has a structured stdout schema (like `preToolUse`), use it — it gives a clean reason and is the officially documented deny path. For events without structured stdout, non-zero exit is the practical block mechanism — this is confirmed by repo samples and learning hub docs, though the official GitHub reference does not explicitly document "non-zero = block" as a contract guarantee.
### Example 1: Commit gate — block commits until lint, types, and tests pass
**Why this pattern matters**: the deny reason includes the actual errors, so the agent sees what's broken and fixes it before trying again. This creates a self-correcting feedback loop — the most powerful thing hooks can do.
**Event**: `preToolUse` — fires before the agent runs `git commit`
**Config**`.github/hooks/commit-gate.json`:
```json
{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"bash": "./.github/hooks/scripts/commit-gate.sh",
"cwd": ".",
"timeoutSec": 120
}
]
}
}
```
**Script**`.github/hooks/scripts/commit-gate.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
tool_name="$(printf '%s' "$payload" | jq -r '.toolName')"
# Only gate bash commands that are git commits
if [[ "$tool_name" != "bash" ]]; then exit 0; fi
command="$(printf '%s' "$payload" | jq -r '.toolArgs' | jq -r '.command // ""')"
if ! printf '%s' "$command" | grep -q "git commit"; then exit 0; fi
CWD="$(printf '%s' "$payload" | jq -r '.cwd')"
ERRORS=""
# 1. TypeScript type check
if [[ -f "$CWD/tsconfig.json" ]]; then
TSC_OUT=$(cd "$CWD" && npx tsc --noEmit 2>&1) || ERRORS="${ERRORS}
=== TypeScript Errors ===
$(echo "$TSC_OUT" | head -30)"
fi
# 2. Lint
if [[ -f "$CWD/package.json" ]]; then
HAS_LINT=$(jq -r '.scripts.lint // empty' "$CWD/package.json" 2>/dev/null)
if [[ -n "$HAS_LINT" ]]; then
LINT_OUT=$(cd "$CWD" && npm run lint --silent 2>&1) || ERRORS="${ERRORS}
=== Lint Errors ===
$(echo "$LINT_OUT" | tail -30)"
fi
# 3. Tests
HAS_TEST=$(jq -r '.scripts.test // empty' "$CWD/package.json" 2>/dev/null)
if [[ -n "$HAS_TEST" ]]; then
TEST_OUT=$(cd "$CWD" && CI=true npm test -- --watchAll=false 2>&1) || ERRORS="${ERRORS}
=== Test Failures ===
$(echo "$TEST_OUT" | tail -30)"
fi
fi
if [[ -n "$ERRORS" ]]; then
jq -nc --arg reason "Cannot commit — fix these issues first:
$ERRORS" \
'{permissionDecision:"deny",permissionDecisionReason:$reason}'
fi
exit 0
```
**What happens at runtime:**
| Scenario | stdout | exit | Host action |
| ---- | ---- | ---- | ---- |
| All checks pass | empty | `0` | Commit proceeds |
| Lint fails | `{"permissionDecision":"deny","permissionDecisionReason":"Cannot commit — fix these issues first:\n=== Lint Errors ===\n..."}` | `0` | Blocks commit; agent sees the errors and fixes them |
| jq missing | empty | non-zero | Hook failure |
### Example 2: Auto-format after file edits
**Why this pattern matters**: the agent writes code, and your formatter runs immediately after — no manual step needed. The agent's next read of that file sees the formatted version.
**Event**: `postToolUse` — fires after `edit` or `create` tool calls
**Config**`.github/hooks/format-on-save.json`:
```json
{
"version": 1,
"hooks": {
"postToolUse": [
{
"type": "command",
"bash": "./.github/hooks/scripts/format-on-save.sh",
"cwd": ".",
"timeoutSec": 15
}
]
}
}
```
**Script**`.github/hooks/scripts/format-on-save.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
tool_name="$(printf '%s' "$payload" | jq -r '.toolName')"
result_type="$(printf '%s' "$payload" | jq -r '.toolResult.resultType // ""')"
# Only format after successful file writes
case "$tool_name" in
edit|create) ;;
*) exit 0 ;;
esac
[[ "$result_type" != "success" ]] && exit 0
file_path="$(printf '%s' "$payload" | jq -r '.toolArgs' | jq -r '.path // ""')"
[[ -z "$file_path" || ! -f "$file_path" ]] && exit 0
# Run the project's formatter — adapt to your stack
if command -v npx >/dev/null 2>&1 && [[ -f "package.json" ]]; then
npx prettier --write "$file_path" 2>/dev/null || true
elif command -v dotnet >/dev/null 2>&1 && [[ "$file_path" == *.cs ]]; then
dotnet format --include "$file_path" 2>/dev/null || true
fi
exit 0
```
**What happens at runtime:**
| Scenario | What the hook does | exit |
| ---- | ---- | ---- |
| Agent edits `src/app.ts` successfully | Runs `prettier --write src/app.ts` | `0` |
| Agent runs `bash ls` | Skips (not a file-writing tool) | `0` |
| Prettier not installed | Silently skips formatting | `0` |
### Example 3: Block dangerous commands with structured deny
**Why this pattern matters**: the simplest guardrail — prevent destructive shell commands before they execute, with a clear reason the agent can read.
**Event**: `preToolUse` — fires before any tool call
**Config**`.github/hooks/block-dangerous.json`:
```json
{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"bash": "./.github/hooks/scripts/block-dangerous.sh",
"cwd": ".",
"timeoutSec": 5,
"env": {
"BLOCK_MODE": "deny"
}
}
]
}
}
```
**Script**`.github/hooks/scripts/block-dangerous.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
block_mode="${BLOCK_MODE:-log}"
tool_name="$(printf '%s' "$payload" | jq -r '.toolName')"
[[ "$tool_name" != "bash" ]] && exit 0
command="$(printf '%s' "$payload" | jq -r '.toolArgs' | jq -r '.command // ""')"
if printf '%s' "$command" | grep -qE 'rm -rf /|git reset --hard|git clean -fd|git push.*--force'; then
# Truncate command to avoid leaking secrets in deny reason or logs
short_cmd="$(printf '%.80s' "$command")"
if [[ "$block_mode" == "deny" ]]; then
jq -cn --arg reason "Destructive command blocked: ${short_cmd}..." \
'{permissionDecision:"deny",permissionDecisionReason:$reason}'
else
echo "Would block: ${short_cmd}..." >&2
fi
fi
exit 0
```
**What happens at runtime:**
| Scenario | BLOCK_MODE | stdout | exit | Host action |
| ---- | ---- | ---- | ---- | ---- |
| Safe command | any | empty | `0` | Proceeds |
| `git push --force` | `deny` | `{"permissionDecision":"deny",...}` | `0` | Blocks with reason |
| `git push --force` | `log` | empty | `0` | Proceeds (log only) |
## Event Types
The full hooks reference is authoritative. **Always check it for the latest payload shapes** before writing a hook:
- [Hooks configuration reference](https://docs.github.com/en/copilot/reference/hooks-configuration)
- [About hooks](https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-hooks)
| Event | stdout | Typical use |
| ---- | ---- | ---- |
| `sessionStart` | **parsed**`additionalContext` in stdout is injected into the session | Setup, validation, context injection, logging |
| `sessionEnd` | ignored | Cleanup, summaries |
| `userPromptSubmitted` | ignored | Auditing, prompt blocking |
| `preToolUse` | **parsed**`permissionDecision`, `modifiedArgs`/`updatedInput`, `additionalContext` | Guardrails, deny/block, argument modification |
| `postToolUse` | ignored | Logging, formatting |
| `postToolUseFailure` | — | Recovery after a failed tool run |
| `agentStop` | — | Final validation |
| `subagentStart` | — | Subagent audit |
| `subagentStop` | — | Subagent output validation |
| `errorOccurred` | ignored | Diagnostics, alerts |
| `preCompact` | — | Pre-compaction work |
| `permissionRequest` | — | Approval workflow |
### Payload schemas for common events
These are the payload shapes from the hooks reference. Always verify against the [official reference](https://docs.github.com/en/copilot/reference/hooks-configuration) for the latest fields.
**`sessionStart`**
```json
{
"timestamp": 1704614400000,
"cwd": "/path/to/project",
"source": "new",
"initialPrompt": "Create a new feature"
}
```
`source` is `"new"`, `"resume"`, or `"startup"`. `initialPrompt` is the user's first prompt if provided.
**`sessionStart` stdout output** — the host parses stdout for:
```json
{
"additionalContext": "Current branch: main. Deploy target: staging."
}
```
`additionalContext` is injected directly into the session conversation, letting hooks provide environment-specific context dynamically.
**`sessionEnd`**
```json
{
"timestamp": 1704618000000,
"cwd": "/path/to/project",
"reason": "complete"
}
```
`reason` is `"complete"`, `"error"`, `"abort"`, `"timeout"`, or `"user_exit"`.
**`userPromptSubmitted`**
```json
{
"timestamp": 1704614500000,
"cwd": "/path/to/project",
"prompt": "Fix the authentication bug"
}
```
The field is `prompt` — the exact text the user submitted.
**`preToolUse`**
```json
{
"timestamp": 1704614600000,
"cwd": "/path/to/project",
"toolName": "bash",
"toolArgs": "{\"command\":\"rm -rf dist\",\"description\":\"Clean build directory\"}"
}
```
`toolArgs` is a **JSON string** — parse it a second time to access its fields.
**`preToolUse` stdout output** — the host parses stdout for:
| Field | What it does |
| ---- | ---- |
| `permissionDecision` | `"deny"` blocks the tool call. `"allow"` and `"ask"` also accepted; only `"deny"` is currently processed. |
| `permissionDecisionReason` | Human-readable reason shown to the user |
| `modifiedArgs` or `updatedInput` | Replacement tool arguments — used instead of the originals |
| `additionalContext` | Text injected into the agent's context for this turn |
**`postToolUse`**
```json
{
"timestamp": 1704614700000,
"cwd": "/path/to/project",
"toolName": "bash",
"toolArgs": "{\"command\":\"npm test\"}",
"toolResult": {
"resultType": "success",
"textResultForLlm": "All tests passed (15/15)"
}
}
```
`resultType` is `"success"`, `"failure"`, or `"denied"`.
**`errorOccurred`**
```json
{
"timestamp": 1704614800000,
"cwd": "/path/to/project",
"error": {
"message": "Network timeout",
"name": "TimeoutError",
"stack": "TimeoutError: Network timeout\n at ..."
}
}
```
**`agentStop`**
```json
{
"timestamp": 1704618000000,
"cwd": "/path/to/project"
}
```
Minimal payload — use it to trigger end-of-session actions like running `git diff --stat` or final validation.
## When Hooks Are the Wrong Tool
| Avoid hooks for | Better fit |
| ---- | ---- |
| Open-ended reasoning or style guidance | Instructions, prompts, or agents |
| Long multi-step workflows with memory, retries, or branching | Agents, scripts, or workflow engines |
| Background daemons, watchers, debounce loops, or async jobs | Dedicated automation, services, or CI |
| Heavy repository-wide validation | CI, scheduled jobs, or dedicated automation |
## Universal Design Rules
| Rule | Why it matters |
| ---- | ---- |
| One hook, one responsibility | Small hooks are easier to trust and debug |
| Default to **observe first** | Blocking or mutation should be an explicit choice |
| Keep hooks synchronous, bounded, and non-interactive | Hooks run in the critical path |
| Make hooks deterministic and idempotent | Re-runs should not create drift |
| Do not mutate branch, index, or worktree state by default | Git-destructive behavior is high risk |
| Treat prompts, tool arguments, and tool output as untrusted and sensitive | Input may be hostile or private |
| Redact secrets, credentials, tokens, and private content from logs | Logs often outlive the hook run |
## Script Authoring Rules
- Validate the JSON fields you actually use
- Quote shell variables and never build commands from raw input
- Keep stdout clean unless the host requires structured output
- Use strict modes: Bash `set -euo pipefail`, PowerShell `Set-StrictMode -Version Latest`
- Check dependencies early and fail clearly if they are missing
- Avoid prompts, hidden installs, or environment mutation during execution
- Test scripts by piping representative JSON payloads into them manually
## Choose the Smallest Viable Implementation
1. **PowerShell 7**, **Node.js**, or **Python** for broadly portable hooks
2. **Bash** where Bash is an explicit requirement or safe assumption
3. **An existing project CLI** when the repository already depends on it
Do **not** introduce a new compiled runtime just to implement an ordinary hook.
## Packaging a Reusable Hook
- Package config, scripts, and docs together
- Document the trigger event, purpose, side effects, dependencies, and disable path
- Explain what the hook reads, what it writes, and what it blocks
## Anti-Patterns
- Long-running hooks, watchers, background daemons, or fire-and-forget async work
- Heavy scans on every event when a narrower trigger would do
- Hidden network calls or uploads in the critical path
- Silent mutation of Git state (checkout, reset, clean, stash, stage, commit, push, or history rewriting) by default
- Interactive prompts or implicit approval steps
- Noisy stdout, ad-hoc output formats, or mixed machine/human output
- Logging raw prompts, secrets, credentials, or large tool outputs
- Monolithic hooks that mix unrelated responsibilities
## Portability
### GitHub Copilot: CLI, VS Code, and Cloud Agent
The same `.github/hooks/*.json` config, the same payload schema, and the same script contract work across CLI, VS Code, and the cloud agent. Event names accept both camelCase (`preToolUse`) and PascalCase (`PreToolUse`). The documented payload field for tool arguments is `toolArgs` (a JSON string).
One thing to know: the cloud agent only loads hooks from the repository's **default branch**. If your hooks.json is only on a feature branch, the cloud agent won't see it.
### Claude Code
Claude Code uses a different hook system:
- Settings in `~/.claude/settings.json` and `.claude/settings.json`
- Different event names and matcher syntax (regex, `if` conditions)
- Exit 2 = block, exit 1 = non-blocking error (not the same as GitHub Copilot)
- 5 hook types (command, http, mcp_tool, prompt, agent)
- 29+ events including `FileChanged`, `CwdChanged`, `ConfigChange`
The shared best practice is the same: keep hooks small, deterministic, explicit about I/O, and strict about side effects.