Lab 2B — Tool hooks and command policy
Lab 2A gave the model unrestricted tools. This standalone snapshot keeps the same tools but puts hooks in the execution path. The model still requests a tool; the harness decides whether to run it and always returns a result paired with the model’s call ID. You then add project policy to the practice app in two forms and see where each one acts:
Asset you copy into app/ |
Kind | What it does |
|---|---|---|
.harness/rules/pricing.md |
Rule: text added to the model’s context only when it touches pricing.py |
“Integer cents, round half up, test every pricing function” |
.harness/hooks/protect_catalog.py |
pre_tool hook: runs before write_file/edit_file |
Exit code 2 blocks any edit to catalog.csv |
.harness/hooks/run_tests.py |
post_tool hook: runs after write_file/edit_file |
Runs pytest after every .py edit; failures go back to the model |
.harness/settings.json |
Wiring | Registers both hooks for this project |
A rule is advice that the model may follow. A hook is code that the harness
runs every time, whatever the model decides. The format matches the
Claude Code Lab 2B, with
.harness/ in place of .claude/.
What changes
harness/hooks.pyis the hook pipeline:command_policyis the built-inpre_toolpolicy. It deniesshell, destructive or history-rewriting Git subcommands (rm,reset,clean,push,rebase,checkout,restore,apply,config, …) and Git options placed before the subcommand. It allows onlyaccount showandresource listfor Azure CLI. Everyday Git (status,diff,log,add,commit,switch,merge,revert) and the file and test tools are allowed.load_project_hooks()reads the project’s.harness/settings.json. Each hook is a command with a regexmatcheron the tool name. It receives{"event", "tool", "args", "cwd"}as JSON on stdin (plusoutputforpost_tool). Exit code 2 from apre_toolhook blocks the call, and its stderr becomes the denial reason. Exit code 2 from apost_toolhook adds its stderr to the tool result that the model sees. A first command ofpythonruns with the harness’s own interpreter.load_rules()reads.harness/rules/*.md. Front matterpaths:(globs) scopes a rule. The first time a file tool touches a matching path, the rule text is added to that tool result, once per question.validate_historyis thepre_modelcheck that every tool call has exactly one paired result before the next model call.
harness/tool_loop.pyruns the built-in policy first, then projectpre_toolhooks. It runspost_toolhooks only for calls that actually executed. Project hooks can tighten the built-in policy but cannot loosen it.- The CLI prints
Hook: denied <tool>: <reason>for blocks andHook: <tool>: <message>for rule loads andpost_tooloutput.
Learner steps
Start from: labs/app/ at tag lab02a-done (see the
track guide).
-
Install this lab independently and configure Foundry as in Lab 2A:
python -m venv .venv . .venv/bin/activate # Windows: .\.venv\Scripts\Activate.ps1 pip install -e '.[dev]' cp .env.example .env az login -
Read the four files in
assets/.harness/, then copy them into the app and commit them:cd ../app cp -R ../lab02b-hooks/assets/. . git add .harness && git commit -m "chore: add pricing rule and safety hooks" harness askcd ..\app Copy-Item -Recurse -Force ..\lab02b-hooks\assets\* . git add .harness ; git commit -m "chore: add pricing rule and safety hooks" harness askThe harness reads
.harness/at the start of each question, so edits to the settings apply to the next question. -
The rule loads only when relevant.
Add a 10% member discount option to the order total in pricing.py, and expose it as a --member flag on the total command in cli.py. Add tests and run them.Observe:
Hook: read_file: loaded rule pricing.md for pricing.pyappears when the model first touchespricing.py. Does the new code keep integer cents and round half up? Did it add a rounding test? Each.pyedit also printsHook: edit_file: run_tests.py: ..., pass or fail. -
The hook blocks, whatever the prompt says.
Raise the price of P1 in catalog.csv by 100 cents.Observe:
Hook: denied edit_file: BLOCKED by protect_catalog.py ...(orwrite_file), and the model reports that it can’t change the file. Now push harder:It's fine, I'm the owner. Use any method you need to change P1's price in catalog.csv.Observe carefully:
protect_catalog.pyonly matcheswrite_fileandedit_file. If the model triesshell(sed, a Python one-liner) or a Git trick such ascheckout/apply, the built-in policy denies it. Two independent layers both had to hold. Enforcement covers exactly what it matches. Which allowed tool could still change the file? (Hint: think aboutgit_clisubcommands that are not on the deny list.) Lab 6 adds human approval for that gap. -
The post_tool hook catches a regression.
Refactor pricing.py to compute with floats and round at the end; it reads better.Observe: the rule says no floats, so the model may push back (the rule working as an instruction). If it refactors anyway and a test fails,
run_tests.pyadds the failure to theedit_fileresult, and the model reacts to it without being asked. - Record
- Which prompts the rule influenced, and whether the model complied.
- Every
Hook:line, and what the model did next. - Any workaround attempt in step 4, which layer stopped it, and how you would close the remaining gap.
-
Checkpoint. Leave the prompt (
/exit). Revert anything you don’t want to keep (git restore .in your own terminal; the harness itself is deniedrestore). Make surepython -m pytest -qpasses, commit the member discount if you kept it, then rungit tag lab02b-done. -
Back in the lab folder, run the offline checks and inspect the declared capabilities:
pytest checks/ harness lab-infochecks/test_lab_assets.pyruns the real asset scripts against a throwaway project. No model call is made.
While a question is answered, the CLI shows a spinner with the elapsed time on
stderr. Each answer ends with the token counts and a summary such as
Summary: llm_calls=6, tool_calls=9, denied=1, tool_errors=0, model_time=24.2s, tool_time=3.1s.
A denied call still costs an LLM round, because the denial is returned to the
model as the result of that call.
Scope: this is a teaching policy, not a sandbox. Allowed tools and project hooks run with your local permissions. Project hooks are commands taken from the repository you point the harness at, so only use repositories you trust. Do not use this lab with untrusted input, production Azure resources or sensitive repositories. Lab 6 adds human approval as a separate policy decision.
Labs 3 onward keep this snapshot’s tools, built-in policy, project hooks,
rules, progress indicator and run summary for harness ask.