# Hooks in Copilot CLI

Format edited files, block dangerous commands and log every tool call in ticket-service with Copilot CLI hooks.

Source: https://ai-sw-factory.mellicci.dev/fundamentals/hooks/copilot-cli

This page covers Copilot CLI; the cloud agent reads the same `.github/hooks/*.json` format, with fewer events and only `bash` entries honored.

## At a glance

| | Copilot CLI |
|---|---|
| Term | Hooks |
| Configured in | `.github/hooks/*.json` (repository), `~/.copilot/hooks/*.json` (user) |
| Loads / runs | Read at session start; each entry runs as a command (or HTTP call) when its event fires |
| Scope & precedence | Policy, user, repository and plugin sources are combined; every matching entry runs, none overrides another |

## Build the scenario

| Step | What you write or run | Where |
|---|---|---|
| 1 | Register the three scripts | `.github/hooks/ticket-service.json` |
| 2 | `log.sh`: append one JSON line per tool call | `.github/hooks/scripts/log.sh` |
| 3 | `guard.sh`: deny recursive `rm` and `.env*` writes | `.github/hooks/scripts/guard.sh` |
| 4 | `format.sh`: Prettier and ESLint on the edited file | `.github/hooks/scripts/format.sh` |
| 5 | `chmod +x` the scripts, start a new session | Terminal |

```json
{
  "version": 1,
  "hooks": {
    "preToolUse": [
      { "type": "command", "bash": "./.github/hooks/scripts/log.sh", "cwd": ".", "timeoutSec": 10 },
      { "type": "command", "matcher": "bash|edit|create",
        "bash": "./.github/hooks/scripts/guard.sh", "cwd": ".", "timeoutSec": 10 }
    ],
    "postToolUse": [
      { "type": "command", "matcher": "edit|create",
        "bash": "./.github/hooks/scripts/format.sh", "cwd": ".", "timeoutSec": 60 }
    ]
  }
}
```

`log.sh` turns the stdin payload into one line. Printing nothing lets the call proceed.

```bash
#!/bin/bash
IN=$(cat); DIR="$(jq -r .cwd <<<"$IN")/.agent/logs"
mkdir -p "$DIR"
jq -c '{ts: .timestamp, tool: .toolName,
  args: (.toolArgs | if type == "string" then (try fromjson catch .) else . end)}' \
  <<<"$IN" >> "$DIR/tool-calls.jsonl" || true
```

`guard.sh` denies by printing one JSON object with a reason.

```bash
#!/bin/bash
IN=$(cat); TOOL=$(jq -r .toolName <<<"$IN")
ARGS=$(jq -c '.toolArgs | if type == "string" then (try fromjson catch {}) else . end' <<<"$IN")
deny() { jq -cn --arg r "$1" '{permissionDecision:"deny",permissionDecisionReason:$r}'; exit 0; }
case "$TOOL" in
  bash) CMD=$(jq -r '.command // empty' <<<"$ARGS")
    grep -Eq 'rm +-[a-zA-Z]*(rf|fr|r)\b' <<<"$CMD" && deny "Recursive rm is blocked."
    grep -Eq '(>|tee|cp|mv|sed -i).*\.env' <<<"$CMD" && deny "Writing .env* files is blocked." ;;
  edit|create) P=$(jq -r '.path // .file_path // empty' <<<"$ARGS")
    [[ "$(basename "$P")" == .env* ]] && deny "Writing .env* files is blocked." ;;
esac
```

`format.sh` runs after a successful edit.

```bash
#!/bin/bash
ARGS=$(jq -c '.toolArgs | if type == "string" then fromjson else . end')
F=$(jq -r '.path // .file_path // empty' <<<"$ARGS")
[ -f "$F" ] || exit 0
pnpm prettier --write "$F" >&2
pnpm eslint --fix "$F" >&2 || true
```

## Specifics

### Events

| Event | Fires when | Can change the outcome |
|---|---|---|
| `sessionStart` | A new or resumed session begins | Adds `additionalContext` |
| `userPromptSubmitted` | You submit a prompt | No (config-file output is dropped) |
| `preToolUse` | Before each tool runs | Allow, deny or modify |
| `postToolUse` | After a tool succeeds | Replace result, add context |
| `agentStop` | The main agent finishes a turn | Block, forcing another turn |
| `subagentStop` | A subagent completes | Block, or replace its response |
| `errorOccurred` | An error occurs | No |

