Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion examples/hooks/bash_command_validator_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
It validates bash commands against a set of rules before execution.
In this case it changes grep calls to using rg.

It also demonstrates how to read the new agent context fields
(agent_id, agent_type) added natively in Claude Code
to tailor messages for main agent vs subagent callers.

Read more about hooks here: https://docs.anthropic.com/en/docs/claude-code/hooks

Make sure to change your path to your actual script.
Expand All @@ -26,6 +30,10 @@
}
}

All PreToolUse hook payloads include these agent context fields:
- agent_id (str) Unique agent identifier; omitted or empty for main agent
- agent_type (str) Subagent name/type; empty string for main agent

"""

import json
Expand Down Expand Up @@ -71,10 +79,22 @@ def main():
if not command:
sys.exit(0)

# ── Agent context fields (available in all hook events) ────────────────
# agent_id – Unique identifier for subagent; omitted/empty for main agent
# agent_type – Subagent name; empty string for the main agent
agent_id = input_data.get("agent_id")
agent_type = input_data.get("agent_type", "")
is_subagent = bool(agent_id)

issues = _validate_command(command)
if issues:
for message in issues:
print(f"• {message}", file=sys.stderr)
# Tailor the message prefix based on agent context
if is_subagent:
prefix = f"[Subagent '{agent_type or 'unknown'}']"
else:
prefix = "[Main agent]"
print(f"{prefix} • {message}", file=sys.stderr)
# Exit code 2 blocks tool call and shows stderr to Claude
sys.exit(2)

Expand Down
21 changes: 20 additions & 1 deletion plugins/plugin-dev/skills/hook-development/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,14 @@ Execute before any tool runs. Use to approve, deny, or modify tool calls.
}
```

**Agent-aware denial messages:**

Use `agent_id` to tailor the `systemMessage` based on context. For example, when denying a Bash call and redirecting to an MCP resource:
- **Main agent** (no `agent_id`) can invoke `ReadMcpResourceTool` directly.
- **Subagent** (has `agent_id`) must use a `resource-read` wrapper tool instead.

This avoids duplicating both instructions in every denial.

### PostToolUse

Execute after tool completes. Use to react to results, provide feedback, or log.
Expand Down Expand Up @@ -307,10 +315,21 @@ All hooks receive JSON via stdin with common fields:
"transcript_path": "/path/to/transcript.txt",
"cwd": "/current/working/dir",
"permission_mode": "ask|allow",
"hook_event_name": "PreToolUse"
"hook_event_name": "PreToolUse",
"agent_id": null,
"agent_type": null
}
```

**Agent context fields:**

| Field | Type | Description |
|---|---|---|
| `agent_id` | string | Unique identifier for the subagent; omitted or empty for the main agent |
| `agent_type` | string | The type/name of the agent (e.g., `git-expert`, `code-reviewer`) |

Use these fields to implement agent-specific policies, security controls, and targeted messages. See `references/advanced.md` for patterns.

**Event-specific fields:**

- **PreToolUse/PostToolUse:** `tool_name`, `tool_input`, `tool_result`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/bin/bash
# agent-aware-bash-validator.sh
# =============================================================================
# Example PreToolUse hook: Agent-Aware Bash Validator
# =============================================================================
# Demonstrates how to use the `agent_id` and `agent_type`
# fields to write targeted denial messages.
#
# Problem this solves (Issue #36270 / #6885):
# When a hook denies a Bash call and redirects to an MCP resource, the
# instructions differ between the main agent and a subagent:
# - Main agent → call ReadMcpResourceTool directly
# - Subagent → use the resource-read wrapper tool instead
# Without is_subagent, hooks had to include both instructions, which is noisy.
#
# Hook configuration in hooks/hooks.json or .claude/settings.json:
# {
# "PreToolUse": [
# {
# "matcher": "Bash",
# "hooks": [
# {
# "type": "command",
# "command": "bash /path/to/agent-aware-bash-validator.sh"
# }
# ]
# }
# ]
# }
# =============================================================================

set -euo pipefail

# Read hook input from stdin
input=$(cat)

# ── Tool filter ──────────────────────────────────────────────────────────────
tool_name=$(echo "$input" | jq -r '.tool_name // ""')
if [ "$tool_name" != "Bash" ]; then
exit 0
fi

# ── Extract agent context fields ─────────────────────────────────────────────
agent_id=$(echo "$input" | jq -r '.agent_id // ""')
agent_type=$(echo "$input" | jq -r '.agent_type // ""')

# ── Extract command ───────────────────────────────────────────────────────────
command=$(echo "$input" | jq -r '.tool_input.command // ""')

# ── Policy: block access to internal-policy.sh ───────────────────────────────
if [[ "$command" == *"internal-policy.sh"* ]]; then

if [ -n "$agent_id" ]; then
# Subagents cannot call ReadMcpResourceTool — use the wrapper instead
context_info="[Subagent: '${agent_type:-unknown}']"
echo "${context_info} Access denied: 'internal-policy.sh' is restricted." >&2
echo "Use the resource-read wrapper tool:" >&2
echo " resource-read(uri='policy://internal-policy')" >&2
else
# Main agent has direct MCP access
echo "Access denied: 'internal-policy.sh' is restricted." >&2
echo "Read the policy directly via:" >&2
echo " ReadMcpResourceTool(uri='policy://internal-policy')" >&2
fi

# Exit code 2 → blocks the tool call and feeds stderr back to Claude
exit 2
fi

# ── Policy: main agent must not run git push directly ────────────────────────
if [ -z "$agent_id" ] && echo "$command" | grep -q "^git push"; then
echo "Direct 'git push' is not permitted from the main agent." >&2
echo "Delegate to the @git-expert subagent to perform git operations." >&2
exit 2
fi

# Allow all other commands
exit 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
Claude Code Hook: Agent-Aware Git Command Validator
====================================================
Demonstrates using the `agent_id` and `agent_type`
fields (added natively in Claude Code) to enforce a security
policy: only the designated @git-expert subagent may run git commands.

Hook configuration (.claude/settings.json):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 /path/to/validate-git-agent.py"
}
]
}
]
}
}

Backward compatibility note:
If `agent_type` is absent from the payload (older Claude Code builds),
the hook falls back to inspecting the session transcript, using the
workaround documented in Issue #6885 comment by @coygeek.
"""

