chore(hooks): project hooks that block blanket staging, secrets and build output [skip ci]

.codewhale/hooks.toml adds two tool_call_before hooks backed by one
policy script, scripts/agent-guard.py:

- Shell guard: denies git add -A, --all, -u, a dot, .., a star or the
  repo root; git commit -a or --all; and add or commit pathspecs naming
  .env files (templates ending .example, .sample or .template stay
  allowed), private keys (pem, key, p8, p12, pfx, jks, keystore, ppk,
  ssh identities) or target/, node_modules/ and .mimosa/ paths (a
  fixtures folder is exempt for build folders). It reads chained,
  subshell, bash -c and eval forms and skips heredoc bodies, quoted
  messages and comments.
- Edit guard: opt-in release freeze. With CODEWHALE_RELEASE_FREEZE set,
  edits to .github/workflows/release*.yml and auto-tag.yml through the
  file tools, apply_patch or mutating shell commands are denied; reads
  stay allowed.

Every deny names the rule and the way forward, worded to pass the
Engine's 240-character denial redactor unchanged.

The hooks file pins the script's SHA-256, so approving the hooks digest
approves the script too: a changed script fails the pin and never runs.
Both hooks keep continue_on_error = true, so a guard that cannot run
(no python3 or shasum, a Windows cmd shell) reports no verdict and
never blocks work. Nothing runs until the person trusts the workspace
and approves the digest (/hooks review, then /hooks approve).

The script also answers Claude Code's PreToolUse protocol (JSON on
stdin, exit 2 with the reason on stderr), so wiring it there later
adds no second policy.

Verified: 77 of 77 block and allow cases (both protocols, freeze on and
off, truncated arguments) in a scratch harness that runs the script
with the Engine's hook environment; all 18 distinct deny reasons pass a
replica of sanitize_hook_denial_reason unchanged, worst case 221
characters; the exact hooks.toml commands run through sh -c, and a
tampered copy of the script exits 1 without running; tomllib parses
the file with only Hook fields. No Rust changed. No CHANGELOG line:
this is repository tooling, not a user-visible change.

Hooks digest: f4112f65c9e5f389a211d4cfb08414de4a7e516096e912afd038314d907d5b1e