Others exist: `sessionEnd`, `userPromptTransformed`, `postToolUseFailure`, `permissionRequest`, `notification`, `preCompact`, `subagentStart`.

### Configuration and matchers

| Location | Scope |
|---|---|
| `.github/hooks/*.json` | Repository; also read by the cloud agent |
| `hooks` in `.github/copilot/settings.json` or `settings.local.json` | Repository, inline |
| `~/.copilot/hooks/*.json`, `hooks` in `~/.copilot/settings.json` | User |
| Plugin `hooks.json` | Installed plugin |
| `/etc/github-copilot/policy.d/*.json` (Linux, macOS) | Administrator policy |

Entry keys: `type` (`command`, `http` or `prompt`), `bash`, `powershell`, `command` (cross-platform fallback), `exec` with `args` (no shell), `cwd`, `env`, `timeoutSec`. `matcher` is a regex compiled as `^(?:PATTERN)$` against the tool name (`preToolUse`, `postToolUse`, `permissionRequest`), agent name (`subagentStart`), trigger (`preCompact`) or notification type. An invalid regex skips the entry. Tool names include `bash`, `powershell`, `create`, `edit`, `view`, `glob`, `grep`, `task`, `web_fetch`.

### Input and output

| Item | Detail |
|---|---|
| stdin | JSON with camelCase fields: `sessionId`, `timestamp` (Unix ms), `cwd`, plus `toolName`, `toolArgs` for tool events |
| `toolArgs` | Typed `unknown` in the reference; the examples deliver a JSON string, so the scripts above accept both |
| PascalCase event names | `PreToolUse` delivers snake_case fields (`tool_name`, `tool_input`) and Claude-style matchers |
| stdout | One JSON object; progress lines (`{"type":"progress"}`) are stripped first |

| Event | Output honored |
|---|---|
| `preToolUse` | `permissionDecision`, `permissionDecisionReason`, `modifiedArgs` |
| `postToolUse` | `modifiedResult`, `additionalContext` |
| `sessionStart`, `subagentStart` | `additionalContext` |
| `agentStop`, `subagentStop` | `decision`, `reason` |
| `userPromptSubmitted` | Dropped for config-file hooks |
| `errorOccurred`, `sessionEnd` | None |

### Blocking and decisions

| Mechanism | Effect |
|---|---|
| `preToolUse` JSON `deny` + reason | Tool call refused; the reason goes to the model. The reason is required |
| Exit `2` or any other non-zero exit (`preToolUse`) | Denied, even if stdout says `allow` |
| Other events, non-zero exit | Logged, run continues |
| `agentStop` `decision: "block"` | Forces another turn; capped at 8 in a row |

Several hooks on one event run in order; for `preToolUse`, one `deny` blocks the call.

### Scope, trust and limits

| Topic | Behavior |
|---|---|
| Timeout | `timeoutSec`, default 30. A timeout fails open for every event, including `preToolUse` |
| Output size | Hook output is bounded at 10 MiB |
| `copilot -p` | Repository hooks load only if the folder is trusted, `COPILOT_ALLOW_ALL` is set, or `GITHUB_COPILOT_PROMPT_MODE_REPO_HOOKS=true` |
| Disable | `disableAllHooks: true` in one hooks file, or in repository `settings.json` for every non-policy hook |
| Policy hooks | Administrator files ignore `disableAllHooks` and need no folder trust |

<warning>

Tool arguments can contain secrets, and hooks have no built-in redaction. Keep `.agent/logs/` out of version control, and review hook scripts like any code that runs with your permissions. See the [security model](https://ai-sw-factory.mellicci.dev/fundamentals/security-model).

</warning>

## Gotchas

- The documentation doesn't specify the argument names of `edit` and `create`; the scripts try `path` and `file_path`. Check a real line in `tool-calls.jsonl` first.
- A guard that exceeds `timeoutSec` lets the call proceed through the normal permission flow. Keep it fast.
- Stdout must be exactly one JSON object after progress lines; extra objects or text make the output ignored. Send tool noise to stderr, as `format.sh` does.
- The `rm` and `.env` checks are text matches on the command. They reduce risk and do not replace permissions or a [sandbox](https://ai-sw-factory.mellicci.dev/fundamentals/sandboxing).
- Session-start loading means edits to the JSON need a new session to take effect.
