Agentic Covenants

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 allow to override deny).
  • - pre-commit framework on the operator's machine.
  • - jq for 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 with permissions defaultMode: deny, an explicit allow list (read-only ops), an ask list (mutation ops), a deny list (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 for rm, the equivalent-command evasions listed in BYPASSES.md, and obfuscation patterns (base64 decode, eval, env-variable indirection). Deploy to /etc/agents/hooks/ with mode 0755 and owner root. Behavior is locked by tests/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.yaml and run pre-commit install.
  • - deny-protected-paths.sh, pre-commit hook that fails when the diff touches infrastructure/prod/, .github/workflows/, or secrets/. 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 classHandling
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 .tfDenied 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 on PATH. Some spellings trip the obfuscation list; the class is open-ended.
  • - Absolute-path deletes below the recursion rule. rm /etc/passwd is a single-file delete, so it is allowed. Deny-by-default on paths belongs in the filesystem, not here.
  • - DELETE FROM without a WHERE clause, and most data-destroying SQL that is not DROP or TRUNCATE. 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.tf is 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 delete but not kubectl scale --replicas=0, or DROP TABLE but not drop 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/ and echo "find /target -delete is a known bypass" >> notes.md are both denied, and both are harmless. Adding a quote-aware exemption to fix that also un-blocks echo "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 allow no longer beats deny. Verify with claude --version and check the changelog.
  • - Pre-commit only on the operator's machine. The agent runs git commit --no-verify and 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 0PR.AA-05, PR.PS-01
NIST AI RMFMANAGE 2.4, MANAGE 4.1
OWASP LLMLLM05:2025, LLM06:2025
OWASP AGENTICASI02:2026, ASI05:2026
OTHEROWASP Agentic Least Agency, NIST SP 800-207 §2.1