feat(hooks): add on_failure block for PreToolUse hooks (#11449)
Signed-off-by: Tymofii Pidlisnyi <171286556+aeoess@users.noreply.github.com> Co-authored-by: Tymofii Pidlisnyi <171286556+aeoess@users.noreply.github.com> Co-authored-by: Lifei Zhou <lifei@squareup.com>
This commit is contained in:
committed by
GitHub
parent
da45bd17b0
commit
ff5935fa12
@@ -691,9 +691,7 @@ impl Agent {
|
||||
.with_tool_call_id(tool_call_id)
|
||||
.with_working_dir(session.working_dir.to_string_lossy().to_string())
|
||||
.with_pre_tool_use_outcome(outcome);
|
||||
self.hook_manager
|
||||
.emit(crate::hooks::HookEvent::PreToolUseResult, ctx)
|
||||
.await;
|
||||
self.hook_manager.emit_pre_tool_use_result(ctx).await;
|
||||
}
|
||||
|
||||
fn with_post_tool_hook(
|
||||
@@ -1177,6 +1175,7 @@ impl Agent {
|
||||
gen_ai.tool.call.id = %request_id,
|
||||
gen_ai.tool.call.arguments = tracing::field::Empty,
|
||||
gen_ai.tool.call.result = tracing::field::Empty,
|
||||
error.type = tracing::field::Empty,
|
||||
)
|
||||
)]
|
||||
pub async fn dispatch_tool_call(
|
||||
@@ -1231,15 +1230,13 @@ impl Agent {
|
||||
)
|
||||
.await;
|
||||
|
||||
if let crate::hooks::HookDecision::Deny { reason, plugin } = pre_tool_outcome.decision {
|
||||
if let Some(denial) = pre_tool_outcome.denial() {
|
||||
tracing::Span::current().record("error.type", denial.error_type);
|
||||
return (
|
||||
request_id,
|
||||
Err(ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
format!(
|
||||
"Tool call denied by policy hook `{plugin}`: {reason}. \
|
||||
Do not retry; this is a policy denial, not a transient failure."
|
||||
),
|
||||
denial.message,
|
||||
None,
|
||||
)),
|
||||
);
|
||||
@@ -5728,6 +5725,14 @@ echo start >> "$PLUGIN_ROOT/hook.log"
|
||||
|
||||
impl RecordingHookEnv {
|
||||
fn new(specs: &[HookSpec<'_>]) -> Self {
|
||||
Self::with_on_failure(specs, "")
|
||||
}
|
||||
|
||||
fn blocking_on_failure(specs: &[HookSpec<'_>]) -> Self {
|
||||
Self::with_on_failure(specs, r#", "on_failure": "block""#)
|
||||
}
|
||||
|
||||
fn with_on_failure(specs: &[HookSpec<'_>], on_failure: &str) -> Self {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let plugin_dir = temp_dir.path().join("test-plugin");
|
||||
std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap();
|
||||
@@ -5740,7 +5745,7 @@ echo start >> "$PLUGIN_ROOT/hook.log"
|
||||
format!(r#""matcher": "{matcher}", "#)
|
||||
};
|
||||
format!(
|
||||
r#""{event}": [{{{matcher}"hooks": [{{"type": "command", "command": "sh ${{PLUGIN_ROOT}}/{script}"}}]}}]"#
|
||||
r#""{event}": [{{{matcher}"hooks": [{{"type": "command", "command": "sh ${{PLUGIN_ROOT}}/{script}"{on_failure}}}]}}]"#
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -5790,6 +5795,10 @@ echo start >> "$PLUGIN_ROOT/hook.log"
|
||||
/// non-zero. That is a hook that ran but never returned a decision.
|
||||
const ABNORMAL_EXIT_AND_RECORD_SCRIPT: &str =
|
||||
"#!/bin/sh\ncat >> \"$PLUGIN_ROOT/pre.log\"\nprintf '\\n' >> \"$PLUGIN_ROOT/pre.log\"\necho boom >&2\nexit 3\n";
|
||||
const HOOK_FAILURE_REFUSAL: &str =
|
||||
"Tool call blocked because policy hook `test-plugin` could not complete: \
|
||||
the hook exited with status 3 and no usable decision. \
|
||||
That hook is configured to block on failure.";
|
||||
|
||||
async fn agent_with_hooks(
|
||||
hook_manager: crate::hooks::HookManager,
|
||||
@@ -6049,4 +6058,56 @@ echo start >> "$PLUGIN_ROOT/hook.log"
|
||||
"PostToolUse must not fire for a tool that never ran",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_tool_use_hook_failure_allows_by_default() {
|
||||
let env = RecordingHookEnv::new(&[
|
||||
("PreToolUse", "", "pre.sh", ABNORMAL_EXIT_AND_RECORD_SCRIPT),
|
||||
("PreToolUseResult", "", "result.sh", RECORD_RESULT_SCRIPT),
|
||||
]);
|
||||
let (agent, session, _data_dir) = agent_with_hooks(env.hook_manager()).await;
|
||||
|
||||
let (_, result) = agent
|
||||
.dispatch_tool_call(shell_call(), "call-open-1".to_string(), None, &session)
|
||||
.await;
|
||||
assert!(result.is_ok(), "a broken hook must not block the call");
|
||||
|
||||
assert_eq!(env.payloads("pre.log").len(), 1);
|
||||
let results = env.payloads("result.log");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0]["decision"], "allow");
|
||||
assert_eq!(results[0]["cause"], "hook_failure");
|
||||
assert_eq!(results[0]["policy_evaluated"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_tool_use_hook_failure_blocks_when_configured() {
|
||||
let env = RecordingHookEnv::blocking_on_failure(&[
|
||||
("PreToolUse", "", "pre.sh", ABNORMAL_EXIT_AND_RECORD_SCRIPT),
|
||||
("PreToolUseResult", "", "result.sh", RECORD_RESULT_SCRIPT),
|
||||
("PostToolUse", "", "post.sh", RECORD_POST_SCRIPT),
|
||||
]);
|
||||
let (agent, session, _data_dir) = agent_with_hooks(env.hook_manager()).await;
|
||||
|
||||
let (_, result) = agent
|
||||
.dispatch_tool_call(shell_call(), "call-closed-1".to_string(), None, &session)
|
||||
.await;
|
||||
|
||||
let Err(error) = result else {
|
||||
panic!("a fail-closed hook failure must not dispatch");
|
||||
};
|
||||
assert_eq!(error.message, HOOK_FAILURE_REFUSAL);
|
||||
assert!(
|
||||
env.payloads("post.log").is_empty(),
|
||||
"PostToolUse must not fire for a blocked call"
|
||||
);
|
||||
|
||||
let results = env.payloads("result.log");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0]["decision"], "deny");
|
||||
assert_eq!(results[0]["cause"], "hook_failure");
|
||||
assert_eq!(results[0]["policy_evaluated"], false);
|
||||
assert_eq!(results[0]["blocked_by"], "test-plugin");
|
||||
assert_eq!(results[0]["tool_call_id"], "call-closed-1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::config::GooseMode;
|
||||
use crate::conversation::message::{ActionRequiredData, Message, MessageContent, ToolRequest};
|
||||
use crate::conversation::Conversation;
|
||||
use crate::hints::load_hints::SubdirectoryHintTracker;
|
||||
use crate::hooks::{HookChainOutcome, HookContext, HookDecision, HookEvent, HookManager};
|
||||
use crate::hooks::{HookChainOutcome, HookContext, HookEvent, HookManager};
|
||||
use crate::session::{EnabledExtensionsState, ExtensionState, Session};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
@@ -100,9 +100,7 @@ async fn emit_pre_tool_use_result(
|
||||
.with_tool_call_id(tool_call_id)
|
||||
.with_working_dir(session.working_dir.to_string_lossy().to_string())
|
||||
.with_pre_tool_use_outcome(outcome);
|
||||
hook_manager
|
||||
.emit(HookEvent::PreToolUseResult, context)
|
||||
.await;
|
||||
hook_manager.emit_pre_tool_use_result(context).await;
|
||||
}
|
||||
|
||||
/// Runs the `PreToolUse` chain, emits `PreToolUseResult`, and reports a denial
|
||||
@@ -145,14 +143,11 @@ pub(super) async fn run_pre_tool_hooks(
|
||||
)
|
||||
.await;
|
||||
|
||||
if let HookDecision::Deny { reason, plugin } = outcome.decision {
|
||||
tracing::Span::current().record("error.type", "hook_denied");
|
||||
if let Some(denial) = outcome.denial() {
|
||||
tracing::Span::current().record("error.type", denial.error_type);
|
||||
return Err(ErrorData::new(
|
||||
rmcp::model::ErrorCode::INTERNAL_ERROR,
|
||||
format!(
|
||||
"Tool call denied by policy hook `{plugin}`: {reason}. \
|
||||
Do not retry; this is a policy denial, not a transient failure."
|
||||
),
|
||||
denial.message,
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -338,6 +338,14 @@ type HookSpec<'a> = (&'a str, &'a str, &'a str, &'a str);
|
||||
|
||||
impl RecordingHookEnv {
|
||||
fn new(specs: &[HookSpec<'_>]) -> Self {
|
||||
Self::with_on_failure(specs, "")
|
||||
}
|
||||
|
||||
fn blocking_on_failure(specs: &[HookSpec<'_>]) -> Self {
|
||||
Self::with_on_failure(specs, r#", "on_failure": "block""#)
|
||||
}
|
||||
|
||||
fn with_on_failure(specs: &[HookSpec<'_>], on_failure: &str) -> Self {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let plugin_dir = temp_dir.path().join("test-plugin");
|
||||
std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap();
|
||||
@@ -350,7 +358,7 @@ impl RecordingHookEnv {
|
||||
format!(r#""matcher": "{matcher}", "#)
|
||||
};
|
||||
format!(
|
||||
r#""{event}": [{{{matcher}"hooks": [{{"type": "command", "command": "sh ${{PLUGIN_ROOT}}/{script}"}}]}}]"#
|
||||
r#""{event}": [{{{matcher}"hooks": [{{"type": "command", "command": "sh ${{PLUGIN_ROOT}}/{script}"{on_failure}}}]}}]"#
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -403,6 +411,10 @@ const DENY_AND_RECORD_SCRIPT: &str =
|
||||
/// non-zero. That is a hook that ran but never returned a decision.
|
||||
const ABNORMAL_EXIT_AND_RECORD_SCRIPT: &str =
|
||||
"#!/bin/sh\ncat >> \"$PLUGIN_ROOT/pre.log\"\nprintf '\\n' >> \"$PLUGIN_ROOT/pre.log\"\necho boom >&2\nexit 3\n";
|
||||
const HOOK_FAILURE_REFUSAL: &str =
|
||||
"Tool call blocked because policy hook `test-plugin` could not complete: \
|
||||
the hook exited with status 3 and no usable decision. \
|
||||
That hook is configured to block on failure.";
|
||||
|
||||
/// deny-invisible: the tool never dispatches, neither post event fires, and a
|
||||
/// PreToolUseResult subscriber still sees the denial with blocked_by and reason.
|
||||
@@ -1598,3 +1610,76 @@ async fn final_output_waits_for_ordinary_sibling_and_answers_every_request() ->
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_tool_use_hook_failure_allows_by_default() -> Result<()> {
|
||||
let env = RecordingHookEnv::new(&[
|
||||
("PreToolUse", "", "pre.sh", ABNORMAL_EXIT_AND_RECORD_SCRIPT),
|
||||
("PreToolUseResult", "", "result.sh", RECORD_RESULT_SCRIPT),
|
||||
("PostToolUse", "", "post.sh", RECORD_POST_SCRIPT),
|
||||
]);
|
||||
let (pipeline, api) = test_pipeline().await?;
|
||||
let pipeline = pipeline.with_hook_manager(env.hook_manager());
|
||||
api.on("add one").call(ADD, value(1));
|
||||
api.on("result: 1").reply("done");
|
||||
|
||||
pipeline.run(["add one"]).await?;
|
||||
|
||||
assert_eq!(
|
||||
pipeline.calculator_total(),
|
||||
1,
|
||||
"a broken hook must not block"
|
||||
);
|
||||
assert_eq!(env.payloads("post.log").len(), 1);
|
||||
let results = env.payloads("result.log");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0]["decision"], "allow");
|
||||
assert_eq!(results[0]["cause"], "hook_failure");
|
||||
assert_eq!(results[0]["policy_evaluated"], false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_tool_use_hook_failure_blocks_when_configured() -> Result<()> {
|
||||
let env = RecordingHookEnv::blocking_on_failure(&[
|
||||
("PreToolUse", "", "pre.sh", ABNORMAL_EXIT_AND_RECORD_SCRIPT),
|
||||
("PreToolUseResult", "", "result.sh", RECORD_RESULT_SCRIPT),
|
||||
("PostToolUse", "", "post.sh", RECORD_POST_SCRIPT),
|
||||
]);
|
||||
let (pipeline, api) = test_pipeline().await?;
|
||||
let pipeline = pipeline.with_hook_manager(env.hook_manager());
|
||||
api.on("add one").call(ADD, value(1));
|
||||
api.on("could not complete").reply("understood");
|
||||
|
||||
let result = pipeline.run(["add one"]).await?;
|
||||
|
||||
assert_eq!(pipeline.calculator_total(), 0, "tool must not dispatch");
|
||||
assert!(
|
||||
env.payloads("post.log").is_empty(),
|
||||
"PostToolUse must not fire for a blocked call"
|
||||
);
|
||||
|
||||
let results = env.payloads("result.log");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0]["decision"], "deny");
|
||||
assert_eq!(results[0]["cause"], "hook_failure");
|
||||
assert_eq!(results[0]["policy_evaluated"], false);
|
||||
assert_eq!(results[0]["blocked_by"], "test-plugin");
|
||||
|
||||
let tool_error = result
|
||||
.conversation()
|
||||
.messages()
|
||||
.iter()
|
||||
.flat_map(|message| &message.content)
|
||||
.find_map(|content| match content {
|
||||
MessageContent::ToolResponse(response) => response.tool_result.as_ref().err(),
|
||||
_ => None,
|
||||
})
|
||||
.expect("blocked tool response");
|
||||
assert!(
|
||||
tool_error.message.ends_with(HOOK_FAILURE_REFUSAL),
|
||||
"expected the shared refusal, got {}",
|
||||
tool_error.message
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+1128
-118
File diff suppressed because it is too large
Load Diff
@@ -124,6 +124,7 @@ Place the plugin under a discovered plugin location, such as `~/.agents/plugins/
|
||||
| `type` | No | Action type. goose currently supports `command`. If omitted, `command` is used. |
|
||||
| `command` | Yes for command hooks | Shell command to run. goose runs it with `sh -c`. |
|
||||
| `timeout` | No | Timeout in seconds for the command. Defaults to 30 seconds. |
|
||||
| `on_failure` | No | What to do when a `PreToolUse` hook fails: it cannot be executed, times out, does not receive its payload, produces output goose cannot decode, or exits without a decision goose recognizes. `allow` (the default) continues the tool call, `block` denies it. Interpreted only for selected `PreToolUse` command actions and ignored for every other event. See [Blocking a Tool Call](#blocking-a-tool-call). |
|
||||
|
||||
Use `${PLUGIN_ROOT}` in a command to reference the plugin directory. goose also sets `PLUGIN_ROOT` in the hook command's environment.
|
||||
|
||||
@@ -172,9 +173,10 @@ When a hook runs, goose writes a JSON payload to the command's stdin. Every payl
|
||||
| `working_dir` | Working directory of the session, on tool events. |
|
||||
| `tool_call_id` | Stable identifier for one tool call, on `PreToolUse`, `PreToolUseResult`, `PostToolUse`, and `PostToolUseFailure`. Correlates the events of a single call, which tool name plus input cannot do when the same call repeats. |
|
||||
| `decision` | `allow` or `deny`, on `PreToolUseResult`. There is no third value. |
|
||||
| `policy_evaluated` | `true` when at least one matching `PreToolUse` hook exited 0 or returned a decision (exit `2`, or `{"decision":"block"}` on stdout), on `PreToolUseResult`. A hook that exits non-zero without a decision, fails to spawn, or times out does not count, and neither does the absence of a matching hook. It is an at-least-one value: a hook that evaluated keeps it `true` even if a later hook fails. |
|
||||
| `policy_evaluated` | `true` when at least one matching `PreToolUse` hook exited 0 or returned an explicit decision (exit `2`, or `{"decision":"block"}` on stdout), on `PreToolUseResult`. A hook that exits non-zero without a decision, fails to spawn, times out, or whose payload could not be serialized does not count, and neither does the absence of a matching hook. It is an at-least-one value that stays `true` if a later hook fails. It reports that a hook ran to a conclusion, not that the conclusion was usable: read `cause` for that. |
|
||||
| `blocked_by` | Plugin whose hook denied the call, on `PreToolUseResult` when `decision` is `deny`. |
|
||||
| `reason` | Reason the denying hook gave, on `PreToolUseResult` when `decision` is `deny`. |
|
||||
| `reason` | Message for a denial, on `PreToolUseResult` when `decision` is `deny`. For an explicit policy denial, goose derives this from exit-2 stderr or the JSON `reason` field. goose does not bound or redact hook-supplied reasons, so hooks must not include secrets. For a hook failure under `on_failure: block`, goose supplies a bounded framework-generated message that does not include command strings, plugin paths, or payload content. |
|
||||
| `cause` | Why the chain ended as it did, on `PreToolUseResult`. `policy_denial` means an explicit policy decision blocked the call. `hook_failure` means at least one hook failed to execute, to receive its payload, to produce decodable output, or to answer the decision protocol, and that failure determined or qualified the reported result. Absent only for an allow with no hook failure. |
|
||||
|
||||
Example payload for a tool event:
|
||||
|
||||
@@ -246,7 +248,7 @@ A `PreToolUse` hook denies the tool call with either of two signals:
|
||||
- **Exit code `2`** — goose blocks and takes the reason from **stderr**.
|
||||
- **`{"decision":"block","reason":"..."}` on stdout** — goose blocks and takes the reason from the `reason` field. goose checks stdout whenever the exit code is not `2`, so this signal is honored regardless of whether the hook exits `0` or non-zero.
|
||||
|
||||
For the stdout signal, stdout must start with `{` and `decision` must be exactly `"block"`; any other value allows the call. If the `reason` is empty, goose substitutes `denied by plugin hook`.
|
||||
For the stdout signal, stdout must start with `{` and `decision` must be exactly `"block"`. If the `reason` is empty, goose substitutes `denied by plugin hook`.
|
||||
|
||||
When a `PreToolUse` hook blocks, goose does not run the tool and returns this message to the model:
|
||||
|
||||
@@ -254,10 +256,84 @@ When a `PreToolUse` hook blocks, goose does not run the tool and returns this me
|
||||
Tool call denied by policy hook `<plugin>`: <reason>. Do not retry; this is a policy denial, not a transient failure.
|
||||
```
|
||||
|
||||
**A broken hook fails open.** goose blocks only on one of the two deny signals above. If the hook produces neither—it prints nothing or non-`{` stdout and does not exit `2`, or it fails to run at all (a spawn error or a timeout)—the call is logged and allowed. Because the stdout signal is checked independently of the exit code, a hook that prints `{"decision":"block"}` and *then* exits non-zero still blocks; do not rely on a non-zero exit to cancel a block you have already printed.
|
||||
|
||||
A `Stop` hook that blocks forces the turn to keep going instead of ending. To prevent a misbehaving hook from looping forever, goose caps the number of consecutive `Stop` blocks; once the cap is hit, goose overrides the hook and ends the turn. Raise the cap with the `GOOSE_STOP_HOOK_BLOCK_CAP` environment variable.
|
||||
|
||||
### stdout Is the Decision Channel
|
||||
|
||||
For a blocking event, goose reads a decision out of the hook's exit status and stdout, in this order:
|
||||
|
||||
1. Exit `2` denies, with the reason from stderr.
|
||||
2. `{"decision":"block"}` on stdout denies, whatever the exit status.
|
||||
3. Exit `0` with empty stdout allows.
|
||||
4. Exit `0` with `{"decision":"allow"}` on stdout allows.
|
||||
5. Anything else means the hook returned no decision.
|
||||
|
||||
Rule 5 covers more than a crash. A stray log line on stdout, truncated or malformed JSON, a JSON array, `{}` with no `decision`, an unrecognized `decision` value, allow JSON printed alongside a non-zero exit, an unexpected exit status, and termination by a signal all read as no decision, as do a spawn error and a timeout. Write logs and diagnostics to stderr and keep stdout for the decision.
|
||||
|
||||
Two further cases read as no decision:
|
||||
|
||||
- **stdout is not valid UTF-8.** goose reads stdout strictly and does not repair invalid bytes, because repairing them can turn malformed output into JSON that parses as an allow. stderr is decoded leniently, since it is only ever shown to a human.
|
||||
- **goose could not deliver the request.** If the payload cannot be written to the hook's stdin, the hook decided without seeing the request, so an allow from it does not count. An explicit denial still does: goose reads the hook's output before deciding, and a block it managed to print is honored.
|
||||
|
||||
Because the block signal is read independently of the exit code, a hook that prints `{"decision":"block"}` and *then* exits non-zero still blocks. Do not rely on a non-zero exit to cancel a block you have already printed.
|
||||
|
||||
### Choose What Happens When a Hook Fails
|
||||
|
||||
A hook allows cleanly only when goose delivered the payload and the process exited 0 with either empty stdout or a valid allow decision. A failed delivery, output goose cannot use, or a non-zero exit without a denial is a hook failure, not an allow.
|
||||
|
||||
**By default a hook failure is logged and the tool call proceeds.** That is the historical behavior and it stays the default, so a broken hook never wedges a session.
|
||||
|
||||
For a policy you actually depend on, set `on_failure` to `block` on the action. goose then denies the tool call when that hook fails:
|
||||
|
||||
```json title="hooks/hooks.json"
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "developer__shell",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${PLUGIN_ROOT}/scripts/block-sudo.sh",
|
||||
"on_failure": "block"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A denial caused by a failure is worded differently from a policy denial, so the model and your logs can tell them apart:
|
||||
|
||||
```text
|
||||
Tool call blocked because policy hook `<plugin>` could not complete: <reason>. That hook is configured to block on failure.
|
||||
```
|
||||
|
||||
Three limits are worth knowing:
|
||||
|
||||
- `on_failure` applies to `PreToolUse` only. A `Stop` hook stays fail-open even with `on_failure: block`, so a broken hook can never keep a finished turn from ending.
|
||||
- `on_failure` never overrides an explicit denial. A hook that explicitly blocks is honored in either mode. It also changes nothing about a clean allow, meaning one where the payload was delivered and the process exited 0 with empty stdout or a valid allow decision.
|
||||
- On a selected `PreToolUse` command action, any value other than `allow` or `block` is a configuration error, and goose skips that plugin's whole `hooks.json` file with a warning rather than guessing a policy. Other events do not interpret the field. `on_failure` governs what happens when a hook runs and fails, not how configuration load errors are handled.
|
||||
|
||||
### What goose Validates in `hooks.json`
|
||||
|
||||
Unknown event names and unsupported string action types are ignored without their payloads being interpreted, so a plugin can carry configuration for a newer goose without breaking on this one. Recognized event schemas and selected runnable command actions are validated. A malformed selected configuration skips that plugin's `hooks.json` with a warning, and an invalid matcher regex warns and skips only that rule. `on_failure` governs what happens when a hook fails at runtime; it does not make configuration-loading or matcher errors fail closed.
|
||||
|
||||
The `PreToolUseResult` event reports the outcome through `cause`: `policy_denial` for an explicit block, `hook_failure` for an execution or protocol failure, and no `cause` for a clean allow.
|
||||
|
||||
### What `PreToolUseResult` Reports
|
||||
|
||||
`PreToolUseResult` carries the outcome of the `PreToolUse` chain. Its fields are defined once in [Hook Payload](#hook-payload); this section covers only what `on_failure` changes.
|
||||
|
||||
`decision` is the result actually applied to the tool call, so under `on_failure: block` a hook failure shows as `deny`. `cause` is how a subscriber tells the two kinds of denial apart: `policy_denial` when a hook explicitly blocked, `hook_failure` when a hook could not be executed, did not receive its payload, produced output goose could not decode, or answered nothing the decision protocol recognizes.
|
||||
|
||||
`policy_evaluated` answers a different question from `cause` and the two move independently. A hook that exits 0 while printing a stray log line has run to a conclusion, so `policy_evaluated` is `true`, but it produced no usable decision, so `cause` is `hook_failure`. A hook that fails to spawn never ran at all, so `policy_evaluated` stays `false`.
|
||||
|
||||
In a mixed chain both readings survive. If an earlier hook allows cleanly and a later one fails, the event reports `policy_evaluated: true` from the earlier hook and `cause: hook_failure` from the later one, whether the call ends up allowed or denied.
|
||||
|
||||
A hook that fails while `on_failure` is `allow` still emits `decision: "allow"` with `cause: "hook_failure"`, so a monitoring plugin can see that a hook was skipped even though the tool call proceeded.
|
||||
|
||||
### Block a Dangerous Command
|
||||
|
||||
This `PreToolUse` hook blocks any shell command that uses `sudo`:
|
||||
@@ -437,7 +513,14 @@ Check the following:
|
||||
|
||||
### My Hook Timed Out or Failed
|
||||
|
||||
Hook failures are logged but do not crash goose or the tool that triggered the hook. If a hook fails or exceeds its timeout, goose logs the failure and continues. This is the fail-open behavior described in [Blocking a Tool Call](#blocking-a-tool-call): to intentionally stop a tool call, a `PreToolUse` hook must emit a clean block signal, not just exit non-zero.
|
||||
Hook failures are logged but do not crash goose or the tool that triggered the hook. By default, a hook that fails to run, exceeds its timeout, never receives its payload, or exits without a decision goose recognizes is logged and the tool call proceeds. To intentionally stop a tool call, a `PreToolUse` hook must emit a clean block signal, not just exit non-zero.
|
||||
|
||||
Two cases look like a failure but are not what people expect:
|
||||
|
||||
- **The hook printed a log line to stdout.** goose reads stdout as the decision channel, so `echo "checking policy"` makes an otherwise healthy hook read as no decision. Send diagnostics to stderr instead.
|
||||
- **The hook printed `{"decision":"allow"}` and then exited non-zero.** The exit status contradicts the decision, so goose treats it as no decision. Exit `0` alongside an allow.
|
||||
|
||||
If you would rather a failing hook stop the tool call instead of being ignored, set `on_failure` to `block` on that action. See [Choose What Happens When a Hook Fails](#choose-what-happens-when-a-hook-fails).
|
||||
|
||||
Set a larger timeout for long-running hooks:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user