Hooks
What hooks do
Section titled “What hooks do”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.
{ "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 } ] }}Wrappers
Section titled “Wrappers”Windows uses PowerShell:
#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 $HookEventexit 0macOS, Linux, and the cloud-compatible path use bash:
#!/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 -uroot="$(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 0fi"$python" -m pharmacy_demo --root "$root" hook "$1"exit 0Both 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.
Python implementation
Section titled “Python implementation”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.
"""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 theCopilot 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, andhookSpecificOutput for the VS Code Local harness. Each runtime ignores the fields it doesn't use."""
from __future__ import annotations
import jsonimport osimport reimport sysfrom datetime import UTC, datetimefrom pathlib import Path
PATH_KEYS = { "path", "paths", "file", "files",Denied edits
Section titled “Denied edits”| Path fragment | Why |
|---|---|
data/generated/ | Generated data. Change the generator and run New-SyntheticData.ps1. |
evaluation/expected/ | Expected answers come from the DuckDB oracle. |
/.pbi/cache.abf | Power BI Desktop cache. |
/.pbi/localSettings.json | Desktop local settings. |
.SemanticModel/Copilot/ | Desktop writes the Copilot folder. Draft in fabric/ai-prep/. |
"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.
("/.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.
( "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"),Post-edit lint
Section titled “Post-edit lint”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.
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.
Input and output JSON
Section titled “Input and output JSON”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." }}Test by hand
Section titled “Test by hand”'{"toolName":"edit","toolArgs":{"file":"data/generated/dim_store.csv"}}' | pwsh -NoProfile -File ./scripts/hooks/run-hook.ps1 -HookEvent pre-tool-useprintf '%s' '{"tool_name":"edit","tool_input":{"file_path":"data/generated/dim_store.csv"}}' | ./scripts/hooks/run-hook.sh pre-tool-useYou should see a deny decision. If .venv is missing, you should see only the wrapper warning.
Surface support
Section titled “Surface support”| Surface | Status |
|---|---|
| VS Code | Supported as preview hooks in .github/hooks/*.json. |
| Copilot CLI | Supported by GitHub CLI docs. |
| Copilot cloud agent | Check current docs and org policy. The repo has a Linux wrapper, but support can be preview gated. |
| github.com chat | Not a hook surface. |