Skip to content

Commit 1192ffc

Browse files
committed
Tool Guardian Hook
Add Tool Guardian hook for blocking dangerous tool operations Introduces a preToolUse hook that scans Copilot agent tool invocations against ~20 threat patterns (destructive file ops, force pushes, DB drops, permission abuse, network exfiltration) and blocks or warns before execution.
1 parent 3b462df commit 1192ffc

5 files changed

Lines changed: 404 additions & 0 deletions

File tree

docs/README.hooks.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-hooks) for guidelines on how to
3535
| [Secrets Scanner](../hooks/secrets-scanner/README.md) | Scans files modified during a Copilot coding agent session for leaked secrets, credentials, and sensitive data | sessionEnd | `hooks.json`<br />`scan-secrets.sh` |
3636
| [Session Auto-Commit](../hooks/session-auto-commit/README.md) | Automatically commits and pushes changes when a Copilot coding agent session ends | sessionEnd | `auto-commit.sh`<br />`hooks.json` |
3737
| [Session Logger](../hooks/session-logger/README.md) | Logs all Copilot coding agent session activity for audit and analysis | sessionStart, sessionEnd, userPromptSubmitted | `hooks.json`<br />`log-prompt.sh`<br />`log-session-end.sh`<br />`log-session-start.sh` |
38+
| [Tool Guardian](../hooks/tool-guardian/README.md) | Blocks dangerous tool operations (destructive file ops, force pushes, DB drops) before the Copilot coding agent executes them | preToolUse | `guard-tool.sh`<br />`hooks.json` |

