Skip to content

Hooks

Hooks are deterministic guardrails. Instructions tell agents what they should do. Hooks decide what a tool call can do.

This repo uses .github/hooks/guardrails.json for four events: sessionStart, preToolUse, postToolUse, and sessionEnd.

.github/hooks/guardrails.json
{
"version": 1,
"hooks": {
"sessionStart": [
{
"type": "command",
"bash": "./scripts/hooks/run-hook.sh session-start",
"powershell": "pwsh -NoProfile -File ./scripts/hooks/run-hook.ps1 -HookEvent session-start",
"cwd": ".",
"timeoutSec": 20
}
],
"preToolUse": [
{
"type": "command",
"bash": "./scripts/hooks/run-hook.sh pre-tool-use",
"powershell": "pwsh -NoProfile -File ./scripts/hooks/run-hook.ps1 -HookEvent pre-tool-use",
"cwd": ".",
"timeoutSec": 20
}
],
"postToolUse": [
{
"type": "command",
"bash": "./scripts/hooks/run-hook.sh post-tool-use",
"powershell": "pwsh -NoProfile -File ./scripts/hooks/run-hook.ps1 -HookEvent post-tool-use",
"cwd": ".",
"timeoutSec": 30
}
],
"sessionEnd": [
{
"type": "command",
"bash": "./scripts/hooks/run-hook.sh session-end",
"powershell": "pwsh -NoProfile -File ./scripts/hooks/run-hook.ps1 -HookEvent session-end",
"cwd": ".",
"timeoutSec": 10
}
]
}
}

Windows uses PowerShell:

scripts/hooks/run-hook.ps1
#Requires -Version 7.2
<#
.SYNOPSIS
Copilot hook entry point on Windows. Forwards the hook payload to python -m pharmacy_demo hook.
.DESCRIPTION
Referenced by .github/hooks/guardrails.json. Reads the JSON payload from stdin and passes it to
the Python handler, which prints a decision (deny or ask) or nothing. If the repo's .venv doesn't
exist yet, the hook does nothing and lets the tool call through, so a fresh clone still works
before scripts/Initialize-DevEnvironment.ps1 has run.
.PARAMETER HookEvent
session-start, pre-tool-use, post-tool-use, or session-end.
#>
[CmdletBinding(PositionalBinding = $false)]
param(
[Parameter(Mandatory)]
[ValidateSet('session-start', 'pre-tool-use', 'post-tool-use', 'session-end')]
[string] $HookEvent
)
$root = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..' '..')).Path
$python = $IsWindows ? (Join-Path $root '.venv' 'Scripts' 'python.exe') : (Join-Path $root '.venv' 'bin' 'python')
if (-not (Test-Path -LiteralPath $python)) {
[Console]::Error.WriteLine('Lab hooks inactive: run scripts/Initialize-DevEnvironment.ps1 to create .venv.')
exit 0
}
$payload = [Console]::In.ReadToEnd()
$payload | & $python -m pharmacy_demo --root $root hook $HookEvent
exit 0

macOS, Linux, and the cloud-compatible path use bash:

scripts/hooks/run-hook.sh
#!/usr/bin/env bash
# Copilot hook entry point on macOS, Linux, and the Copilot cloud agent.
# Forwards the hook payload on stdin to python -m pharmacy_demo hook <event>.
# If .venv doesn't exist yet the hook does nothing, so a fresh clone still works.
set -u
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
python="$root/.venv/bin/python"
if [ ! -x "$python" ]; then
echo "Lab hooks inactive: run scripts/Initialize-DevEnvironment.ps1 to create .venv." >&2
exit 0
fi
"$python" -m pharmacy_demo --root "$root" hook "$1"
exit 0

Both wrappers find the repo root, check for .venv, read JSON from stdin, and call python -m pharmacy_demo --root <repo> hook <event>. If .venv is missing, they warn and allow the call so a fresh clone still works.

The implementation is tools/python/pharmacy_demo/hooks.py. It accepts both Copilot CLI style payloads (toolName, toolArgs) and snake_case payloads (tool_name, tool_input). Silence means “no opinion”. It never emits an allow decision.

