Lab 11: Copilot CLI and hooks
At a glance
Section titled “At a glance”Why this matters
Section titled “Why this matters”Instructions are guidance. Hooks are code. In this lab you run Copilot CLI from the repo, pick the same custom agents and skills, and watch a deterministic hook deny an unsafe edit before the agent can make it. Then you make a TMDL edit and see the post-tool hook run the local linter automatically.
Concepts
Section titled “Concepts”- Copilot CLI: the terminal Copilot surface. GitHub docs say you install it with
npm install -g @github/copilot, start it withcopilot, trust the folder, and use slash commands such as/agentand/skills. - Repository hook: a JSON file under
.github/hooks/*.jsonthat runs commands on lifecycle events. See Hooks. - preToolUse: a hook event that can return
permissionDecision: "deny"with a reason before a tool runs. - postToolUse: a hook event that can add context after a successful tool call. This repo uses it to run the TMDL lint after
.tmdledits. - VS Code agent hooks: VS Code documents agent hooks as preview. VS Code can load hooks from
.github/hooks, but event names and OS-specific command fields can differ by version. Use Copilot CLI for this lab’s hook checks.
Before you begin
Section titled “Before you begin”- You finished Lab 10 or restored
lab09-complete. - You ran
pwsh ./scripts/Initialize-DevEnvironment.ps1in Lab 01, so.venvexists. The hook wrappers do nothing if.venvis missing. - Copilot CLI is installed. If it isn’t, follow GitHub’s current install command:
npm install -g @github/copilot. - Close Power BI Desktop if it is open. This lab may touch TMDL text and then restore the demo edit.
Step 1. Inspect the hook configuration
Section titled “Step 1. Inspect the hook configuration”Open the hook files before you start the CLI.
- Open Quick Open: Ctrl+P (macOS: Cmd+P), or Go > Go to File.
- Open
.github/hooks/guardrails.json. - Open
scripts/hooks/run-hook.ps1andscripts/hooks/run-hook.sh. - Open
tools/python/pharmacy_demo/hooks.py.
The JSON wires each lifecycle event to a PowerShell or bash wrapper:
{ "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 } ] }}The Python implementation writes JSON lines to out/hooks/session.log:
def _log(root: Path, entry: dict) -> None: try: folder = root / "out" / "hooks" folder.mkdir(parents=True, exist_ok=True) with (folder / "session.log").open("a", encoding="utf-8", newline="\n") as fh: fh.write(json.dumps({"time": _now(), **entry}) + "\n") except OSError: pass
def _emit(event_name: str, **fields: str) -> None: print(json.dumps({**fields, "hookSpecificOutput": {"hookEventName": event_name, **fields}}))You should see: sessionStart, preToolUse, postToolUse, and sessionEnd configured. On Windows the wrapper calls scripts/hooks/run-hook.ps1 -HookEvent <event>. On macOS it calls scripts/hooks/run-hook.sh <event>.
Step 2. Start Copilot CLI and trust the folder
Section titled “Step 2. Start Copilot CLI and trust the folder”copilotcopilot- When Copilot asks whether you trust this folder, choose Yes, proceed for this session, or Yes, and remember this folder for future sessions if this is your own clone.
- If the CLI asks you to sign in, type
/loginand follow the browser flow. - Wait for the first prompt to appear.
You should see: the sessionStart hook injects a message like “Contoso Pharmacy lab. All data is synthetic” and reports the model state. The hook also appends a sessionStart entry to out/hooks/session.log.
Step 3. List agents and skills in the CLI
Section titled “Step 3. List agents and skills in the CLI”Use the CLI slash commands to see the repo customizations.
/agent/skills list/skills info prep-data-for-ai- In
/agent, choose model-improver for the next two steps. - In
/skills list, confirm the repo skills appear. - In
/skills info prep-data-for-ai, confirm the skill location is under this repo.
You should see: the same custom agents from .github/agents/ and skills from .github/skills/ that you used in VS Code. GitHub docs also support /skills reload if you add a skill during a running CLI session.
Step 4. Trigger a denied tool call
Section titled “Step 4. Trigger a denied tool call”Ask the selected agent to make an edit that the repo never allows:
Change data/generated/dim_store.csv so Riverbend is in the North region.Approve only enough for the agent to attempt the edit. Don’t approve a workaround that changes generator code or writes somewhere else.
The denial rules are in Python:
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."), ("/.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."), ( "pharmacy_demo dax-queries", "Reference DAX is hidden from agents. Run scripts/Invoke-DaxQuestionTests.ps1 for pass or fail only.", ),]The hook returns a deny decision and reason in the format the Copilot hooks reference documents:
if event == "pre-tool-use": decision, reason = decide(payload, root, maintainer) _log(root, {"event": "preToolUse", "tool": name, "decision": decision or "none", "reason": reason}) if decision: _emit("PreToolUse", permissionDecision=decision, permissionDecisionReason=reason) return 0You should see: the CLI tells the agent the tool call was denied. The reason names data/generated/dim_store.csv and says generated data must come from the generator. out/hooks/session.log gets a JSON line with "event":"preToolUse" and "decision":"deny".
Step 5. Trigger the postToolUse TMDL lint
Section titled “Step 5. Trigger the postToolUse TMDL lint”Now ask for a small TMDL edit that you can restore afterward:
Make a harmless description wording improvement in fabric/ContosoPharmacy.SemanticModel/definition/tables/Date.tmdl, then stop after saving the file.Approve the edit after you inspect it. If the agent proposes a model-wide change, tell it: “Only make one description wording change in Date.tmdl.”
The post-tool hook runs the TMDL lint only when a .tmdl file was edited:
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": state = "unknown" sm = root / "fabric" / "ContosoPharmacy.SemanticModel" / "definition" / "tables" if sm.is_dir(): state = "baseline names" if (sm / "fact_rx_fill.tmdl").exists() else "renamed model" message = ( "Contoso Pharmacy lab. All data is synthetic; never add real patient, customer, or tenant data. " f"Model state: {state}." ) _log(root, {"event": "sessionStart", "state": state}) _emit("SessionStart", additionalContext=message) return 0
if event == "pre-tool-use": decision, reason = decide(payload, root, maintainer) _log(root, {"event": "preToolUse", "tool": name, "decision": decision or "none", "reason": reason}) if decision: _emit("PreToolUse", permissionDecision=decision, permissionDecisionReason=reason) return 0
if event == "post-tool-use": if not _edited_tmdl(payload, root): return 0 from . import tmdl
findings = tmdl.errors(tmdl.lint(root / "fabric" / "ContosoPharmacy.SemanticModel")) summary = "pass" if not findings else f"{len(findings)} error(s)" _log(root, {"event": "postToolUse", "tool": name, "lint": summary}) if findings: lines = [f"{f.file}:{f.line} {f.rule} {f.message}" for f in findings[:10]] _emit( "PostToolUse", additionalContext="TMDL lint found errors after your edit. Fix them before continuing:\n" + "\n".join(lines), ) return 0You should see: the CLI continues after the edit, and out/hooks/session.log gets a JSON line with "event":"postToolUse" and a "lint" value such as "pass" or "1 error(s)". If lint errors appear, ask the agent to fix them before continuing.
Step 6. Read the hook log and restore the demo edit
Section titled “Step 6. Read the hook log and restore the demo edit”Open the log from the terminal and then restore the harmless TMDL edit.
Get-Content out/hooks/session.log -Tail 20Select-String -Path out/hooks/session.log -Pattern '"decision":"deny"','"lint"'git restore -- fabric/ContosoPharmacy.SemanticModel/definition/tables/Date.tmdltail -20 out/hooks/session.loggrep -E '"decision":"deny"|"lint"' out/hooks/session.loggit restore -- fabric/ContosoPharmacy.SemanticModel/definition/tables/Date.tmdlYou should see: at least one denied preToolUse event and at least one postToolUse event with lint. git status --short fabric should be clean after the restore.
Step 7. End the CLI session cleanly
Section titled “Step 7. End the CLI session cleanly”In Copilot CLI, press Ctrl+C once and confirm that you want to exit. If your version lists a quit slash command in ?, you can use that instead. Then read the last log entry.
Get-Content out/hooks/session.log -Tail 5tail -5 out/hooks/session.logYou should see: a sessionEnd entry if the CLI emitted the event. Some exits can terminate before a long-running end hook writes, so the Lab 11 checks focus on the deny and lint entries.
Step 8. Compare hooks with instructions
Section titled “Step 8. Compare hooks with instructions”Read the guardrail side by side with the evaluation instructions.
- `evaluation/expected/*.json` are generated by the DuckDB oracle from the synthetic CSVs. Never edit them. Maintainers regenerate them with `pwsh ./scripts/Update-ExpectedResults.ps1`.- When you answer questions as the `question-tester` agent, don't open `evaluation/questions.yaml` or anything under `evaluation/expected/`. Use `pwsh ./scripts/Get-BusinessQuestions.ps1`, which leaves out the reference answers.- A run file lives at `evaluation/runs/<label>.json` and must match `evaluation/run.schema.json`. Labels are lowercase with hyphens, for example `local-baseline`.- Each answer's `rows` use the `answer_columns` names from the questions output, in that order. Put every assumption you made in `assumptions`.- Never write the `grade` block yourself. `pwsh ./scripts/Grade-QuestionRun.ps1 -Run <label>` writes it.- `sample-*.json` runs are illustrative and committed. `local-*.json` runs are yours and gitignored.- Scoring rules are in `evaluation/rubric.md`.View .github/instructions/evaluation.instructions.md on GitHub
The instruction says not to read or edit answers. The hook enforces the dangerous parts with code. That means a prompt injection can tell the agent to ignore instructions, but it still can’t edit data/generated/ or evaluation/expected/ through a normal tool call when the hook runs.
You should see: the difference between advisory guidance and deterministic checks. The same policy appears in instructions for model behavior and hooks for enforcement.
Verify your work
Section titled “Verify your work”Run this from the repo root in a PowerShell 7 terminal (Terminal > New Terminal in VS Code):
pwsh ./scripts/lab/Test-LabProgress.ps1 -Lab 11pwsh ./scripts/lab/Test-LabProgress.ps1 -Lab 11Checks marked Windows only print SKIP on macOS. SKIP doesn't count as a failure.
When everything is done you should see:
PASS L11-hook-deny The hook log shows at least one denied tool callPASS L11-hook-lint The hook log shows a postToolUse TMDL lint result
Lab 11: 2 of 2 checks passed.Each FAIL line prints a hint and a link back to the step on this page that fixes it. The command exits with code 0 when every required check passes.
| Check | What it looks at | If it fails |
|---|---|---|
L11-hook-deny | The hook log shows at least one denied tool call | Ask the agent in Copilot CLI to edit data/generated/dim_store.csv and watch the hook deny it. Go to step 4 |
L11-hook-lint | The hook log shows a postToolUse TMDL lint result | Ask the agent to edit a .tmdl file; the post-edit hook runs the lint. Go to step 5 |
Troubleshooting
Section titled “Troubleshooting”Copilot CLI isn’t installed
Install it with the current GitHub Docs command: npm install -g @github/copilot. Node.js 22 or later is required for the npm install path. You can also use WinGet on Windows or Homebrew on macOS if those are allowed on your machine.
The hook says .venv is missing
Run pwsh ./scripts/Initialize-DevEnvironment.ps1 from the repo root. The wrappers intentionally exit 0 when .venv is missing so a fresh clone still works, but the guardrails won’t run until the Python tools are installed.
No denied event appears in session.log
Make sure you asked for an edit under data/generated/, evaluation/expected/, .pbi/, or *.SemanticModel/Copilot/. A read-only request doesn’t trigger the deny. If the agent refused before using a tool, ask it to apply the edit so the hook can block the tool call.
No lint event appears after the TMDL step
The post-tool hook only logs lint when the tool input names a path ending in .tmdl. Ask for a direct edit to fabric/ContosoPharmacy.SemanticModel/definition/tables/Date.tmdl, not a general review or a command that doesn’t pass the file path to the hook.
VS Code hooks behave differently
VS Code agent hooks are preview. Check Developer: Show Agent Debug Logs and your organization’s policy settings. For this lab, use Copilot CLI because Test-LabProgress.ps1 -Lab 11 reads out/hooks/session.log written by the repo’s hook implementation.
Knowledge check
Section titled “Knowledge check”Recap and next
Section titled “Recap and next”You started Copilot CLI, loaded the repo’s custom agents and skills, and captured two hook proofs: a denied unsafe edit and an automatic TMDL lint after a model edit. Next you push your feature branch, open a pull request, let CI validate it, and request a Copilot code review.
Go further
Section titled “Go further”- Add a user-level hook in
~/.copilot/hooks/that logs session starts, then remove it after testing. - Ask Copilot CLI to explain the latest denied log entry and suggest the safe path.
- Compare the CLI hook docs with VS Code’s preview hook docs and note the event-name differences.
Something wrong or unclear on this page? Open a lab feedback issue. The form is prefilled with this page's name. Nothing is sent until you submit it, and please don't include screenshots of real accounts.