mirror of
https://github.com/github/awesome-copilot.git
synced 2026-04-30 12:15:56 +00:00
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:
597
instructions/hooks.instructions.md
Normal file
597
instructions/hooks.instructions.md
Normal 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.
|
||||
Reference in New Issue
Block a user