import json
import sys
from pathlib import Path

# The only agent authorized to run git commands
AUTHORIZED_GIT_AGENT = "git-expert"


# ─── Transcript fallback (for older Claude Code versions) ────────────────────

def find_agent_in_transcript(transcript_path: str) -> str | None:
"""
Fallback: parse the JSONL transcript to infer the most recently
invoked subagent when native `agent_type` is unavailable.

Returns the agent name string, or None if no Task tool call is found.
"""
try:
lines = Path(transcript_path).read_text(encoding="utf-8").splitlines()
except OSError:
return None

for line in reversed(lines):
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue

if msg.get("type") != "assistant":
continue

content = msg.get("message", {}).get("content", [])
if not isinstance(content, list):
continue

for item in content:
if item.get("type") == "tool_use" and item.get("name") == "Task":
description = item.get("input", {}).get("description", "")
if f"@{AUTHORIZED_GIT_AGENT}" in description:
return AUTHORIZED_GIT_AGENT
# Found a Task call for a different agent
return "other-agent"

return None # No agent context detected


# ─── Main ─────────────────────────────────────────────────────────────────────

def main() -> None:
try:
data = json.load(sys.stdin)
except json.JSONDecodeError as exc:
print(f"Hook error: invalid JSON input — {exc}", file=sys.stderr)
sys.exit(1)

# Only validate Bash tool calls
if data.get("tool_name") != "Bash":
sys.exit(0)

command: str = data.get("tool_input", {}).get("command", "")
if not command.startswith("git"):
sys.exit(0) # Not a git command — not our concern

# ── Read agent context fields ──────────────────────────────────────────
agent_id: str | None = data.get("agent_id")
agent_type: str = data.get("agent_type", "")
is_subagent: bool = bool(agent_id)
transcript_path: str = data.get("transcript_path", "")

# ── Resolve agent name (with transcript fallback) ─────────────────────
resolved_name = agent_type
used_fallback = False

if is_subagent and not agent_type and transcript_path:
# Older Claude Code: agent_type not yet in payload — use transcript
resolved_name = find_agent_in_transcript(transcript_path) or "unknown"
used_fallback = True

# ── Policy evaluation ─────────────────────────────────────────────────
if not is_subagent:
# Main agent must not run git commands directly
print(
"Security policy: the main agent must not run git commands directly.\n"
f"Delegate '{command}' to the @{AUTHORIZED_GIT_AGENT} subagent.",
file=sys.stderr,
)
sys.exit(2)

if resolved_name != AUTHORIZED_GIT_AGENT:
context = f"agent='{resolved_name}'"
if used_fallback:
context += " (inferred from transcript)"

print(
f"Security policy violation [{context}]:\n"
f"Only the @{AUTHORIZED_GIT_AGENT} subagent may run git commands.\n"
f"Attempted command: {command}",
file=sys.stderr,
)
sys.exit(2)

# Authorized — allow the git command
sys.exit(0)


if __name__ == "__main__":
main()
Loading