From 7d6be4a46c751dd6949738c29ac57072722eb613 Mon Sep 17 00:00:00 2001 From: Chetan Raghuvanshi <145042127+chhhee10@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:28:09 +0530 Subject: [PATCH] docs: document hook deny contract, matcher regex, tool_input keys, and provider config (#10467) Co-authored-by: Claude Opus 4.8 --- .../docs/getting-started/providers.md | 8 ++ documentation/docs/guides/config-files.md | 4 + .../docs/guides/context-engineering/hooks.md | 81 ++++++++++++++++++- 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 187ca8630..ddf72988b 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -283,12 +283,20 @@ The built-in OpenAI provider can connect to OpenAI's official API (`api.openai.c Need to connect to multiple OpenAI-compatible endpoints? [Configure custom providers](#configure-custom-provider) instead for easier switching and better organization, as well as custom naming and shareable configurations. ::: +:::note Pointing at a LiteLLM proxy +You can reach a [LiteLLM](https://docs.litellm.ai/) proxy in either of two ways—pick one, don't mix them: + +- Use the **OpenAI provider**: set `OPENAI_HOST` to your proxy's root (no trailing path) and `OPENAI_BASE_PATH` to the path it serves (usually `v1/chat/completions`). A `404` usually means `OPENAI_BASE_PATH` is wrong for your proxy. A `401` with `No api key passed in` is a different problem—the API key is not being loaded (for example, a key placed in `config.yaml`, which is ignored); see [Provider API keys and `config.yaml`](/docs/guides/config-files#security-considerations). +- Use the dedicated **LiteLLM provider**, which is configured with its own `LITELLM_HOST`, `LITELLM_BASE_PATH`, and `LITELLM_API_KEY` variables instead of the `OPENAI_*` ones. +::: + #### Configuration Parameters | Parameter | Required | Description | |-----------|----------|-------------| | `OPENAI_API_KEY` | Yes | Authentication key for the API | | `OPENAI_HOST` | No | Custom endpoint URL (defaults to api.openai.com) | +| `OPENAI_BASE_PATH` | No | Request path appended to the host (defaults to `v1/chat/completions`). Set this when your endpoint serves the chat completions API at a different path—most proxies expect `v1/chat/completions`, but some are mounted at `chat/completions` (no `v1`). | | `OPENAI_ORGANIZATION` | No | Organization ID for usage tracking and governance | | `OPENAI_PROJECT` | No | Project identifier for resource management | | `OPENAI_CUSTOM_HEADERS` | No | Additional headers to include in the request. Can be set via environment variable, configuration file, or CLI, in the format `HEADER_A=VALUE_A,HEADER_B=VALUE_B`. | diff --git a/documentation/docs/guides/config-files.md b/documentation/docs/guides/config-files.md index 89f004266..3ffa968d6 100644 --- a/documentation/docs/guides/config-files.md +++ b/documentation/docs/guides/config-files.md @@ -182,6 +182,10 @@ Settings are applied in the following order of precedence: ## Security Considerations +:::warning Provider API keys do not go in `config.yaml` +goose does not read provider API keys from `config.yaml`. A key placed there is ignored, which typically surfaces as an authentication failure such as `No api key passed in`. Store the key in the system keyring (via `goose configure`), or—when using file-based secret storage—in `secrets.yaml`. It can also be supplied through the provider's environment variable (for example `OPENAI_API_KEY`), which takes precedence over stored secrets. +::: + - Avoid storing sensitive information (API keys, tokens) in the config file - Use the system keyring (keychain on macOS) for storing secrets. When available, this is the recommended option. - If goose is using file-based secret storage, secrets are stored in a separate `secrets.yaml` file (in plain text). This can happen when: diff --git a/documentation/docs/guides/context-engineering/hooks.md b/documentation/docs/guides/context-engineering/hooks.md index e4670ee0c..f5653e268 100644 --- a/documentation/docs/guides/context-engineering/hooks.md +++ b/documentation/docs/guides/context-engineering/hooks.md @@ -119,7 +119,7 @@ Place the plugin under a discovered plugin location, such as `~/.agents/plugins/ | Field | Required | Description | |---|---:|---| -| `matcher` | No | Regular expression used to decide whether the rule runs for the event. If omitted, the rule runs for every event of that type. | +| `matcher` | No | Regular expression (not a glob) used to decide whether the rule runs for the event. If omitted, the rule runs for every event of that type. | | `hooks` | Yes | Actions to run when the event and matcher apply. | | `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`. | @@ -143,7 +143,11 @@ Use `${PLUGIN_ROOT}` in a command to reference the plugin directory. goose also | `BeforeShellExecution` | Before goose runs a shell command | Shell command | | `AfterShellExecution` | After goose successfully runs a shell command | Shell command | -The matcher is a regular expression matched against the most relevant string for the event. For example, use `"\\.rs$"` to match Rust files on `AfterFileEdit`, or `"^(cargo test|pnpm test)"` to match test commands on `AfterShellExecution`. +The matcher is a regular expression matched against the most relevant string for the event. For example, use `"\\.rs$"` to match Rust files on `AfterFileEdit`, or `"^(cargo test|pnpm test)"` to match test commands on `AfterShellExecution`. The match is unanchored, so `"developer__shell"` also matches `"developer__shell_foo"`; anchor with `^`/`$` when you need an exact match. + +:::warning Use `.*`, not `*`, to match everything +The matcher is a regular expression, not a glob. A bare `"*"` is an invalid regex, so the whole rule is **silently skipped** (goose logs a warning and moves on). To run a rule for every event, either omit `matcher` entirely or use `".*"`. +::: :::note `AfterFileEdit` and `AfterShellExecution` only run after successful tool calls. To react to failed edits, failed shell commands, or other failed tool calls, use `PostToolUseFailure`. @@ -211,6 +215,77 @@ tool="$(printf '%s' "$payload" | jq -r '.tool_name // "none"')" echo "goose hook: event=$event tool=$tool" >> "${PLUGIN_ROOT}/hook.log" ``` +### Tool Input Keys + +`tool_name` uses the tool's namespaced name (for example `developer__shell`), and `tool_input` holds that tool's own arguments. The keys are the tool's schema, so they vary by tool—a hook that inspects a file path must read the right field for the tool it matched. The keys for goose's built-in `developer` tools are: + +| `tool_name` | `tool_input` keys | +|---|---| +| `developer__shell` | `command`, `timeout_secs` (optional) | +| `developer__write` | `path`, `content` | +| `developer__edit` | `path`, `before`, `after` | +| `developer__tree` | `path`, `depth` | +| `developer__read_image` | `source`, `crop` (optional) | + +For the shell and file tools, `matcher_context` already carries the shell command (on `BeforeShellExecution`/`AfterShellExecution`) or the file path (on `BeforeReadFile`/`AfterFileEdit`), so those hooks can match without parsing `tool_input`. + +## Blocking a Tool Call + +Most events are observation-only: goose runs the hook, logs the result, and continues regardless of what the hook returns. Two events are different—**`PreToolUse` and `Stop` can block**. A hook on any other event (including `UserPromptSubmit`, `PostToolUse`, and the `Before*`/`After*` events) cannot stop anything; a block decision from those events is ignored. + +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`. + +When a `PreToolUse` hook blocks, goose does not run the tool and returns this message to the model: + +```text +Tool call denied by policy hook ``: . 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. + +### Block a Dangerous Command + +This `PreToolUse` hook blocks any shell command that uses `sudo`: + +```json title="hooks/hooks.json" +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "developer__shell", + "hooks": [ + { + "type": "command", + "command": "${PLUGIN_ROOT}/scripts/block-sudo.sh" + } + ] + } + ] + } +} +``` + +```bash title="scripts/block-sudo.sh" +#!/usr/bin/env bash +set -euo pipefail + +payload="$(cat)" +command="$(printf '%s' "$payload" | jq -r '.tool_input.command // empty')" + +if printf '%s' "$command" | grep -qE '(^|[[:space:]])sudo([[:space:]]|$)'; then + printf '{"decision":"block","reason":"sudo is not allowed in this session"}' +fi +``` + +The hook prints nothing when the command is allowed, so goose runs it normally. + ## Examples ### Notify When a Tool Fails @@ -354,7 +429,7 @@ 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. +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. Set a larger timeout for long-running hooks: