Agentic Covenants

Protect (PR) · Authorization

Authorization at the client side 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.

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="$(echo "$INPUT" | jq -r '.tool_name // empty')"
TOOL_INPUT="$(echo "$INPUT" | jq -r '.tool_input.command // empty')"
SESSION_ID="$(echo "$INPUT" | jq -r '.session_id // "default"')"

# ----- 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 to TOOL_INPUT.
DENY_PATTERNS=(
  '\brm\s+-rf\s+/'
  '\brm\s+-rf\s+~'
  '\brm\s+-rf\s+\$HOME'
  '\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*\};:'
)

for pattern in "${DENY_PATTERNS[@]}"; do
  if echo "$TOOL_INPUT" | grep -qE "$pattern"; then
    echo "BLOCKED: command matches deny pattern: ${pattern}" >&2
    echo "$(date -Iseconds) DENY $SESSION_ID $TOOL_NAME pattern=${pattern} input=${TOOL_INPUT}" \
      >> "$LOG_DIR/pre_tool_use.log" 2>/dev/null || true
    exit 2
  fi
done

# ----- 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'
)

for pattern in "${OBFUSCATION_PATTERNS[@]}"; do
  if echo "$TOOL_INPUT" | grep -qE "$pattern"; then
    echo "BLOCKED: obfuscation pattern detected: ${pattern}" >&2
    echo "$(date -Iseconds) DENY-OBFUSCATION $SESSION_ID $TOOL_NAME pattern=${pattern} input=${TOOL_INPUT}" \
      >> "$LOG_DIR/pre_tool_use.log" 2>/dev/null || true
    exit 2
  fi
done

# ----- Allow with logging -----
echo "$(date -Iseconds) ALLOW $SESSION_ID $TOOL_NAME input=${TOOL_INPUT}" \
  >> "$LOG_DIR/pre_tool_use.log" 2>/dev/null || 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 (rm -rf /, terraform destroy, fork bombs) and obfuscation patterns (base64 decode, eval). Deploy to /etc/agents/hooks/ with mode 0755 and owner root.
  • - 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.

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

Common mistakes

  • - Allowing Bash(*) in the allow list, which gates nothing.
  • - Pattern hooks that match kubectl delete but not kubectl scale --replicas=0. Pattern hooks catch casual misuse, not adversarial agents. Document the equivalent-command bypass and accept the matrix's framing.
  • - 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 (Excessive Agency); LLM05 (Improper Output Handling). OWASP ASI02 (Tool Misuse), ASI05 (Unexpected Code Execution). OWASP MCP02, MCP05. 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, LLM06
OWASP AGENTICASI02, ASI05
OTHEROWASP Agentic Least Agency, NIST SP 800-207 §2.1