Refs CW-7860

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-11-07 15:07:15 -08:00
1 parent 4c5426f9bb
commit 3631a174f6
3 files changed
+517 -1

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
[[hooks]]
name = "shell guard (staging, release freeze)"
event = "tool_call_before"
command = 'cd "$DEEPSEEK_WORKSPACE" && echo "4c8e10ebe99524e6a5b2f0fbb090a8f4881f95b2bf732f020c566a610f949aa4 scripts/agent-guard.py" | shasum -a 256 -c -s && exec python3 scripts/agent-guard.py'
timeout_secs = 10
continue_on_error = true
condition = { type = "tool_name", name = "bash" }
[[hooks]]
name = "edit guard (CODEWHALE_RELEASE_FREEZE=1)"
event = "tool_call_before"
command = '[ -z "$CODEWHALE_RELEASE_FREEZE" ] || { cd "$DEEPSEEK_WORKSPACE" && echo "4c8e10ebe99524e6a5b2f0fbb090a8f4881f95b2bf732f020c566a610f949aa4 scripts/agent-guard.py" | shasum -a 256 -c -s && exec python3 scripts/agent-guard.py; }'
timeout_secs = 10
continue_on_error = true
[hooks.condition]
type = "any"
conditions = [
{ type = "tool_category", category = "file_write" },
{ type = "tool_name", name = "write_file" },
{ type = "tool_name", name = "edit_file" },
{ type = "tool_name", name = "fim_edit" },
]
+3 -1
View File
@@ -91,9 +91,11 @@ project_overhaul_prompt.md
# Local runtime state
# Ignore everything under any .codewhale/ (snapshots, auto-generated
# instructions.md, etc.) at any depth EXCEPT the committed repo authority policy.
# instructions.md, etc.) at any depth EXCEPT the committed repo authority policy
# and this repository's own project hooks.
**/.codewhale/*
!**/.codewhale/constitution.json
!/.codewhale/hooks.toml
.deepseek/
**/session_*.json
*.db
+491
View File
@@ -0,0 +1,491 @@
#!/usr/bin/env python3
import fnmatch
import glob
import json
import os
import re
import shlex
import sys
FREEZE_VARIABLE = "CODEWHALE_RELEASE_FREEZE"
FROZEN_GLOBS = (".github/workflows/release*.yml", ".github/workflows/auto-tag.yml")
SHELL_TOOLS = {"bash", "Bash", "exec_shell"}
FILE_TOOLS = {
"write",
"edit",
"write_file",
"edit_file",
"fim_edit",
"File",
"file",
"apply_patch",
"Write",
"Edit",
"MultiEdit",
"NotebookEdit",
}
FILE_READ_ACTIONS = {"read", "list", "search_name", "search_content"}
KEYWORDS = {"if", "then", "do", "else", "elif", "while", "until", "!", "{", "}"}
WRAPPERS = {"time", "sudo", "doas", "env", "command", "builtin", "exec", "nice", "nohup", "xargs"}
SHELLS = {"sh", "bash", "zsh", "dash", "ksh"}
READ_ONLY_PROGRAMS = {
"cat", "less", "more", "head", "tail", "grep", "egrep", "fgrep", "rg", "ag", "wc",
"diff", "cmp", "ls", "stat", "file", "shasum", "sha256sum", "md5", "md5sum", "jq",
"yq", "bat", "actionlint", "yamllint", "echo", "printf", "test", "[", "realpath",
"basename", "dirname", "sed", "awk", "gawk", "perl",
}
IN_PLACE_PROGRAMS = {"sed", "awk", "gawk", "perl"}
READ_ONLY_GIT = {
"diff", "log", "show", "status", "blame", "annotate", "grep", "ls-files",
"cat-file", "rev-parse", "shortlog",
}
GIT_VALUE_OPTIONS = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env", "--super-prefix"}
COMMIT_VALUE_OPTIONS = {
"--message", "--file", "--reuse-message", "--reedit-message", "--fixup", "--squash",
"--author", "--date", "--template", "--cleanup", "--trailer", "--pathspec-from-file",
}
TEMPLATE_SUFFIXES = (".example", ".sample", ".template")
KEY_SUFFIXES = (".pem", ".key", ".p8", ".p12", ".pfx", ".jks", ".keystore", ".ppk")
SSH_IDENTITIES = {"id_rsa", "id_dsa", "id_ecdsa", "id_ed25519"}
BUILD_FOLDERS = ("target", "node_modules", ".mimosa")
FORBIDDEN_SAMPLES = (
".env", ".env.local", "prod.env", "id_rsa", "id_ed25519", "server.pem", "server.key",
"AuthKey.p8", "cert.p12", "cert.pfx", "release.jks", "release.keystore", "putty.ppk",
)
BLANKET = (
"Staging guard: this stages everything at once (the A, all or update flags, a dot, a "
"star or the repo root) and can sweep in credentials, build output and unfinished work "
"from other agents. Stage your own files by name."
)
COMMIT_ALL = (
"Staging guard: commit with the a or all flag sweeps in every tracked change, including "
"edits other agents have not finished. Stage your own changes by name, then commit "
"without that flag."
)
CREDENTIALS = (
"Staging guard: {name} holds credentials (.env style), which are never committed. "
"Templates ending in .example, .sample or .template are fine. Keep the real one "
"untracked and listed in .gitignore."
)
PRIVATE_KEY = (
"Staging guard: {name} looks like a private key (pem, key, p8, p12, pfx, keystore or an "
"ssh identity), and keys are never committed. If it is a deliberate test fixture, ask "
"the person to stage it."
)
BUILD_OUTPUT = (
"Staging guard: {name} holds build output, dependencies or tool state, which never "
"belongs in git. Stage the source change only, and add that folder to .gitignore if it "
"shows up as untracked."
)
FROZEN = (
"Release freeze: {name} is a release workflow, frozen while CODEWHALE_RELEASE_FREEZE "
"is set. Reading it is fine; ask the person before changing it. The freeze lifts when "
"that variable is unset."
)
HEREDOC = re.compile(r"<<-?[ \t]*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1[^\n]*\n.*?\n[ \t]*\2[ \t]*(?=\n|$)", re.S)
PATCH_PATHS = re.compile(
r"^(?:\+\+\+|---) (?:[ab]/)?(\S+)|^\*\*\* (?:Update|Add|Delete) File: (.+?)\s*$|^\*\*\* Move to: (.+?)\s*$",
re.M,
)
ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
COMMAND_FIELD = re.compile(r'"command"\s*:\s*"((?:[^"\\]|\\.)*)')
def display(name):
cleaned = "".join(ch for ch in name if ch not in "'\"`").strip()
return cleaned[:32] or "this"
def freeze_on():
return os.environ.get(FREEZE_VARIABLE, "").strip().lower() not in ("", "0", "false", "no", "off")
def is_separator(token):
return bool(token) and all(ch in "();|&\n" for ch in token)
def is_redirect(token):
return bool(token) and set(token) <= set("<>&|") and ("<" in token or ">" in token)
def segments(text):
text = HEREDOC.sub("\n", text)
try:
lexer = shlex.shlex(text, posix=True, punctuation_chars="();<>|&\n")
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
tokens = list(lexer)
except ValueError:
for piece in re.split(r"[;&|\n]+", text):
yield [token.strip("'\"") for token in piece.split()], []
return
words, targets = [], []
index = 0
while index < len(tokens):
token = tokens[index]
if token.startswith("#"):
while index < len(tokens) and tokens[index] != "\n":
index += 1
continue
if is_redirect(token):
if index + 1 < len(tokens):
operand = tokens[index + 1]
duplicate = token.endswith("&") and (operand.isdigit() or operand == "-")
if not duplicate and "<" not in token:
targets.append(operand)
index += 2
continue
if is_separator(token):
if words:
yield words, targets
words, targets = [], []
else:
words.append(token)
index += 1
if words:
yield words, targets
def strip_leading(words):
index, wrapped = 0, False
while index < len(words):
word = words[index]
if word in KEYWORDS or word in WRAPPERS or ASSIGNMENT.match(word) or (wrapped and word.startswith("-")):
wrapped = wrapped or word in WRAPPERS
index += 1
continue
break
return words[index:]
def shell_argument(words):
for index, word in enumerate(words[1:-1], start=1):
if word.startswith("-") and not word.startswith("--") and "c" in word[1:]:
return words[index + 1]
return None
def commands(text, depth=0):
for words, targets in segments(text):
words = strip_leading(words)
if not words:
if targets:
yield [], targets
continue
program = os.path.basename(words[0])
if depth < 3 and program in SHELLS:
inner = shell_argument(words)
if inner is not None:
yield from commands(inner, depth + 1)
continue
if depth < 3 and program == "eval":
yield from commands(" ".join(words[1:]), depth + 1)
continue
yield words, targets
def git_subcommand(words):
index = 1
while index < len(words):
word = words[index]
if word in GIT_VALUE_OPTIONS:
index += 2
continue
if word.startswith("-"):
index += 1
continue
return word, words[index + 1:]
return None, []
def pathspec_body(spec):
spec = spec.replace("\\", "/")
if spec.startswith(":("):
magic, _, rest = spec[2:].partition(")")
if "exclude" in magic:
return None
return rest
if spec.startswith(":"):
rest = spec[1:]
if rest[:1] in ("!", "^"):
return None
return rest.lstrip("/")
return spec
def is_blanket(spec):
body = pathspec_body(spec)
if body is None:
return False
parts = [part for part in body.strip().split("/") if part]
return all(part in (".", "..", "*") for part in parts)
def forbidden(spec):
body = pathspec_body(spec)
if body is None:
return None
parts = [part for part in body.split("/") if part not in ("", ".")]
if not parts:
return None
for folder in BUILD_FOLDERS:
if folder in parts and "fixtures" not in parts:
return BUILD_OUTPUT.format(name=folder)
base = parts[-1]
lower = base.lower()
if any(ch in lower for ch in "*?["):
samples = [sample for sample in FORBIDDEN_SAMPLES if fnmatch.fnmatch(sample.lower(), lower)]
if not samples:
return None
lower = samples[0].lower()
if lower.endswith(TEMPLATE_SUFFIXES):
return None
if lower == ".env" or lower.startswith(".env.") or lower.endswith(".env"):
return CREDENTIALS.format(name=display(base))
if lower in SSH_IDENTITIES or lower.endswith(KEY_SUFFIXES):
return PRIVATE_KEY.format(name=display(base))
return None
def add_verdict(args):
specs, blanket, dry_run, literal = [], False, False, False
for arg in args:
if literal:
specs.append(arg)
elif arg == "--":
literal = True
elif arg in ("--all", "--no-ignore-removal", "--update"):
blanket = True
elif arg == "--dry-run":
dry_run = True
elif arg.startswith("--"):
continue
elif arg.startswith("-") and len(arg) > 1:
letters = arg[1:]
dry_run = dry_run or "n" in letters
blanket = blanket or "A" in letters or "u" in letters
else:
specs.append(arg)
if dry_run:
return None
if blanket or any(is_blanket(spec) for spec in specs):
return BLANKET
for spec in specs:
reason = forbidden(spec)
if reason:
return reason
return None
def commit_verdict(args):
specs, literal, index = [], False, 0
while index < len(args):
arg = args[index]
if literal:
specs.append(arg)
elif arg == "--":
literal = True
elif arg == "--all":
return COMMIT_ALL
elif arg.startswith("--"):
if "=" not in arg and arg in COMMIT_VALUE_OPTIONS:
index += 1
elif arg.startswith("-") and len(arg) > 1:
letters = arg[1:]
for position, letter in enumerate(letters):
if letter == "a":
return COMMIT_ALL
if letter in "mFCct":
if position == len(letters) - 1:
index += 1
break
if letter in "Su":
break
else:
specs.append(arg)
index += 1
if any(is_blanket(spec) for spec in specs):
return BLANKET
for spec in specs:
reason = forbidden(spec)
if reason:
return reason
return None
def relative(token, workspace):
text = token.strip().replace("\\", "/")
if not text or text.startswith("~"):
return None
if os.path.isabs(text):
root = workspace.replace("\\", "/").rstrip("/") + "/"
if not text.startswith(root):
return None
text = text[len(root):]
while text.startswith("./"):
text = text[2:]
text = text.rstrip("/")
return text if text and text != "." else None
def frozen_files(workspace):
found = set()
for pattern in FROZEN_GLOBS:
for path in glob.glob(os.path.join(workspace, pattern)):
found.add(os.path.relpath(path, workspace).replace("\\", "/"))
return sorted(found)
def frozen_hit(token, workspace, frozen):
rel = relative(token, workspace)
if rel is None:
return None
globbed = any(ch in rel for ch in "*?[")
for path in frozen:
same_depth = path.count("/") == rel.count("/") or "**" in rel
if rel == path or path.startswith(rel + "/") or (globbed and same_depth and fnmatch.fnmatch(path, rel)):
return os.path.basename(path)
for pattern in FROZEN_GLOBS:
if fnmatch.fnmatch(rel, pattern):
return os.path.basename(rel)
return None
def writes_in_place(words):
for word in words[1:]:
if word.startswith("--in-place") or word == "inplace":
return True
if word.startswith("-") and not word.startswith("--") and "i" in word[1:]:
return True
return False
def shell_freeze_verdict(words, targets, workspace, frozen):
for target in targets:
hit = frozen_hit(target, workspace, frozen)
if hit:
return FROZEN.format(name=display(hit))
hits = [hit for hit in (frozen_hit(word, workspace, frozen) for word in words[1:]) if hit]
if not hits:
return None
program = os.path.basename(words[0])
if program in IN_PLACE_PROGRAMS and writes_in_place(words):
return FROZEN.format(name=display(hits[0]))
if program in READ_ONLY_PROGRAMS:
return None
if program == "git" and git_subcommand(words)[0] in READ_ONLY_GIT:
return None
return FROZEN.format(name=display(hits[0]))
def shell_verdict(text, workspace, freeze):
frozen = frozen_files(workspace) if freeze else []
for words, targets in commands(text):
if freeze:
reason = shell_freeze_verdict(words, targets, workspace, frozen)
if reason:
return reason
if not words or os.path.basename(words[0]) != "git":
continue
subcommand, args = git_subcommand(words)
if subcommand in ("add", "stage"):
reason = add_verdict(args)
elif subcommand == "commit":
reason = commit_verdict(args)
else:
reason = None
if reason:
return reason
return None
def file_targets(args):
paths = []
for key in ("path", "file_path", "filePath", "notebook_path"):
value = args.get(key)
if isinstance(value, str):
paths.append(value)
for key in ("replace", "changes", "edits"):
items = args.get(key)
if isinstance(items, list):
paths.extend(item["path"] for item in items if isinstance(item, dict) and isinstance(item.get("path"), str))
patch = args.get("patch") or args.get("input")
if isinstance(patch, str):
for groups in PATCH_PATHS.findall(patch):
paths.extend(group for group in groups if group)
return paths
def file_freeze_verdict(args, raw, workspace):
frozen = frozen_files(workspace)
if not isinstance(args, dict):
for path in frozen:
if path in raw:
return FROZEN.format(name=display(os.path.basename(path)))
return None
if str(args.get("action", "")).lower() in FILE_READ_ACTIONS:
return None
for target in file_targets(args):
hit = frozen_hit(target, workspace, frozen)
if hit:
return FROZEN.format(name=display(hit))
return None
def shell_text(args, raw):
if isinstance(args, dict) and isinstance(args.get("command"), str):
return args["command"]
match = COMMAND_FIELD.search(raw)
if match:
fragment = match.group(1)
while fragment:
try:
return json.loads('"' + fragment + '"')
except ValueError:
fragment = fragment[:-1]
return raw
def verdict(tool, args, raw, workspace):
freeze = freeze_on()
if tool in SHELL_TOOLS:
return shell_verdict(shell_text(args, raw), workspace, freeze)
if freeze and tool in FILE_TOOLS:
return file_freeze_verdict(args, raw, workspace)
return None
def parse(raw):
try:
return json.loads(raw)
except ValueError:
return None
def main():
if "DEEPSEEK_TOOL_NAME" in os.environ:
raw = os.environ.get("DEEPSEEK_TOOL_ARGS", "")
workspace = os.environ.get("DEEPSEEK_WORKSPACE") or os.getcwd()
reason = verdict(os.environ["DEEPSEEK_TOOL_NAME"], parse(raw), raw, workspace)
if reason:
print(json.dumps({"decision": "deny", "reason": reason}))
return 0
payload = json.load(sys.stdin)
tool_input = payload.get("tool_input")
raw = json.dumps(tool_input)
workspace = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
reason = verdict(payload.get("tool_name", ""), tool_input, raw, workspace)
if reason:
print(reason, file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())