Protect (PR) · Authorization
Authorization at the Client-side hooks layer
deterministic · Outside the model's reasoning
If the agent decides to violate this concern, what stops it at this layer?
What this cell does
Deny-by-default tool allowlist, capability-based restriction, PreToolUse hooks, pre-commit hooks.
Artifacts (4)
deny-protected-paths.shview on GitHub#!/usr/bin/env bash
# ABOUTME: pre-commit hook that fails when the diff touches operator-only paths.
# ABOUTME: This catches operator-machine commits; --no-verify bypasses it. Server-side pre-receive is the backstop.
set -euo pipefail
# Operator may set this env var to bypass the check for legitimate human commits
# performed via a controlled session. Setting it from inside an agent context
# requires write access to the operator's shell profile, which the agent should
# not have.
if [[ "${OPERATOR_OVERRIDE:-}" == "1" ]]; then
exit 0
fi
cat <<'EOF' >&2
BLOCKED: edits to protected paths are operator-only.
The agent must not commit to:
- infrastructure/prod/ (production IaC)
- .github/workflows/ (CI configuration)
- secrets/ (sealed secrets and key material)
- .claude/ (agent runtime configuration)
- /etc/agents/ (system-level agent config)
If you are the operator and need to make this change manually, set
OPERATOR_OVERRIDE=1 in your shell and re-run the commit.
This pre-commit hook is bypassable with `git commit --no-verify`. The
server-side Git pre-receive hook in controls/authorization/server-side/
enforces the same rule and is not bypassable. Try to push with --no-verify
applied locally; the push will fail there too.
EOF
exit 1
pre-commit-config.yamlview on GitHub# ABOUTME: pre-commit framework config running gitleaks and a protected-paths gate.
# ABOUTME: Drop at repo root as .pre-commit-config.yaml and run `pre-commit install`. Backstopped by server-side pre-receive.
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: detect-private-key
- id: check-added-large-files
args: ["--maxkb=512"]
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: local
hooks:
- id: deny-protected-paths
name: Deny edits to protected paths from agent commits
language: script
entry: ./scripts/deny-protected-paths.sh
files: '^(infrastructure/prod/|\.github/workflows/|secrets/|\.claude/|/etc/agents/)'
always_run: false
pass_filenames: true
pre_tool_use.shview on GitHub#!/usr/bin/env bash
# ABOUTME: Claude Code PreToolUse hook with deny-then-ask-then-allow precedence.
# ABOUTME: Receives JSON on stdin. Exit 0 = allow. Exit 2 = deny (Claude Code shows the message). Exit other = error.
#
# This is a FILTER, not a BOUNDARY. It raises the cost of the casual and the
# careless path. An adversarial agent with shell access and time gets past it.
# The boundary is the server-side column: RBAC on the target, an admission
# controller, a pre-receive hook, a credential the agent never holds.
# See ../../../framework/BYPASSES.md for the evasion classes this cannot close.
set -euo pipefail
LOG_DIR="${LOG_DIR:-/var/log/agents/claude-code}"
mkdir -p "$LOG_DIR" 2>/dev/null || true
# Hook receives JSON on stdin per Claude Code hook spec.
INPUT="$(cat)"
TOOL_NAME="$(printf '%s' "$INPUT" | jq -r '.tool_name // empty')"
RAW_COMMAND="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')"
FILE_PATH="$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')"
SESSION_ID="$(printf '%s' "$INPUT" | jq -r '.session_id // "default"')"
deny() {
local kind="$1"
local detail="$2"
printf 'BLOCKED: %s: %s\n' "$kind" "$detail" >&2
printf '%s DENY %s %s %s=%s command=%s path=%s\n' \
"$(date -Iseconds)" "$SESSION_ID" "$TOOL_NAME" "$kind" "$detail" \
"$RAW_COMMAND" "$FILE_PATH" \
2>/dev/null >> "$LOG_DIR/pre_tool_use.log" || true
exit 2
}
# ----- Normalization -----
# Patterns match against a normalized copy; the raw string is what gets logged.
# Normalization folds the cheapest evasions into one shape so the pattern list
# does not have to enumerate every spelling of the same command. It closes the
# splitting tricks below and nothing beyond them.
#
# r""m -rf / quote splitting
# r\m -rf / escape splitting
# rm${IFS}-rf${IFS}/ $IFS word splitting
# rm -rf / whitespace padding
normalize() {
local s="$1"
s="${s//'""'/}"
s="${s//"''"/}"
s="${s//'\'/}"
s="${s//'${IFS}'/ }"
s="${s//'$IFS'/ }"
printf '%s' "$s" | tr '\n\t' ' ' | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//'
}
COMMAND="$(normalize "$RAW_COMMAND")"
# ----- Tier-4 hard-deny patterns -----
# These cannot be confirmed past at the client side. The agent must not even
# be asked to run them. Each pattern is a regex applied case-insensitively to
# the normalized command. Case folding matters: `drop table` and `TerraForm
# destroy` are the same instruction to the machine that runs them.
#
# `rm` is handled by the flag parser below rather than by a regex here, because
# -rf, -fr, -Rf, -f -r, and --recursive --force are one command with five
# spellings and a regex per spelling is a list that is always one short.
DENY_PATTERNS=(
'\bterraform\s+destroy'
'\bterraform\s+apply.*-auto-approve'
'\bDROP\s+TABLE'
'\bDROP\s+DATABASE'
'\bTRUNCATE\s+TABLE'
'\bkubectl\s+delete\s+ns'
'\bkubectl\s+delete\s+namespace'
'\bkubectl\s+scale\s+.*--replicas=0'
'\baws\s+s3\s+rb'
'\baws\s+ec2\s+terminate-instances'
'\baws\s+rds\s+delete-db-instance'
'\bgh\s+repo\s+delete'
'\bgit\s+push\s+(--force|-f)'
'\bgit\s+reset\s+--hard'
'\bdd\s+if=/dev/zero\s+of='
'\bmkfs\b'
':\(\)\s*\{\s*:\|:&\s*\};:'
# Equivalent commands, enumerated in framework/BYPASSES.md as evasions of the
# patterns above. `find` rooted outside the working tree with a delete action
# is `rm -rf` with a different name, so it gets the same answer.
'\bfind\s+(/|~|\$HOME|\$\{HOME\})[^;|&]*(-delete\b|-exec(dir)?\s+(rm|shred)\b|-ok\s+(rm|shred)\b)'
'\bshred\s+(-[a-z]+\s+)*(/|~|\$HOME)'
)
for pattern in "${DENY_PATTERNS[@]}"; do
if printf '%s' "$COMMAND" | grep -qiE "$pattern"; then
deny "deny-pattern" "$pattern"
fi
done
# ----- rm flag analysis -----
# Parse the flags instead of pattern-matching them, so flag order, flag case,
# combined flags, separated flags, and long flags all reach the same verdict.
#
# The policy line: a recursive delete aimed at an absolute path, $HOME, or ~ is
# denied. The same delete aimed inside the working tree (`rm -rf ./node_modules`)
# is ordinary work and is allowed. A hook that denies both is an outage.
segment_is_recursive_root_rm() {
local segment="$1"
local -a tokens=()
local tok base flags
local seen_rm=0 recursive=0 root_target=0
read -r -a tokens <<< "$segment"
if (( ${#tokens[@]} == 0 )); then
return 1
fi
for tok in "${tokens[@]}"; do
tok="${tok//'"'/}"
tok="${tok//"'"/}"
if [[ -z "$tok" ]]; then
continue
fi
if (( seen_rm == 0 )); then
# Skip leading env assignments and wrappers such as sudo, and match
# on the basename so /bin/rm counts.
base="${tok##*/}"
if [[ "${base,,}" == "rm" ]]; then
seen_rm=1
fi
continue
fi
case "${tok,,}" in
--recursive|--no-preserve-root)
recursive=1
;;
--*)
;;
-*)
flags="${tok,,}"
if [[ "${flags#-}" == *r* ]]; then
recursive=1
fi
;;
/*|'~'*|'$home'*|'${home}'*)
root_target=1
;;
esac
done
if (( seen_rm == 1 && recursive == 1 && root_target == 1 )); then
return 0
fi
return 1
}
# Judge each simple command on its own, so `rm -r ./build && ls /etc` is not
# read as a recursive delete of /etc.
while IFS= read -r segment; do
if segment_is_recursive_root_rm "$segment"; then
deny "recursive-delete" "rm -r aimed at an absolute path, \$HOME, or ~: ${segment}"
fi
done < <(printf '%s\n' "$COMMAND" | sed -E 's/[;|&]/\n/g')
# ----- Obfuscation defenses -----
# Catches the trivial cases. Not a guarantee. An adversarial agent has more
# obfuscation options than a regex list can enumerate.
OBFUSCATION_PATTERNS=(
'\bbase64\s+(--decode|-d|-D)\b'
'\beval\s+\$\('
'\bbash\s+-c\s+\$\('
'\bsh\s+-c\s+\$\('
'\bpython3?\s+-c\s+["\x27]exec\('
'\bcurl\s+[^|]+\|\s*(bash|sh)\b'
'\bwget\s+[^|]+\|\s*(bash|sh)\b'
'\bxargs\s+rm\b'
# Env-variable indirection: binding a destructive binary to a name so the
# pattern list never sees the binary. Heuristic, and it will occasionally
# deny a harmless assignment.
'(^|[^[:alnum:]_])[A-Za-z_][A-Za-z0-9_]*=['\''"]?(/[A-Za-z0-9_./-]*/)?(rm|shred|mkfs)\b'
)
for pattern in "${OBFUSCATION_PATTERNS[@]}"; do
if printf '%s' "$COMMAND" | grep -qiE "$pattern"; then
deny "obfuscation" "$pattern"
fi
done
# ----- Protected write paths -----
# Editing Terraform state or production Terraform by hand is the documented
# stand-in for running `terraform destroy`, and the Edit and Write tools carry a
# file path rather than a command. Only the detectable cases are covered. Adjust
# these to your own repository layout; they are not a substitute for the
# server-side pre-receive hook or for filesystem ownership.
PROTECTED_PATH_PATTERNS=(
'\.tfstate(\.backup)?$'
'(^|/)(infrastructure|infra|terraform|deploy)/prod(uction)?/.*\.(tf|tfvars)$'
)
if [[ -n "$FILE_PATH" ]]; then
for pattern in "${PROTECTED_PATH_PATTERNS[@]}"; do
if printf '%s' "$FILE_PATH" | grep -qE "$pattern"; then
deny "protected-path" "$pattern"
fi
done
fi
# ----- Allow with logging -----
printf '%s ALLOW %s %s command=%s path=%s\n' \
"$(date -Iseconds)" "$SESSION_ID" "$TOOL_NAME" "$RAW_COMMAND" "$FILE_PATH" \
2>/dev/null >> "$LOG_DIR/pre_tool_use.log" || true
exit 0
settings.jsonview on GitHub{
"_comment": "defaultMode dontAsk auto-denies anything that would otherwise prompt: only the allow rules below, built-in read-only Bash, and calls a PreToolUse hook approves will run. Valid modes are default (aka manual), acceptEdits, plan, auto, dontAsk, bypassPermissions. There is no deny mode; the deny LIST below is what blocks specific tools.",
"permissions": {
"defaultMode": "dontAsk",
"allow": [
"Read",
"Glob",
"Grep"
],
"ask": [
"Edit",
"Write",
"Bash(git:status)",
"Bash(git:diff)",
"Bash(git:log)",
"Bash(git:add:*)",
"Bash(git:commit:*)",
"Bash(npm:install)",
"Bash(pip:install)"
],
"deny": [
"Bash(rm:-rf)",
"Bash(rm:-rf:/)",
"Bash(terraform:destroy)",
"Bash(terraform:apply:-auto-approve)",
"Bash(kubectl:delete:*)",
"Bash(kubectl:scale:*--replicas=0*)",
"Bash(aws:s3:rb)",
"Bash(aws:ec2:terminate-instances)",
"Bash(aws:rds:delete-db-instance)",
"Bash(gh:repo:delete)",
"Bash(curl:*-X:DELETE*)",
"Bash(docker:system:prune)",
"Bash(helm:uninstall)",
"Bash(git:push:--force)",
"Bash(git:reset:--hard)",
"Bash(dd:*)",
"Bash(mkfs:*)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/etc/agents/hooks/pre_tool_use.sh"
}
]
}
]
}
}
Cell notes
Authorization / Client-side
Control. --allowedTools deny-by-default. Capability-based tool restriction at the SDK layer. PreToolUse pattern hooks with deny-then-ask-then-allow precedence. Pre-commit hooks blocking changes to protected paths. Hooks directory and config owned by the operator, not the agent.
Strength. Deterministic when the hook config is uncompromised and the agent runtime honors precedence. Bypassable through allowlisted-shell shell-out, command obfuscation (base64, eval, env-var indirection), equivalent commands not in the pattern list, --no-verify, and filesystem tampering when the agent has write access to the hook surface.
Tooling
- - Claude Code v2.1.40 or later (the May 2026 PreToolUse precedence patch, pre-patch versions allow
allowto overridedeny). - -
pre-commitframework on the operator's machine. - -
jqfor the hook script. - - A server-side Git pre-receive hook to backstop
--no-verify. Lives in../server-side/.
Files in this directory
- -
settings.json, Claude Code project settings withpermissionsdefaultMode: deny, an explicitallowlist (read-only ops), anasklist (mutation ops), adenylist (destructive ops). Drop in your project at.claude/settings.json(operator-owned, agent runs as a different user). - -
pre_tool_use.sh, PreToolUse hook that receives JSON on stdin per the Claude Code hook spec. Hard-deny patterns (terraform destroy,DROP TABLE, fork bombs), a flag parser forrm, the equivalent-command evasions listed inBYPASSES.md, and obfuscation patterns (base64 decode, eval, env-variable indirection). Deploy to/etc/agents/hooks/with mode 0755 and owner root. Behavior is locked bytests/test_pre_tool_use.py. - -
pre-commit-config.yaml, pre-commit framework config that runs gitleaks plus a deny-protected-paths hook. Drop at the repo root as.pre-commit-config.yamland runpre-commit install. - -
deny-protected-paths.sh, pre-commit hook that fails when the diff touchesinfrastructure/prod/,.github/workflows/, orsecrets/. Operator commits to those paths; agent commits do not.
What the hook catches, and what it does not
This is a filter, not a boundary. It raises the cost of the casual and the careless path. It does not stop an adversary. Anyone with shell access and time gets past a pattern matcher, and GHSA-xq4m-mc3c-vvg3 is the proof that even the runtime's own allowlist parser can be talked into the wrong answer about a command it read correctly. The boundary is the server-side column: RBAC on the target, an admission controller, a pre-receive hook, a credential the agent never holds. Nothing in this directory is a substitute for those, and the hardening below does not change that.
What it does catch, because these are cheap to catch and are what casual misuse actually looks like:
| Evasion class | Handling |
Case variation (drop table, TerraForm destroy) | All patterns match case-insensitively. |
rm flag permutation (-fr, -Rf, -f -r, --recursive --force) | Flags are parsed, not pattern-matched. One rule covers every spelling. |
Word splitting (r""m, r\m, rm${IFS}-rf${IFS}/) | The command is normalized before matching: empty quote pairs removed, escapes stripped, $IFS expanded, whitespace collapsed. |
Equivalent commands (find / -delete, kubectl scale --replicas=0, TRUNCATE) | Denied by name, from the list in BYPASSES.md. |
Env-variable indirection (X=rm; $X -rf /) | Heuristic: binding a destructive binary to a variable name is denied. |
Hand-editing Terraform state or production .tf | Denied on the Edit and Write tools by file path, where the path is detectable. |
What it deliberately does not catch, so nobody mistakes an untested gap for a covered one:
- - Anything that reaches the shell through an interpreter. A Python or Node one-liner, a script written to a file and then executed, a Makefile target. The hook sees a command string, not a syscall.
- - Indirection the normalizer cannot resolve.
$(echo cm0K | base64 -d), a function defined earlier in the session, an alias, a wrapper script onPATH. Some spellings trip the obfuscation list; the class is open-ended. - - Absolute-path deletes below the recursion rule.
rm /etc/passwdis a single-file delete, so it is allowed. Deny-by-default on paths belongs in the filesystem, not here. - -
DELETE FROMwithout aWHEREclause, and most data-destroying SQL that is notDROPorTRUNCATE. Distinguishing a routine delete from a table wipe needs a parser, and a half-parser produces false denials. - - Shell redirection into a protected path.
echo x > infrastructure/prod/main.tfis a Bash command, not an Edit call, and the path check only reads the Edit and Write tool inputs. - - A hook the agent can rewrite. File ownership answers this, not the hook's own content.
The policy line for deletes is that a recursive delete aimed at an absolute path, $HOME, or ~ is denied, while the same delete inside the working tree is ordinary work. rm -rf /var/lib is denied; rm -rf ./node_modules is allowed. A hook that denies both is an outage, not a control.
Writing about a dangerous command is the same as running one
The hook is handed a command string. It cannot tell a command that uses a dangerous pattern from one that quotes it, and it does not try. Both of these are harmless and both are denied:
grep -rn "drop table" docs/
echo "find /target -delete is a known bypass" >> notes.md
Case folding widens this. git commit -m "drop table rendering from the docs" is denied for the same reason drop table users is.
A quote-aware exemption for read-only commands (grep, echo, printf, cat) was considered and rejected. These are the same shape as the two above, and all three are destructive:
echo "drop table users" | psql prod
echo "rm -rf /" > payload.sh; sh payload.sh
printf "%s" "find /target -delete" > p.sh && bash p.sh
They are denied today precisely because quoted text is matched, and write-payload-then-execute is an obfuscation class BYPASSES.md already lists. An exemption broad enough to clear the false positives clears these too. Separating them needs a shell interpreter, not a rule, and deciding what counts as "inside a quote" is the defect in GHSA-xq4m-mc3c-vvg3, where the parser read the command correctly and still reached the wrong verdict. A filter that cannot parse intent is wrong in one direction or the other. For a control whose failure mode is destruction, erring toward denial is the right default, and a loud false denial is a better failure than a quiet exploitable exemption.
The trade is survivable because the Edit and Write tools carry a file path rather than a command, so the hook never sees the text. Documenting an evasion in BYPASSES.md goes through unaffected. Use the file tools rather than a shell heredoc, and if a specific denied string blocks real work, tune the pattern list to your own traffic rather than adding a general exemption.
Verification
# 1. Hook fires on a denied command
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/test"}}' \
| /etc/agents/hooks/pre_tool_use.sh
echo "exit code: $?"
# expected: BLOCKED message + exit 2
# 2. Hook does not fire on an allowed command
echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' \
| /etc/agents/hooks/pre_tool_use.sh
echo "exit code: $?"
# expected: exit 0
# 3. Hook config ownership
ls -la /etc/agents/hooks/pre_tool_use.sh
# expected: owner root, mode 0755
# 4. Agent cannot edit the hook
sudo -u agent-runner touch /etc/agents/hooks/pre_tool_use.sh
# expected: Permission denied
# 5. Pre-commit fires on a protected path
git checkout -b test-protected
echo "test" >> infrastructure/prod/main.tf
git add . && git commit -m "test"
# expected: failure at the deny-protected-paths hook
# 6. Known evasions are denied, not just the canonical spelling
for cmd in 'rm -fr /' 'find /target -delete' 'drop table users' 'TerraForm destroy'; do
printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$cmd" \
| /etc/agents/hooks/pre_tool_use.sh >/dev/null 2>&1
echo "$? <- $cmd"
done
# expected: 2 on every line
# 7. Ordinary work still runs
for cmd in 'git status' 'kubectl get pods' 'rm -rf ./node_modules'; do
printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$cmd" \
| /etc/agents/hooks/pre_tool_use.sh >/dev/null 2>&1
echo "$? <- $cmd"
done
# expected: 0 on every line
Both directions are covered by the test suite, which drives the hook over the same JSON-on-stdin contract Claude Code uses:
uvx --with pyyaml --from pytest pytest tests/test_pre_tool_use.py -q
Common mistakes
- - Allowing
Bash(*)in the allow list, which gates nothing. - - Pattern hooks that match
kubectl deletebut notkubectl scale --replicas=0, orDROP TABLEbut notdrop table. Every deny pattern needs its equivalent commands and its case variants tested, in both directions, or the list is a guess. Even then, pattern hooks catch casual misuse and not adversarial agents. Document the residual bypass and accept the matrix's framing. - - Treating a hardened pattern list as a boundary. Closing the known evasions makes the filter better at being a filter. It does not make it a control the server-side column can lean on.
- - Reading a false positive as a bug in the pattern list.
grep -rn "drop table" docs/andecho "find /target -delete is a known bypass" >> notes.mdare both denied, and both are harmless. Adding a quote-aware exemption to fix that also un-blocksecho "drop table users" | psql prod, which is the same shape and is not harmless. See the trade above before loosening anything. - - Hook config owned by the agent's own user. The agent edits its own deny list at runtime.
- - Forgetting to enable the May 2026 Claude Code patch where
allowno longer beatsdeny. Verify withclaude --versionand check the changelog. - - Pre-commit only on the operator's machine. The agent runs
git commit --no-verifyand skips it. Mitigation is the server-side pre-receive hook in../server-side/.
Citation
NIST CSF 2.0 PR.AA-05 (least privilege, separation of duties), PR.PS-01 (configuration management practices). NIST AI RMF MANAGE 2.4, MANAGE 4.1. OWASP LLM06:2025 (Excessive Agency); LLM05:2025 (Improper Output Handling). OWASP ASI02:2026 (Tool Misuse), ASI05:2026 (Unexpected Code Execution). OWASP MCP02:2025, MCP05:2025. OWASP Agentic Least Agency principle. NIST SP 800-207 §2.1 (least privilege).
Primary bypasses
Documented, not hypothetical. A control whose bypass is undocumented is worse than no control, because somebody trusted it.
- shell-out via allowlisted Bash
- pattern evasion (equivalent commands)
- --no-verify on pre-commit
Crosswalk
| NIST CSF 2 0 | PR.AA-05, PR.PS-01 |
|---|---|
| NIST AI RMF | MANAGE 2.4, MANAGE 4.1 |
| OWASP LLM | LLM05:2025, LLM06:2025 |
| OWASP AGENTIC | ASI02:2026, ASI05:2026 |
| OTHER | OWASP Agentic Least Agency, NIST SP 800-207 §2.1 |
Cite this cell:
https://agenticcovenants.com/protect/authorization/client-side/