tools/python/pharmacy_demo/hooks.py (lines 1-24)
"""Agent hook handler shared by the pwsh and bash wrappers in scripts/hooks/.
Reads the hook payload (JSON) on stdin and writes a JSON decision on stdout. Accepts both the
Copilot CLI payload shape (toolName, toolArgs) and the snake_case shape (tool_name, tool_input).
Silence means "no opinion": this handler never returns "allow", so it can't bypass a permission prompt.
Output carries both shapes in one object: top-level fields for Copilot CLI and the cloud agent, and
hookSpecificOutput for the VS Code Local harness. Each runtime ignores the fields it doesn't use.
"""
from __future__ import annotations
import json
import os
import re
import sys
from datetime import UTC, datetime
from pathlib import Path
PATH_KEYS = {
"path",
"paths",
"file",
"files",
Path fragmentWhy
data/generated/Generated data. Change the generator and run New-SyntheticData.ps1.
evaluation/expected/Expected answers come from the DuckDB oracle.
/.pbi/cache.abfPower BI Desktop cache.
/.pbi/localSettings.jsonDesktop local settings.
.SemanticModel/Copilot/Desktop writes the Copilot folder. Draft in fabric/ai-prep/.
tools/python/pharmacy_demo/hooks.py (lines 29-49)
"target_file",
"targetFile",
"uri",
"dirPath",
"old_path",
"new_path",
"source",
"destination",
}
COMMAND_KEYS = {"command", "cmd", "script", "commandLine"}
NEVER_EDIT = [
(
"data/generated/",
"Generated data. Change tools/python/pharmacy_demo/datagen.py and run scripts/New-SyntheticData.ps1.",
),
(
"evaluation/expected/",
"Expected answers come from the DuckDB oracle. Run scripts/Update-ExpectedResults.ps1.",
),
("/.pbi/cache.abf", "Power BI Desktop cache file."),

Denied reads and commands for non-maintainers

Section titled “Denied reads and commands for non-maintainers”

Without CPDEMO_MAINTAINER=1, the hook also blocks expected answers, evaluation/questions.yaml, model generator files, sample-answer files, and pharmacy_demo dax-queries. That keeps the question-tester from reading answers.

tools/python/pharmacy_demo/hooks.py (lines 50-63)
("/.pbi/localSettings.json", "Power BI Desktop local settings."),
(
".SemanticModel/Copilot/",
"Power BI Desktop writes the Copilot folder. Draft in fabric/ai-prep/ and apply through Prep data for AI.",
),
]
MAINTAINER_ONLY = [
("evaluation/expected/", "Expected answers are hidden from agents so question runs stay honest."),
(
"evaluation/questions.yaml",
"questions.yaml holds reference answers. Use: python -m pharmacy_demo questions.",
),
("tools/python/pharmacy_demo/modelgen/", "The checkpoint generator contains the lab answers."),
("tools/python/pharmacy_demo/samples.py", "Sample runs are generated from the answers."),

Shell denials include force push, printing GitHub tokens, printing cloud access tokens, and changing global Git config. Destructive cleanup commands such as git reset --hard return an ask decision.

tools/python/pharmacy_demo/hooks.py (lines 64-77)
(
"pharmacy_demo dax-queries",
"Reference DAX is hidden from agents. Run scripts/Invoke-DaxQuestionTests.ps1 for pass or fail only.",
),
]
SHELL_DENY = [
(
re.compile(r"\bgit\s+push\b[^\n]*(\s--force\b|\s-f\b|\s--force-with-lease\b)"),
"Force push is blocked in this repo.",
),
(re.compile(r"\bgh\s+auth\s+token\b"), "Printing GitHub tokens is blocked."),
(re.compile(r"get-access-token"), "Printing cloud access tokens is blocked."),
(
re.compile(r"\bgit\s+config\s+--global\b"),

After a tool edits a .tmdl file, the hook runs the TMDL linter. A clean edit logs pass. Errors are returned to the agent as additional context.

tools/python/pharmacy_demo/hooks.py (lines 197-217)
r"(^|[\s;&|])(>|>>|Set-Content|Out-File|tee)\s*[^\n]*data/generated/", normalized
):
return "deny", NEVER_EDIT[0][1]
for pattern, reason in SHELL_ASK:
if pattern.search(command):
return "ask", reason
return None, ""
def _edited_tmdl(payload: dict, root: Path) -> bool:
_, args = _tool(payload)
targets = _collect(args, PATH_KEYS) + _patch_paths(args)
return any(t.lower().endswith(".tmdl") for t in targets)
def main(event: str, root: Path) -> int:
payload = _payload()
maintainer = os.environ.get("CPDEMO_MAINTAINER") == "1"
name, _ = _tool(payload)
if event == "session-start":

The session log is out/hooks/session.log as JSON Lines.

Accepted input shapes:

{"toolName":"edit","toolArgs":{"file":"data/generated/dim_store.csv"}}
{"tool_name":"edit","tool_input":{"file_path":"data/generated/dim_store.csv"}}

Deny output includes both top-level fields and hookSpecificOutput:

{
"permissionDecision": "deny",
"permissionDecisionReason": "data/generated/dim_store.csv: Generated data. Change tools/python/pharmacy_demo/datagen.py and run scripts/New-SyntheticData.ps1.",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "data/generated/dim_store.csv: Generated data. Change tools/python/pharmacy_demo/datagen.py and run scripts/New-SyntheticData.ps1."
}
}
Terminal window
'{"toolName":"edit","toolArgs":{"file":"data/generated/dim_store.csv"}}' |
pwsh -NoProfile -File ./scripts/hooks/run-hook.ps1 -HookEvent pre-tool-use

You should see a deny decision. If .venv is missing, you should see only the wrapper warning.

SurfaceStatus
VS CodeSupported as preview hooks in .github/hooks/*.json.
Copilot CLISupported by GitHub CLI docs.
Copilot cloud agentCheck current docs and org policy. The repo has a Linux wrapper, but support can be preview gated.
github.com chatNot a hook surface.