Lifecycle Hooks
Cosh lets you run custom shell commands before every tool call (PreToolUse) and after every successful tool call (PostToolUse). You can block dangerous operations, auto-approve safe ones, rewrite tool arguments, or inject extra context into tool results.
Hooks are configured in ~/.config/cosh/setup.json under the hooks section.
Configuration
Open ~/.config/cosh/setup.json and add a hooks block:
{
"hooks": {
"PreToolUse": [
{
"name": "Block dangerous bash",
"matcher": "bash_run",
"command": "if echo \"$COSH_TOOL_NAME\" | grep -q 'rm -rf'; then echo '{\"decision\": \"deny\", \"reason\": \"rm -rf is not allowed\"}'; exit 2; fi"
}
],
"PostToolUse": [
{
"name": "Log file writes",
"matcher": "fs_write",
"command": "echo '{\"context\": \"File write completed: \" $COSH_TOOL_NAME}'"
}
]
}
}
Hook fields
| Field | Required | Description |
|---|---|---|
name |
No | Friendly display name. Falls back to command if empty. |
matcher |
No | Regex pattern tested against the tool name. Empty = match all tools. |
command |
Yes | Shell command to execute. |
timeout |
No | Timeout in seconds (default: 30). |
PreToolUse hooks
PreToolUse hooks run before dispatching a tool call. They can block, allow, or rewrite the call.
How it works
- Before dispatching a tool call, Cosh finds all hooks whose
matchermatches the tool name. - Each matching hook runs via
sh -c "<command>"in parallel. - The tool name and input are available via:
- stdin: JSON payload
{"event":"PreToolUse","tool_name":"bash_run","tool_input":{...}} - env vars:
COSH_EVENT,COSH_TOOL_NAME
- stdin: JSON payload
- The hook’s exit code and stdout determine the outcome.
- Results from all hooks are aggregated (deny wins over allow, halt is sticky).
Exit codes
| Code | Meaning |
|---|---|
0 |
Parse stdout JSON for decision |
2 |
Deny this tool call (stderr = reason) |
49 |
Halt the entire turn (stderr = reason) |
| Other | Non-blocking error, treated as “no opinion” |
Stdout JSON (exit code 0)
{
"decision": "allow|deny|none",
"reason": "why this was blocked",
"context": "extra text appended to the tool result",
"updated_input": {"command": "modified command here"}
}
decision:allowauto-approves the permission prompt.denyblocks the tool call.none(or missing) expresses no opinion.reason: shown to the user when the tool is blocked.context: injected into the tool result after execution.updated_input: shallow-merges onto the tool’s input JSON before execution.
PostToolUse hooks
PostToolUse hooks run after a successful tool execution and before the result enters the conversation history. They are useful for logging, validation, or annotating tool output.
How it works
- After a tool call succeeds, Cosh finds all PostToolUse hooks whose
matchermatches the tool name. - Each matching hook runs via
sh -c "<command>"in parallel. - The tool name, input, and output are available via:
- stdin: JSON payload
{"event":"PostToolUse","tool_name":"bash_run","tool_input":{...},"tool_output":"..."} - env vars:
COSH_EVENT,COSH_TOOL_NAME
- stdin: JSON payload
- Results from all hooks are aggregated.
Key differences from PreToolUse
- The stdin payload includes a
tool_outputfield with the raw tool result. updated_inputis ignored — the tool already ran, so rewriting its input has no effect.denycannot un-run the tool. Instead, it appends a[hook] <reason>warning line to the output the model sees. This is useful for flagging suspicious output without blocking execution.contextis appended to the tool output the model sees.haltstops the turn immediately (same as PreToolUse).
Example: flag suspicious output
{
"hooks": {
"PostToolUse": [
{
"name": "Flag large file reads",
"matcher": "fs_read",
"command": "output_len=$(echo '{\"tool_output\":\"\"}' | wc -c); if [ \"$output_len\" -gt 10000 ]; then echo '{\"decision\": \"deny\", \"reason\": \"Large output detected — review before trusting\"}'; fi"
}
]
}
}
Examples
Block specific bash commands
{
"hooks": {
"PreToolUse": [
{
"name": "No destructive commands",
"matcher": "bash_run",
"command": "if echo \"$COSH_TOOL_NAME\" | grep -qE 'rm -rf|mkfs|dd if='; then exit 2; fi; echo '{\"decision\": \"allow\"}'"
}
]
}
}
Auto-approve file reads
{
"hooks": {
"PreToolUse": [
{
"name": "Allow reads",
"matcher": "^fs_read$",
"command": "echo '{\"decision\": \"allow\"}'"
}
]
}
}
Rewrite tool arguments
{
"hooks": {
"PreToolUse": [
{
"name": "Add --color=never to grep",
"matcher": "find_grep",
"command": "echo '{\"updated_input\": {\"command\": \"--color=never $COSH_TOOL_NAME\"}}'"
}
]
}
}
Halt the entire turn
{
"hooks": {
"PreToolUse": [
{
"name": "Emergency stop",
"matcher": ".*",
"command": "if [ -f /tmp/cosh-halt ]; then echo 'HALTED' >&2; exit 49; fi"
}
]
}
}
Priority order
PreToolUse
Hooks (allow/deny/halt) → Permission System → Tool Execution
- A hook
allowauto-approves the permission prompt (skips the dialog). - A hook
denyblocks before any permission check. - A hook
haltstops the entire turn immediately. - If no hooks match, the normal permission system applies.
PostToolUse
Tool Execution → Hooks (context/deny-as-warning/halt) → History
- A hook
contextappends text to the tool output before it enters history. - A hook
denyappends a[hook] <reason>warning line (the tool already ran). - A hook
haltstops the turn immediately. - If no hooks match, the tool output enters history unchanged.
Notes
- Hooks run synchronously — keep them fast.
- Invalid regex in
matcheris silently skipped (hook won’t run). - Duplicate commands are deduplicated (first wins).
- The
timeoutfield kills the hook process after N seconds if it hasn’t finished. - Both hook types can be toggled independently in the Settings screen.