hooks/tool-guardian/README.md

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
---
2+
name: 'Tool Guardian'
3+
description: 'Blocks dangerous tool operations (destructive file ops, force pushes, DB drops) before the Copilot coding agent executes them'
4+
tags: ['security', 'safety', 'preToolUse', 'guardrails']
5+
---
6+
7+
# Tool Guardian Hook
8+
9+
Blocks dangerous tool operations before a GitHub Copilot coding agent executes them, acting as a safety net against destructive commands, force pushes, database drops, and other high-risk actions.
10+
11+
## Overview
12+
13+
AI coding agents can autonomously execute shell commands, file operations, and database queries. Without guardrails, a misinterpreted instruction could lead to irreversible damage. This hook intercepts every tool invocation at the `preToolUse` event and scans it against ~20 threat patterns across 6 categories:
14+
15+
- **Destructive file ops**: `rm -rf /`, deleting `.env` or `.git`
16+
- **Destructive git ops**: `git push --force` to main/master, `git reset --hard`
17+
- **Database destruction**: `DROP TABLE`, `DROP DATABASE`, `TRUNCATE`, `DELETE FROM` without `WHERE`
18+
- **Permission abuse**: `chmod 777`, recursive world-writable permissions
19+
- **Network exfiltration**: `curl | bash`, `wget | sh`, uploading files via `curl --data @`
20+
- **System danger**: `sudo`, `npm publish`
21+
22+
## Features
23+
24+
- **Two guard modes**: `block` (exit non-zero to prevent execution) or `warn` (log only)
25+
- **Safer alternatives**: Every blocked pattern includes a suggestion for a safer command
26+
- **Allowlist support**: Skip specific patterns via `TOOL_GUARD_ALLOWLIST`
27+
- **Structured logging**: JSON Lines output for integration with monitoring tools
28+
- **Fast execution**: 10-second timeout; no external network calls
29+
- **Zero dependencies**: Uses only standard Unix tools (`grep`, `sed`); optional `jq` for input parsing
30+
31+
## Installation
32+
33+
1. Copy the hook folder to your repository:
34+
35+
```bash
36+
cp -r hooks/tool-guardian .github/hooks/
37+
```
38+
39+
2. Ensure the script is executable:
40+
41+
```bash
42+
chmod +x .github/hooks/tool-guardian/guard-tool.sh
43+
```
44+
45+
3. Create the logs directory and add it to `.gitignore`:
46+
47+
```bash
48+
mkdir -p logs/copilot/tool-guardian
49+
echo "logs/" >> .gitignore
50+
```
51+
52+
4. Commit the hook configuration to your repository's default branch.
53+
54+
## Configuration
55+
56+
The hook is configured in `hooks.json` to run on the `preToolUse` event:
57+
58+
```json
59+
{
60+
"version": 1,
61+
"hooks": {
62+
"preToolUse": [
63+
{
64+
"type": "command",
65+
"bash": ".github/hooks/tool-guardian/guard-tool.sh",
66+
"cwd": ".",
67+
"env": {
68+
"GUARD_MODE": "block"
69+
},
70+
"timeoutSec": 10
71+
}
72+
]
73+
}
74+
}
75+
```
76+
77+
### Environment Variables
78+
79+
| Variable | Values | Default | Description |
80+
|----------|--------|---------|-------------|
81+
| `GUARD_MODE` | `warn`, `block` | `block` | `warn` logs threats only; `block` exits non-zero to prevent tool execution |
82+
| `SKIP_TOOL_GUARD` | `true` | unset | Disable the guardian entirely |
83+
| `TOOL_GUARD_LOG_DIR` | path | `logs/copilot/tool-guardian` | Directory where guard logs are written |
84+
| `TOOL_GUARD_ALLOWLIST` | comma-separated | unset | Patterns to skip (e.g., `git push --force,npm publish`) |
85+
86+
## How It Works
87+
88+
1. Before the Copilot coding agent executes a tool, the hook receives the tool invocation as JSON on stdin
89+
2. Extracts `toolName` and `toolInput` fields (via `jq` if available, regex fallback otherwise)
90+
3. Checks the combined text against the allowlist — if matched, skips all scanning
91+
4. Scans combined text against ~20 regex threat patterns across 6 severity categories
92+
5. Reports findings with category, severity, matched text, and a safer alternative
93+
6. Writes a structured JSON log entry for audit purposes
94+
7. In `block` mode, exits non-zero to prevent the tool from executing
95+
8. In `warn` mode, logs the threat and allows execution to proceed
96+
97+
## Threat Categories
98+
99+
| Category | Severity | Key Patterns | Suggestion |
100+
|----------|----------|-------------|------------|
101+
| `destructive_file_ops` | critical | `rm -rf /`, `rm -rf ~`, `rm -rf .`, delete `.env`/`.git` | Use targeted paths or `mv` to back up |
102+
| `destructive_git_ops` | critical/high | `git push --force` to main/master, `git reset --hard`, `git clean -fd` | Use `--force-with-lease`, `git stash`, dry-run |
103+
| `database_destruction` | critical/high | `DROP TABLE`, `DROP DATABASE`, `TRUNCATE`, `DELETE FROM` without WHERE | Use migrations, backups, add WHERE clause |
104+
| `permission_abuse` | high | `chmod 777`, `chmod -R 777` | Use `755` for dirs, `644` for files |
105+
| `network_exfiltration` | critical/high | `curl \| bash`, `wget \| sh`, `curl --data @file` | Download first, review, then execute |
106+
| `system_danger` | high | `sudo`, `npm publish` | Use least privilege; `--dry-run` first |
107+
108+
## Examples
109+
110+
### Safe command (exit 0)
111+
112+
```bash
113+
echo '{"toolName":"bash","toolInput":"git status"}' | bash hooks/tool-guardian/guard-tool.sh
114+
```
115+
116+
### Blocked command (exit 1)
117+
118+
```bash
119+
echo '{"toolName":"bash","toolInput":"git push --force origin main"}' | \
120+
GUARD_MODE=block bash hooks/tool-guardian/guard-tool.sh
121+
```
122+
123+
```
124+
🛡️ Tool Guardian: 1 threat(s) detected in 'bash' invocation
125+
126+
CATEGORY SEVERITY MATCH SUGGESTION
127+
-------- -------- ----- ----------
128+
destructive_git_ops critical git push --force origin main Use 'git push --force-with-lease' or push to a feature branch
129+
130+
🚫 Operation blocked: resolve the threats above or adjust TOOL_GUARD_ALLOWLIST.
131+
Set GUARD_MODE=warn to log without blocking.
132+
```
133+
134+
### Warn mode (exit 0, threat logged)
135+
136+
```bash
137+
echo '{"toolName":"bash","toolInput":"rm -rf /"}' | \
138+
GUARD_MODE=warn bash hooks/tool-guardian/guard-tool.sh
139+
```
140+
141+
### Allowlisted command (exit 0)
142+
143+
```bash
144+
echo '{"toolName":"bash","toolInput":"git push --force origin main"}' | \
145+
TOOL_GUARD_ALLOWLIST="git push --force" bash hooks/tool-guardian/guard-tool.sh
146+
```
147+
148+
## Log Format
149+
150+
Guard events are written to `logs/copilot/tool-guardian/guard.log` in JSON Lines format:
151+
152+
```json
153+
{"timestamp":"2026-03-16T10:30:00Z","event":"threats_detected","mode":"block","tool":"bash","threat_count":1,"threats":[{"category":"destructive_git_ops","severity":"critical","match":"git push --force origin main","suggestion":"Use 'git push --force-with-lease' or push to a feature branch"}]}
154+
```
155+
156+
```json
157+
{"timestamp":"2026-03-16T10:30:00Z","event":"guard_passed","mode":"block","tool":"bash"}
158+
```
159+
160+
```json
161+
{"timestamp":"2026-03-16T10:30:00Z","event":"guard_skipped","reason":"allowlisted","tool":"bash"}
162+
```
163+
164+
## Customization
165+
166+
- **Add custom patterns**: Edit the `PATTERNS` array in `guard-tool.sh` to add project-specific threat patterns
167+
- **Adjust severity**: Change severity levels for patterns that need different treatment
168+
- **Allowlist known commands**: Use `TOOL_GUARD_ALLOWLIST` for commands that are safe in your context
169+
- **Change log location**: Set `TOOL_GUARD_LOG_DIR` to route logs to your preferred directory
170+
171+
## Disabling
172+
173+
To temporarily disable the guardian:
174+
175+
- Set `SKIP_TOOL_GUARD=true` in the hook environment
176+
- Or remove the `preToolUse` entry from `hooks.json`
177+
178+
## Limitations
179+
180+
- Pattern-based detection; does not perform semantic analysis of command intent
181+
- May produce false positives for commands that match patterns in safe contexts (use the allowlist to suppress these)
182+
- Scans the text representation of tool input; cannot detect obfuscated or encoded commands
183+
- Requires tool invocations to be passed as JSON on stdin with `toolName` and `toolInput` fields

