Chapter 11: A Word Before the Action — Hooks

Xiao A's agent had wired session state into the loop. During a security review he hit a problem: when the model is about to call the write-file tool, who can check the path before it actually runs? He raised the question at the walkthrough.
"I'll just add a line to the prompt saying 'check before writing,' and it'll probably comply," Xiao A suggested.
"A prompt can only suggest, not guarantee — that bets on the model happening to comply when it continues by probability," Lao Z shook his head. "A prompt at most gets the model to 'want to check'; a hook lets you 'be able to check': the code you write is called when the event fires, leaving the runtime a verifiable point of intervention where you can genuinely say 'stop' before the action happens. The two paths are entirely different. Miss the rule this time and the rule is gone; as long as the mount point stays, the code stays. What the walkthrough has to answer is whether the rule has a home that lands without relying on the model's self-discipline."
This chapter discusses interception, subscription, and failure policy for Hooks (often written Agent Hook / Agent Hooks) in agent systems; it prescribes no function names or execution order any framework must use.
Align the names first
| Term | English / abbreviation | Working definition in this book | What it is not |
|---|---|---|---|
| Agent hook | Agent Hook / Hook | application code called when an agent lifecycle event fires | a general industry API standard |
| interceptor | interceptor | a hook that may allow, deny, or rewrite the main path | a logging-only listener |
| subscriber / listener | subscriber / listener | a receiver that observes events without changing the main path | an approval or authorization mechanism |
| lifecycle event | lifecycle event | a state-transition point the runtime agrees on | any function call |
| reentrancy | reentrancy | a hook firing the same event chain again while it runs | ordinary asynchronous waiting |
A hook is a runtime point of intervention
"So what exactly is a hook?" Xiao A asked.
"A hook is application code called when a lifecycle event fires. It can, before the action, allow, deny, or rewrite the input; it can also, after the action, record the result," Lao Z said after a moment, reaching for an analogy. "Think of it as a security checkpoint: the passengers are tool calls, the officers are hooks. Names and capabilities are defined by the concrete implementation — beforeToolCall, afterToolCall are just common naming, not a universal standard."
"Just put a hook in front of the tool — is that safe?" Xiao A asked.
"You can't infer that. A hook is one policy enforcement point. If it doesn't cover a call path, or if it is itself bypassed, the risk remains."
| Intervention mode | Changes the main path? | Suitable duty |
|---|---|---|
| intercept | may allow, deny, or rewrite | approval, argument validation, quota |
| subscribe | should not change the result | audit, metrics, debug events |
"Put interception and subscription in the wrong place — what breaks?" Xiao A asked.
"Two ways to break," Lao Z said. "Cram logging into an interceptor, and the main path now depends on a component that should have had no side effects: when the logging service slows or goes down, tool calls that would have succeeded are dragged down or fail on timeout — and worse, when you troubleshoot you suspect the tool itself first, never the log write lurking in the interception chain. Make approval a subscription, and by the time the subscriber gets the event the action has often already begun or finished, so at best you get an after-the-fact alarm — it can't stop anything at all. That's like a camera discovering the contraband only after the passenger has boarded. So logging belongs to subscriptions, approval to interceptors; the two duties must be separated."
Metaphor: the security checkpoint
Interception is the officer (can let through, stop, open the bag); subscription is the camera (only records, never stops anyone). Don't mistake the camera for the officer.
"Which layer do you hang it on to call it a 'lifecycle event'?" Xiao A asked.
"The boundary depends on which observable state transitions your runtime defines," Lao Z said. "Before and after a tool call is the most common mount point, because an action has a definite moment of occurrence; turn start and end, session establish and disconnect also get hooked often. Two criteria decide it: is this transition point stably observable, and does the code hung on it run at exactly the moment you need. Define events too coarsely and interception goes blunt; define them too finely and the mount cost and the mismatch risk climb."
"What do coarse and fine actually look like?" Xiao A pressed.
"Coarse enough that 'a whole turn' is a single event: to stop 'writing a file' you'd have to parse it out of the turn's entire content yourself — that's reimplementing lifecycle judgment, and it may still miss internal calls. Fine enough that every token emitted counts as an event: the recording alone swamps the main path. The common compromise is two layers: define events by tool action so interception is exact to the single call; define state by turn so observation serves aggregation and audit. Whichever layer you fix the granularity at, your policy can only be as exact as that layer — write it into the contract."
"If an interceptor and a subscriber both error, is the blast radius the same?"
"Not at all," Lao Z said. "An interceptor is on the decision path; the moment it throws or times out, the main path either stalls or continues with an incomplete decision — both are wrong. A subscriber is on a side path; its error shouldn't affect the main path in principle, but that 'shouldn't' depends on how the framework implements it: fold the subscriber's exception into the main path, or make the main path serially wait on a slow subscriber, and the side path becomes part of the main path. So write the subscriber's failure isolation into the contract."
"Ignore it, log it, rethrow it — what does each cost?"
"Ignore the exception and the main path runs on when the logging service is broken, at the cost of no audit evidence for that window. Rethrow and any broken subscriber callback can fail the call, making the main path extremely brittle. The compromise is a separate recording channel for subscribers: an exception that can't reach the main chain still leaves a trace. If you don't spell this out, implementers usually pick whatever is easiest to write — silence-swallowing — and you only learn after the incident that not even evidence was kept."
Define the events first, then the contract
"So what should an interceptor actually return?" Xiao A asked.
"Define the events first, then the contract. A minimal tool-call lifecycle can be written as:"
produce call request → validate arguments → pre-execution policy → run tool → post-process → record event"The pre-execution policy in that sketch must state its return semantics clearly: allow, deny, need human confirmation, or replace the input," Lao Z said. "A denial should also carry a structured reason, used separately by the caller, the model, and the audit record — don't just silently drop the request."
"Hang several hooks together, run them serially in registration order — that should be enough, right?" Xiao A guessed.
"Not enough — order is part of the contract," Lao Z said. "Serial in registration order is a common scheme, but it isn't inherently correct: parallel subscribers may deliver out of order, and serial subscribers turn a slow observer into a main-path bottleneck. So write order into the contract: layer by duty first — security-class policies must run before anything (path boundaries, permission checks), business-class policies next (quota, rewriting), pure observers last (logging, metrics); when a layer has more than one hook, either mark priorities explicitly or agree on a short-circuit order. Don't let order be decided by the accident of registration timing, or a different startup order changes the behavior. Priority, short-circuit rules, timeout, and cancellation propagation — spell all of these out."
"Out-of-order parallel subscriptions — what's the real problem?"
"Mainly distorted timestamps in audit and metrics," Lao Z said. "Say two subscribers record 'call started' and 'call finished'; if they run in parallel and out of order, your log can show 'finished' before 'started'. That doesn't affect the main path, but it makes the event chain fail to line up when you troubleshoot. So parallel subscription suits metric aggregation that doesn't care about order; an event chain that needs order should be recorded serially, or carry a monotonically increasing event ID."
"That ordering layering — can it be laid out as a table?"
Lao Z wrote on the board:
| Layer | Example duties | Ordering requirement | Common failure |
|---|---|---|---|
| security policy | path boundaries, permission checks | must run first | deciding on non-normalized arguments |
| business policy | quota, approval, rewriting | after security policy | depending on the upstream policy's result |
| observer | logging, metrics, event records | not required to go first | a slow observer drags down the serial chain |
"Hang several hooks in the same layer — how do you set the order?" Xiao A asked.
"Marking priorities or agreeing on a short-circuit order, both work, as I said. Two counterexamples worth remembering: run a layer in parallel and the order isn't reproducible, so any construct that depends on before/after breaks; run it in series and the slowest hook at the front holds up every hook behind it. Order itself isn't right or wrong — reproducibility is what matters: as long as two runs' behavior can be explained by one and the same log, that order is manageable."
One controlled confirmation point
"Can you show me a minimal implementation?" Xiao A asked.
"This is a simplified sketch, not any framework's API:"
type Decision = { allow: true } | { allow: false; reason: string };
async function approveWrite(path: string): Promise<Decision> {
if (!path.startsWith(workspaceRoot + "/")) {
return { allow: false, reason: "path escapes the workspace" };
}
return await requestHumanApproval({ action: "write", path });
}"The confirmation let it through once — doesn't that mean the path is safe?" Xiao A asked.
"No. The confirmation resolves only this one action; it can't replace path resolution, symlink-escape defense, credential isolation, and execution-environment limits," Lao Z said. "There's another layer: the policy has to check the normalized actual arguments, otherwise the model takes the denial reason and rephrases its way around it."
"Rephrase around the rule — what does that look like, concretely?"
"Suppose the interceptor denies any write whose path starts with ../ and returns that reason to the model," Lao Z said. "Next turn the model might change the path to ./subdir/../../etc/ — reworded, no longer literally starting with ../, yet after normalization pointing at the same target. A policy that only string-matches and never normalizes a path gets bypassed."
"A hook can rewrite input, then — can it just silently turn a dangerous argument into a safe one?" Xiao A asked again.
"It can, but be very restrained. Rewriting is more dangerous than denying — it quietly substitutes for the model's intent," Lao Z said. "Change the path ../../etc/passwd to ./local/passwd and it looks harmless, but the model may keep reasoning on the original path, so its later actions no longer line up with the rewritten actual state. So a rewrite must either echo the rewritten result back to the model, so it knows the real arguments changed, or else deny outright, letting the model decide again. Silent rewriting is burying an inconsistency in the main path."
"When it's denied, won't the model just keep switching wording and retrying?"
"It will — that's a 'denial storm,'" Lao Z said. "You return 'path out of bounds' as the reason, the model switches wording and tries again next turn, and each time it walks the entire policy chain. So give the denial reason at a granularity the model can understand: which specific boundary it crossed, what the normalized actual path is. For repeated denials of the same action, either rate-limit or escalate to a human; assume on the policy side that the model will definitely come back with the reason and retry."
"What about that 'needs human confirmation' return semantic?" Xiao A continued.
"It's a third state between allow and deny: the main path parks and waits on a human decision, and the model gets no final result," Lao Z said. "There's a granularity question here: confirm once per action, or authorize a class of actions for a period? Too fine and the human is interrupted back and forth; too coarse and everything of that class inside the authorization window goes through — that's outsourcing the decision to time. So confirmation granularity has to be revocable: every authorization records its scope, expiry, and the actions let through, so it can be rolled back and audited after the fact."
"Once a human confirmation is hung out and the process crashes, then what?"
"A pending confirmation is a persistence problem, not a memory problem," Lao Z said. "If the pending state lives only in memory, the moment the process restarts that call waiting for someone to press the button vanishes into thin air — the human confirms, and the system doesn't know. Either persist the pending state to disk and restore it after a restart to keep waiting, or set the confirmation timeout to a short window and just deny on timeout after a restart."
"Are confirmation timeout and policy timeout the same thing?"
"No. Policy timeout guards against a downstream dependency being unavailable; confirmation timeout guards against the human not responding. A confirmation request hung out that no one presses leaves the call dangling, so set an explicit waiting limit; on timeout, deny, or let the user cancel. Cancellation has to propagate: when the user cancels, both the waiting confirmation and any already-started action receive the signal. Pending confirmations also stack up — if the user fires off several at once, you must be able to cancel them in a batch. Spell none of this out and the confirmation point is a hidden wait that can stall the main path."
"Can you draw the whole decision chain?" Xiao A asked.
Lao Z drew the decision chain:
model submits raw text
↓
normalize: parse, canonicalize, resolve symlinks
↓
decide: allow / deny (structured reason) / need human / rewrite
↓
record: event ID, policy version, input digest, result, duration"In this diagram, the decision always happens after normalization," Lao Z stressed. "Flip the order and the rules act on the raw text the model submitted — that's how a bypass like ./subdir/../../etc/ succeeds."
Cost and observability
"So what's the cost of a hook?" Xiao A asked.
"Hooks add implicit control flow. When you troubleshoot one tool failure, beyond the tool's implementation you also have to find which policy changed the input, denied the request, or timed out," Lao Z said. "Record for each decision the event ID, policy version, input digest, result, and duration; log no secrets or full sensitive content."
"With a hook guarding the door, does the tool still need to validate?" Xiao A guessed.
"Backwards — a hook must not be the only boundary," Lao Z said. "A rule that must be enforced still needs the tool itself to validate its input and run under least privilege; a rule you only want to observe must have a failure policy decoupled from the main path, with it stated clearly whether losing an event is acceptable."
"When exactly does a hook get skipped or bypassed?"
"Three main cases," Lao Z said. "One, the call never passes through the mount point — say an internal direct tool call that bypasses the public entry. Two, a misconfiguration or version mismatch, so the hook isn't registered on that path. Three, the hook itself throws and the framework silently swallows it — it looks like it's running when it isn't actually doing anything."
"Then what's the point of hooks existing at all?" Xiao A asked.
"They hold responsibility at different layers," Lao Z said. "A tool's self-check is the floor: it runs under least privilege and refuses out-of-bounds input, so even with the hook absent the rule still holds. A hook is a centralized policy point: it converges cross-tool policies like approval, quota, rewriting into one place, easy to audit and to change uniformly. The floor defends against hook failure; the centralized point defends against every tool writing its own rules and drifting out of sync sooner or later. The two stack; they don't replace each other."
"Those three failure cases — can they be laid out as a table for troubleshooting?"
| Failure case | Typical symptom | Where to start looking |
|---|---|---|
| call never reaches the mount point | internal path calls the tool directly, bypassing the public entry | compare the public entry against internal call paths |
| unregistered or version mismatch | hook not attached to the target path | check the registration config and the deployed version |
| exception silently swallowed | looks effective, actually isn't | reconcile the hook's run count against the decision records |
"How do you catch these the moment they happen?"
"By counting and comparing," Lao Z said. "For each mount point record 'how many times did the event fire, how many times did the hook participate in a decision' — the two disagreeing is the failure signal. Stamp each decision with a version number; records still showing the old version after an upgrade are residue that was never replaced. Failure is often not a one-off bug but path drift: the code changed, the config changed, the mount relationship didn't keep up, and the rule quietly unhooks."
"Log all that — doesn't it slow the main path instead?"
"It can, so the recording itself must be layered," Lao Z said. "The decision's key fields — event ID, policy version, result — write synchronously; you must have them when troubleshooting. Duration and input digest can be written asynchronously or sampled; losing them isn't fatal. But async means an event may be lost before it hits disk, so the loss tolerance goes into the policy."
"If the rule itself is wrong versus the hook not firing — how do you tell them apart?"
"Look at the decision records," Lao Z said. "A record shows 'the policy allowed per the rule, but the result went wrong' — that's a rule written incorrectly: a missing validation condition, a missed normalization, insufficient coverage. A record shows the policy should have fired but didn't — that's a mount failure. The two point at different fixes: change the policy for the first, repair the mount for the second. Without decision records, these two kinds of problem just look like the same 'something went wrong.'"
"Do hooks themselves need tests?"
"Yes, and you test 'invariants,' not 'implementation,'" Lao Z said. "Three invariants worth nailing down: a path outside the workspace is always denied; a log outage never blocks the main path; the same event entering the same hook twice never executes twice. When testing, replace downstream dependencies — the approval service, the logging library — with controllable stand-ins, inject slow responses, throws, and disconnects, and verify the invariants still hold. A hook is tested for whether the contract holds, not just whether the code was written correctly."
Reentrancy and error propagation
"Calling a tool from inside a hook — does that cause trouble?" Xiao A asked.
"It does. If an interceptor rewrites the request and then fires the same event again, that forms reentrancy; the implementation should pass an event ID or a depth marker, and specify whether a given policy may run only once," Lao Z said. "To illustrate: when beforeWrite calls the write tool to make a backup, that backup write should not trigger the same approval chain again. Policy errors also need classification: a mandatory policy failing defaults to deny, a pure observer failing is recorded and kept separate from the main path — both choices need a timeout ceiling."
"Beyond recursion, does reentrancy have other shapes?" Xiao A pressed.
"There's indirect reentrancy across hooks," Lao Z said. "Interceptor A rewrites the request, which fires interceptor B, and B calls some tool, and that tool passes through A again — the chain loops all the way back to A. This reentrancy is more insidious than direct recursion, because no single hook looks wrong on its own, so defense can't rely on each being careful; it needs a mechanism like an event ID or a depth marker."
"Mandatory policy failing defaults to deny — doesn't that freeze the system?"
"It can, and that's exactly why it must come with a timeout," Lao Z said. "Default-deny guards against the safety risk of 'allow when uncertain,' but once a downstream dependency is unavailable — say the approval service is down — every write fails. So give mandatory policies a timeout; whether the timeout degrades to deny or to allow depends on this rule's security weight: a security-critical policy denies on timeout, a business policy may allow."
"Can you draw that indirect loop so I can see it?" Xiao A asked.
Lao Z drew the loop-back chain:
interceptor A rewrites request
↓
interceptor B runs → calls tool C
↓
C's call re-enters A ──→ loops back to original chain
↑ │
└── event ID detects the same marker ──┘"Without an event ID, this loop spins forever; with one, A sees the same marker and just allows or denies outright," Lao Z said. "A depth marker works the same way: set how many levels of nesting one event may reach, break when exceeded — that's more realistic than trying to logically rule out every possible cycle."
"For the classification of failure policies, is there one unified reference table?"
Lao Z laid out a table:
| Policy type | Default on failure | Matching timeout | Main risk |
|---|---|---|---|
| mandatory policy | deny | deny on timeout | total stall when a dependency is unavailable |
| business policy | degrade per security weight | may allow on timeout | letting through what should have been intercepted |
| pure observer | decoupled from main path | never blocks the main path | lost events, unrecoverable |
"Does the degradation decision itself get recorded?" Xiao A asked.
"Yes, and in more detail than a normal decision," Lao Z said. "Degradation is 'temporarily changing the rule while a dependency is unavailable,' and this kind of temporary state is the easiest to forget. The action allowed once, the action denied once — keep the reason and the time for both. A mode switch (allow today, deny tomorrow) also needs who and when it was switched back. Otherwise you troubleshoot seeing the same class of request sometimes allowed and sometimes denied, with no idea where the boundary sits. Degradation is a time-limited policy change and deserves the same versioning and records as the policy itself."
Making the contract hold through failures
"How are audit and approval handled during a failure?" Xiao A asked.
"A synchronous hook on the same event folds its result straight into the main path — that suits approval and argument rewriting; an asynchronous subscription suits logging, metrics, and notifications, but you must state whether to wait for completion," Lao Z said. "If 'record the audit' failing blocks writing a file, the audit service becomes an accidental single point of availability; if approval is an asynchronous subscription, the action has already happened. Spell the default failure policy out item by item — don't wave it through with a blanket 'fail open' or 'fail closed.'"
"Can you run one full scenario?"
"End-to-end scenario: the model asks to upload a build artifact to an external target. The path check first normalizes the path and judges the workspace boundary; the outbound approval then shows the actual URL and a file digest; the audit subscription records the decision," Lao Z said. "Happy path: checks pass, confirmation given, upload runs. Failure path one: approval times out — return a denial and send no request. Two: audit write fails — alarm per the established policy but don't retry the upload. Three: the user cancels — propagate an AbortSignal and stop waiting."
"Can you draw this scenario as one chain?"
Lao Z drew a chain on the board:
model requests uploading a build artifact
↓
path check: normalize + workspace boundary ── out of bounds → deny and return reason
↓
outbound approval: show actual URL and file digest ── timeout / deny → send no request
↓
run the upload
↓
audit subscription: dedup-record by idempotency key ── write fails → alarm, no upload retry"Note that each failure path sits at a different point on the chain," Lao Z said. "Path check and approval are synchronous interception — the failure happens before the action, so the model gets the reason and can change course. The audit is an asynchronous subscription — the failure happens after the action, so you can only alarm and remediate. Where the failure lands decides what remediation is possible: want to stop it before the action, put an interceptor there; want only a paper trail after the fact, put a subscription there. In this scenario the same action goes down both channels — the front interceptor owns safety, the back recorder owns observability."
"How do you set the rules for ordering and reentrancy?" Xiao A asked.
"The rules for ordering, short-circuiting, and reentrancy are part of the API too," Lao Z said. "Reuse the layering for priority: security policy first, then business policy, then observers; once interception denies it short-circuits, and a subscription must not secretly restore it. If a hook fires the same event from inside itself, it likewise needs an event ID or a depth limit to avoid recursive approval. For actions that may retry, audit and notification use an idempotency key, so one failed-and-retried upload doesn't produce several 'uploaded' records."
"How exactly does an idempotency key prevent duplicate records?" Xiao A pressed.
"Each action carries a unique key, and the audit subscription dedups by it," Lao Z said. "When an action fails and retries, the retry and the original call share the same key; when writing the audit, first check whether the key is already recorded — if so, update the status rather than append a new row. Without an idempotency key, one failed retry gets logged as two 'uploaded,' and the audit log stops matching the real world. The idempotency key should be generated where the action is launched, not inside a hook — otherwise different hooks may see different keys."
"How does a cancellation signal travel across several hooks?" Xiao A asked next.
"Use an explicit cancellation channel like AbortSignal, passed down along the call chain," Lao Z said. "When the user cancels, the signal is set; every hook and tool implementation should listen on it and, on receipt, stop waiting and release resources. One point to write into the contract: cancellation is cooperative, not forced. It asks the hook to stop, but whether the hook can respond immediately depends on whether it's currently sitting on an interruptible wait — a hook stuck in a non-interruptible operation will still drag on until it completes or times out."
"Returning a denial reason to the model — does that leak internal policy?" Xiao A asked.
"Two layers," Lao Z said. "What the model sees is a structured 'why not': which boundary was crossed, what condition must be met. What the model doesn't see is internal implementation detail: where the policy file lives, the account structure of the permission system, the approver's contact. The reason is part of the conversation — the model needs it to adjust its next step; the policy implementation is the system's internals — the model doesn't need it. The return value should guide the model to correct itself without exposing the implementation surface. The usual way is to give a reason template rather than stitch raw internal information."
"Given the reason, can the model always correct into compliance?"
"Not necessarily," Lao Z said. "It may not understand the reason, or it may understand the rule yet be unable to satisfy it — say, insufficient permissions. So auto-retry needs a count ceiling; reach it and stop and escalate to a human. Don't assume that handing it a reason means it will fix itself."
Summary
A hook is a controlled point of intervention laid on an agent's lifecycle events, letting external code put in a word at critical moments. This "word" comes in two kinds: an interceptor can change or stop an action about to happen, say blocking a dangerous call before a tool runs; a subscriber only observes events that have already happened, say writing a log or refreshing the UI. Their boundary must be clear: an interceptor error can fail the whole turn, a subscriber error at worst loses one log line. A hook's reliability rests on the engineering of four things: execution order among hooks, each hook's timeout handling, how the cancellation signal propagates, and whether key actions have audit records. Miss any one and the hook degrades from a "controlled intervention" into an "unpredictable interference."
Behind those four things is one principle: write order and failure into the contract instead of leaving them to the runtime's luck. For order, security policy runs first, business policy next, observers last, and within a layer explicit priorities or short-circuit rules keep it reproducible; a behavior that changes when you change the startup order is the classic signal that order was never put in writing. For failure policy, a mandatory policy denies by default but must carry a timeout, a business policy degrades per its security weight, and an observer decouples from the main path with it stated whether event loss is acceptable — spelled out item by item rather than blanket-"fail open" or "fail closed." The layering of interception over subscription, plus the split between synchronous and asynchronous channels, decides what belongs on the "stop it before the action" path and what belongs on the "leave a trail after the action" path for the same action.
The trap a hook most easily steps on is reentrancy: a hook fires the same class of event while it's running, recursing forever. That demands the hook's implementation defend explicitly and never assume "I won't trigger myself." Beyond direct recursion there are indirect cross-hook loops in which every hook looks fine alone and only the chain forms a deadlock, so defense must lean on a mechanism like an event ID or a depth marker rather than careful coding. On top of that, interceptors carry two inherent risks: silent rewriting buries an inconsistency in the main path, and a model that keeps switching wording on a denial reason forms a denial storm. A rewrite either echoes back to the model or should just be a denial; denial reasons must be structured so the model understands them and can't route around them.
Remember too that a hook is a soft extension point, not a hard security layer: it can intercept, but interception isn't mandatory — with no hook installed, things pass through by default; it can observe, but observation isn't control. The real security constraints still fall on tools, permissions, and the execution environment. A hook's failure is also often not a one-off bug but path drift: the call doesn't reach the mount point, the config never registered it, the exception gets swallowed — in all three the rule quietly unhooks. Catch it with signals like "event count matches decision count" and "no stale versions linger after an upgrade," not by waiting for an incident to expose it. Design duties, order, failure policy, and observability clearly, and the hook becomes that "verifiable point of intervention"; design them poorly and it's just one more layer of implicit control flow nobody knows when it will misbehave.
Hands-on Verification
- For a write-file action, list the events, inputs, allow/deny outcomes, and audit fields.
- Give two policies an order and a timeout: the path-boundary check must precede human confirmation; a logging failure must neither allow a write through nor block it.
- Make the tool implementation itself refuse paths outside the workspace, then verify the refusal still holds even when the hook is skipped.