hooks/tool-guardian/guard-tool.sh

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
#!/bin/bash
2+
3+
# Tool Guardian Hook
4+
# Blocks dangerous tool operations (destructive file ops, force pushes, DB drops,
5+
# etc.) before the Copilot coding agent executes them.
6+
#
7+
# Environment variables:
8+
# GUARD_MODE - "warn" (log only) or "block" (exit non-zero on threats) (default: block)
9+
# SKIP_TOOL_GUARD - "true" to disable entirely (default: unset)
10+
# TOOL_GUARD_LOG_DIR - Directory for guard logs (default: logs/copilot/tool-guardian)
11+
# TOOL_GUARD_ALLOWLIST - Comma-separated patterns to skip (default: unset)
12+
13+
set -euo pipefail
14+
15+
# ---------------------------------------------------------------------------
16+
# Early exit if disabled
17+
# ---------------------------------------------------------------------------
18+
if [[ "${SKIP_TOOL_GUARD:-}" == "true" ]]; then
19+
exit 0
20+
fi
21+
22+
# ---------------------------------------------------------------------------
23+
# Read tool invocation from stdin (JSON with toolName + toolInput)
24+
# ---------------------------------------------------------------------------
25+
INPUT=$(cat)
26+
27+
MODE="${GUARD_MODE:-block}"
28+
LOG_DIR="${TOOL_GUARD_LOG_DIR:-logs/copilot/tool-guardian}"
29+
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
30+
31+
mkdir -p "$LOG_DIR"
32+
LOG_FILE="$LOG_DIR/guard.log"
33+
34+
# ---------------------------------------------------------------------------
35+
# Extract tool name and input text
36+
# ---------------------------------------------------------------------------
37+
TOOL_NAME=""
38+
TOOL_INPUT=""
39+
40+
if command -v jq &>/dev/null; then
41+
TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.toolName // empty' 2>/dev/null || echo "")
42+
TOOL_INPUT=$(printf '%s' "$INPUT" | jq -r '.toolInput // empty' 2>/dev/null || echo "")
43+
fi
44+
45+
# Fallback: extract with grep/sed if jq unavailable or fields empty
46+
if [[ -z "$TOOL_NAME" ]]; then
47+
TOOL_NAME=$(printf '%s' "$INPUT" | grep -oE '"toolName"\s*:\s*"[^"]*"' | head -1 | sed 's/.*"toolName"\s*:\s*"//;s/"//')
48+
fi
49+
if [[ -z "$TOOL_INPUT" ]]; then
50+
TOOL_INPUT=$(printf '%s' "$INPUT" | grep -oE '"toolInput"\s*:\s*"[^"]*"' | head -1 | sed 's/.*"toolInput"\s*:\s*"//;s/"//')
51+
fi
52+
53+
# Combine for pattern matching
54+
COMBINED="${TOOL_NAME} ${TOOL_INPUT}"
55+
56+
# ---------------------------------------------------------------------------
57+
# Parse allowlist
58+
# ---------------------------------------------------------------------------
59+
ALLOWLIST=()
60+
if [[ -n "${TOOL_GUARD_ALLOWLIST:-}" ]]; then
61+
IFS=',' read -ra ALLOWLIST <<< "$TOOL_GUARD_ALLOWLIST"
62+
fi
63+
64+
is_allowlisted() {
65+
local text="$1"
66+
for pattern in "${ALLOWLIST[@]}"; do
67+
pattern=$(printf '%s' "$pattern" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
68+
[[ -z "$pattern" ]] && continue
69+
if [[ "$text" == *"$pattern"* ]]; then
70+
return 0
71+
fi
72+
done
73+
return 1
74+
}
75+
76+
# Check allowlist early — if the combined text matches, skip all scanning
77+
if [[ ${#ALLOWLIST[@]} -gt 0 ]] && is_allowlisted "$COMBINED"; then
78+
printf '{"timestamp":"%s","event":"guard_skipped","reason":"allowlisted","tool":"%s"}\n' \
79+
"$TIMESTAMP" "$TOOL_NAME" >> "$LOG_FILE"
80+
exit 0
81+
fi
82+
83+
# ---------------------------------------------------------------------------
84+
# Threat patterns (6 categories, ~20 patterns)
85+
#
86+
# Each entry: "CATEGORY:::SEVERITY:::REGEX:::SUGGESTION"
87+
# Uses ::: as delimiter to avoid conflicts with regex pipe characters
88+
# ---------------------------------------------------------------------------
89+
PATTERNS=(
90+
# Destructive file operations
91+
"destructive_file_ops:::critical:::rm -rf /:::Use targeted 'rm' on specific paths instead of root"
92+
"destructive_file_ops:::critical:::rm -rf ~:::Use targeted 'rm' on specific paths instead of home directory"
93+
"destructive_file_ops:::critical:::rm -rf \.:::Use targeted 'rm' on specific files instead of current directory"
94+
"destructive_file_ops:::critical:::rm -rf \.\.:::Never remove parent directories recursively"
95+
"destructive_file_ops:::critical:::(rm|del|unlink).*\.env:::Use 'mv' to back up .env files before removing"
96+
"destructive_file_ops:::critical:::(rm|del|unlink).*\.git[^i]:::Never delete .git directory — use 'git' commands to manage repo state"
97+
98+
# Destructive git operations
99+
"destructive_git_ops:::critical:::git push --force.*(main|master):::Use 'git push --force-with-lease' or push to a feature branch"
100+
"destructive_git_ops:::critical:::git push -f.*(main|master):::Use 'git push --force-with-lease' or push to a feature branch"
101+
"destructive_git_ops:::high:::git reset --hard:::Use 'git stash' to preserve changes, or 'git reset --soft'"
102+
"destructive_git_ops:::high:::git clean -fd:::Use 'git clean -n' (dry run) first to preview what will be deleted"
103+
104+
# Database destruction
105+
"database_destruction:::critical:::DROP TABLE:::Use 'ALTER TABLE' or create a migration with rollback support"
106+
"database_destruction:::critical:::DROP DATABASE:::Create a backup first; consider revoking DROP privileges"
107+
"database_destruction:::critical:::TRUNCATE:::Use 'DELETE FROM ... WHERE' with a condition for safer data removal"
108+
"database_destruction:::high:::DELETE FROM [a-zA-Z_]+ *;:::Add a WHERE clause to 'DELETE FROM' to avoid deleting all rows"
109+
110+
# Permission abuse
111+
"permission_abuse:::high:::chmod 777:::Use 'chmod 755' for directories or 'chmod 644' for files"
112+
"permission_abuse:::high:::chmod -R 777:::Use specific permissions ('chmod -R 755') and limit scope"
113+
114+
# Network exfiltration
115+
"network_exfiltration:::critical:::curl.*\|.*bash:::Download the script first, review it, then execute"
116+
"network_exfiltration:::critical:::wget.*\|.*sh:::Download the script first, review it, then execute"
117+
"network_exfiltration:::high:::curl.*--data.*@:::Review what data is being sent before using 'curl --data @file'"
118+
119+
# System danger
120+
"system_danger:::high:::sudo :::Avoid 'sudo' — run commands with the least privilege needed"
121+
"system_danger:::high:::npm publish:::Use 'npm publish --dry-run' first to verify package contents"
122+
)
123+
124+
# ---------------------------------------------------------------------------
125+
# Escape a string for safe JSON embedding
126+
# ---------------------------------------------------------------------------
127+
json_escape() {
128+
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g'
129+
}
130+
131+
# ---------------------------------------------------------------------------
132+
# Scan combined text against threat patterns
133+
# ---------------------------------------------------------------------------
134+
THREATS=()
135+
THREAT_COUNT=0
136+
137+
for entry in "${PATTERNS[@]}"; do
138+
category="${entry%%:::*}"
139+
rest="${entry#*:::}"
140+
severity="${rest%%:::*}"
141+
rest="${rest#*:::}"
142+
regex="${rest%%:::*}"
143+
suggestion="${rest#*:::}"
144+
145+
if printf '%s\n' "$COMBINED" | grep -qiE "$regex" 2>/dev/null; then
146+
local_match=$(printf '%s\n' "$COMBINED" | grep -oiE "$regex" 2>/dev/null | head -1)
147+
THREATS+=("${category} ${severity} ${local_match} ${suggestion}")
148+
THREAT_COUNT=$((THREAT_COUNT + 1))
149+
fi
150+
done
151+
152+
# ---------------------------------------------------------------------------
153+
# Output and logging
154+
# ---------------------------------------------------------------------------
155+
if [[ $THREAT_COUNT -gt 0 ]]; then
156+
echo ""
157+
echo "🛡️ Tool Guardian: $THREAT_COUNT threat(s) detected in '$TOOL_NAME' invocation"
158+
echo ""
159+
printf " %-24s %-10s %-40s %s\n" "CATEGORY" "SEVERITY" "MATCH" "SUGGESTION"
160+
printf " %-24s %-10s %-40s %s\n" "--------" "--------" "-----" "----------"
161+
162+
# Build JSON findings array
163+
FINDINGS_JSON="["
164+
FIRST=true
165+
for threat in "${THREATS[@]}"; do
166+
IFS=$'\t' read -r category severity match suggestion <<< "$threat"
167+
168+
# Truncate match for display
169+
display_match="$match"
170+
if [[ ${#match} -gt 38 ]]; then
171+
display_match="${match:0:35}..."
172+
fi
173+
printf " %-24s %-10s %-40s %s\n" "$category" "$severity" "$display_match" "$suggestion"
174+
175+
if [[ "$FIRST" != "true" ]]; then
176+
FINDINGS_JSON+=","
177+
fi
178+
FIRST=false
179+
FINDINGS_JSON+="{\"category\":\"$(json_escape "$category")\",\"severity\":\"$(json_escape "$severity")\",\"match\":\"$(json_escape "$match")\",\"suggestion\":\"$(json_escape "$suggestion")\"}"
180+
done
181+
FINDINGS_JSON+="]"
182+
183+
echo ""
184+
185+
# Write structured log entry
186+
printf '{"timestamp":"%s","event":"threats_detected","mode":"%s","tool":"%s","threat_count":%d,"threats":%s}\n' \
187+
"$TIMESTAMP" "$MODE" "$(json_escape "$TOOL_NAME")" "$THREAT_COUNT" "$FINDINGS_JSON" >> "$LOG_FILE"
188+
189+
if [[ "$MODE" == "block" ]]; then
190+
echo "🚫 Operation blocked: resolve the threats above or adjust TOOL_GUARD_ALLOWLIST."
191+
echo " Set GUARD_MODE=warn to log without blocking."
192+
exit 1
193+
else
194+
echo "⚠️ Threats logged in warn mode. Set GUARD_MODE=block to prevent dangerous operations."
195+
fi
196+
else
197+
# Log clean result
198+
printf '{"timestamp":"%s","event":"guard_passed","mode":"%s","tool":"%s"}\n' \
199+
"$TIMESTAMP" "$MODE" "$(json_escape "$TOOL_NAME")" >> "$LOG_FILE"
200+
fi
201+
202+
exit 0

0 commit comments

Comments
 (0)