Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .claude/hooks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Kit hooks — deterministic gates

Hook scripts shipped by the scaffold kit and wired into `../settings.json`. They are the
harness-enforcement layer of the deterministic-fleet plan: the model proposes, these
scripts validate. **A gate counts as landed only where its hook is installed** — the
canon text alone is explicitly inert.

| Script | Event · matcher | Enforces |
|:--|:--|:--|
| `dangerous-command-gate.sh` | `PreToolUse` · `Bash` (**first** in the chain) | Permission floor (`principles-base.md` §19): destructive git/gh operations and `.env` reads, matched on the normalised operation rather than a literal prefix |
| `linear-card-lint.sh` | `PreToolUse` · `mcp__linear__save_issue` | Card contract (`skill-linear-cards.md`): agent state vocabulary, fleet assignee, owner line + signature + universal label on create |
| `telegram-send-check.sh` | `PreToolUse` · `Bash` | Channel registry (`telegram-channels.md`): a send's chat ID must equal the registered channel for this project, or it is refused |
| `gate-ledger-gate.sh` | `PreToolUse` · `Bash` | Gate ledger (`skill-gate-ledger.md`): PR/merge/deploy commands blocked while the active block is missing prior-stage records |
| `persona-anchor-inject.sh` | `SessionStart` · `compact` | Persona persistence (`principles-base.md` §17): re-injects `*-anchor.md` files after every compaction |

## Per-project configuration

Each script runs on built-in fleet defaults; optional config files beside the scripts
override them (all untracked-safe, none required to boot):

- `linear-lint.config.json` — `{ "allowed_states": [...], "assignee": "..." }`
- `telegram-channels.local.json` — **required before any Telegram send**; written once at
project start after the operator confirms the channel. Prefer `chat_id_env` (secrets by
ENV NAME only); keep this file untracked.
- `gate-ledger.config.json` — `{ "ledger_dir", "active_block_file", "pr_pattern", "merge_pattern", "deploy_pattern" }`.
`pr_pattern` is **default off**: absent, the `gh pr create` surface is inert; set (e.g.
`"gh[[:space:]]+pr[[:space:]]+create"`), PR creation requires `build challenge validation`
recorded. A supervisor that turns it on removes the key again at teardown.
- `persona-anchors.config.json` — `{ "paths": ["glob", ...] }`

## Semantics

- Refusals exit `2` with the reason on stderr — the agent reads it and corrects course;
it never works around a refusal.
- `jq` is required to parse hook input; if missing, the PreToolUse gates skip **loudly**
(stderr warning) rather than break every tool call. Install `jq` to arm them.
- The gate-ledger gate activates only while `.tron-active-block` (project root) names an
**active** block. The supervising process writes one entry per line — `<id>` (active) or
`<id> closed` (closed-marked: the block's evidence landed, its gates are down) — appending
at block start and marking at block close. **Zero active entries is inert**: the gate
passes everything, exactly as an absent file does.
- Block resolution is **fail-closed and never branch-derived**: an explicit `TRON_BLOCK=<id>`
prefix on the command wins (active entry → that block governs; closed-marked entry →
ungated; an id in no entry → **refused**, so a mistype never silently disarms the gate);
else a single active entry governs; else two or more active entries without a token are
refused with the token form named. A guarded command is never passed unresolved.
- The script header carries a `gate-ledger-gate-version:` line — a supervisor tests an
installed copy's **marker value** against the current template rather than its mere
presence, so a stale retrofitted copy is detectable.
- The permission floor is the **hook**, not `settings.json → permissions.deny`. Deny entries are
literal prefix matches (`git -C <path> reset --hard` and `git clean -xdf` walk past them) and are
skipped entirely in bypassPermissions mode; PreToolUse hooks run in **every** mode. The deny block
stays as one readable, tracked layer that stops the direct form — never as the guarantee.
`disableBypassPermissionsMode` is deliberately **not** shipped: it locks the operator out of their
own repo and defends nothing the gate does not already defend.
- `dangerous-command-gate.sh` matches command **text**. It normalises away the usual disguises
(`git -C`, env prefixes, `sudo`, absolute paths, reordered flags) but indirection through a
variable (`g="reset"; git $g --hard`) still defeats it. Documented in the script header; the gate
is a floor against accidents and defaults, not an adversary-proof sandbox.
- Retrofits install these same files unchanged; new projects are born with them.
115 changes: 115 additions & 0 deletions .claude/hooks/dangerous-command-gate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/bin/bash
# tron kit — Dangerous command gate (PreToolUse hook, matcher: Bash).
# Contract it enforces: canon `principles-base.md §19` permission floor.
#
# Why this exists as a hook and not only as a `permissions.deny` list:
#
# 1. `deny` entries are matched as LITERAL PREFIXES. `Bash(git reset --hard:*)` never sees
# `git -C <path> reset --hard`, and `Bash(git clean -fdx:*)` never sees `git clean -xdf`.
# Both walk-arounds were reproduced against the 1.9.0 block (KONDO, 2026-08-14).
# 2. `deny` is skipped entirely in bypass-permissions mode. Hooks are not permissions — they run
# in every mode — so the floor holds without taking the operator's bypass away from them.
#
# The declarative `deny` block stays in settings.json beside this: it is readable in the repo and it
# stops the direct form even where this hook is missing. This gate is the layer that holds.
#
# What it refuses (after normalising the command, so `git -C P`, `--git-dir=`, `-c k=v`, env
# prefixes and reordered short flags are all seen for what they are):
# - force push in any spelling, including --force-with-lease and --mirror
# - git reset --hard, git clean -f, git filter-branch / filter-repo
# - --no-verify (and `git commit -n`), which walks past the canon commit gates
# - gh pr merge --auto (canon: auto-merge is never armed — the operator clicks)
# - gh repo delete, rm -rf /
# - reading a real .env through Bash (`cat`/`less`/`grep`/… ), which the Read deny cannot see
#
# Limits, stated honestly: it matches the command TEXT. Indirection defeats it
# (`g=reset; git $g --hard`, a wrapper script, `eval "$(printf …)"`). It is a floor, not a sandbox.

input=$(cat)

if ! command -v jq >/dev/null 2>&1; then
echo "dangerous-command-gate: jq not found — check skipped, destructive-command floor UNENFORCED this call. Install jq to arm this gate." >&2
exit 0
fi

cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
[ -n "$cmd" ] || exit 0

# --- normalise -------------------------------------------------------------------------------
# Split compound commands so each segment is judged on its own, then strip the wrappers that make
# an identical operation look different: leading env assignments, absolute paths to the binary,
# `git -C <path>`, `--git-dir=`/`--work-tree=`, `-c key=value`, `--no-pager`.
segments=$(printf '%s' "$cmd" | tr '\n;|&' '\n\n\n\n')

norm() {
printf '%s' "$1" \
| sed -E 's/^[[:space:]]*(\(|\{)?[[:space:]]*//' \
| sed -E 's/^([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)+//' \
| sed -E 's#^[[:space:]]*(sudo|command|nohup|time)[[:space:]]+##' \
| sed -E 's#^[[:space:]]*(/[^[:space:]]*/)?(git|gh)([[:space:]])#\2\3#' \
| sed -E 's#\B-C[[:space:]]+[^[:space:]]+##g; s#[[:space:]]-C[[:space:]]+[^[:space:]]+##g' \
| sed -E 's#--git-dir(=| )[^[:space:]]+##g; s#--work-tree(=| )[^[:space:]]+##g' \
| sed -E 's#[[:space:]]-c[[:space:]]+[^[:space:]]+=[^[:space:]]+##g' \
| sed -E 's#--no-pager##g' \
| sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//'
}

refuse() {
echo "dangerous-command-gate: REFUSED — $1" >&2
echo "Canon principles-base.md §19 (permission floor). This gate reads the whole command, so \`git -C <path>\`, reordered flags and env prefixes do not step around it. If the operation is genuinely needed, it is the operator's call — surface it as a typed question (§11) rather than rephrasing the command." >&2
exit 2
}

while IFS= read -r seg; do
[ -n "$seg" ] || continue
n=$(norm "$seg")
[ -n "$n" ] || continue

case "$n" in
git\ *)
# force push, any spelling
printf '%s' "$n" | grep -qE '^git .*\bpush\b' && \
printf '%s' "$n" | grep -qE '(--force-with-lease|--force\b|--mirror\b|(^|[[:space:]])-[A-Za-z]*f([[:space:]]|$))' && \
refuse "force push. The integration branch's history is shared; a force push rewrites what others already have."

printf '%s' "$n" | grep -qE '^git .*\breset\b.*--hard' && \
refuse "\`git reset --hard\` discards uncommitted work irreversibly."

printf '%s' "$n" | grep -qE '^git .*\bclean\b' && \
printf '%s' "$n" | grep -qE '(--force\b|(^|[[:space:]])-[A-Za-z]*f)' && \
refuse "\`git clean -f\` deletes untracked files, which git cannot bring back."

printf '%s' "$n" | grep -qE '^git .*\b(filter-branch|filter-repo)\b' && \
refuse "history rewriting."

printf '%s' "$n" | grep -qE '^git .*\b(commit|push|merge)\b.*--no-verify' && \
refuse "\`--no-verify\` skips the canon commit and push gates."

printf '%s' "$n" | grep -qE '^git .*\bcommit\b.*(^|[[:space:]])-[A-Za-z]*n[A-Za-z]*([[:space:]]|$)' && \
refuse "\`git commit -n\` is \`--no-verify\`; it skips the canon commit gates."
;;
gh\ *)
# Anchor on the subcommand pair. `.*` between `gh` and the verb would swallow
# `gh pr merge --repo X --delete-branch`, which is an ordinary landing.
printf '%s' "$n" | grep -qE '^gh( +--[A-Za-z-]+([= ][^ ]+)?)* +pr +merge\b.*(^|[[:space:]])--auto([[:space:]]|=|$)' && \
refuse "auto-merge. Canon: the merge is the operator's click, never armed to fire later."

printf '%s' "$n" | grep -qE '^gh( +--[A-Za-z-]+([= ][^ ]+)?)* +repo +delete\b' && \
refuse "repo deletion."
;;
esac

printf '%s' "$n" | grep -qE '(^|[[:space:]])rm[[:space:]]+(-[A-Za-z]+[[:space:]]+)*-{0,2}[A-Za-z]*[rR][A-Za-z]*f?[A-Za-z]*[[:space:]]+/([[:space:]]|$)' && \
refuse "\`rm -rf /\`."

# A real .env read through Bash. `.env.example`/`.sample`/`.template` are documentation and pass.
printf '%s' "$n" | grep -qE '(^|[[:space:]])(cat|bat|less|more|head|tail|strings|xxd|od|nl|grep|rg|awk|sed)([[:space:]]|$)' && \
printf '%s' "$n" | grep -qE '(^|[[:space:]/])\.env([[:space:]]|$|\.[A-Za-z0-9_-]+)' && \
! printf '%s' "$n" | grep -qE '\.env\.(example|sample|template|dist)' && \
refuse "reading a .env through Bash. Secrets are referenced by ENV NAME, never read into a transcript."

done <<EOF
$segments
EOF

exit 0
134 changes: 134 additions & 0 deletions .claude/hooks/gate-ledger-gate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/bin/bash
# tron kit — gate-ledger gate (PreToolUse hook, matcher: Bash).
# gate-ledger-gate-version: 2026-08-13.1
# Contract it enforces: {meta}/skills/skill-gate-ledger.md (canon home:
# tortuga/dead-mans-chest/skills/skill-gate-ledger.md) + deterministic-fleet plan §1.8.
#
# No record, no advance: a stage-advancing command may run only when every prior
# stage of the active block has a ledger record (`## {stage} — DONE` or the
# block-start `## {stage} — n/a` declaration).
#
# Activation is explicit: the supervising process writes block entries to
# .tron-active-block (project root), ONE PER LINE:
# <id> — active: the block's gates are up
# <id> closed — closed-marked: the block's evidence landed, its gates are down
# No active-block file, or a file with zero active entries → no supervised block in
# flight → this hook passes everything (supervisors still self-enforce the ledger as
# canon text).
#
# Block resolution is fail-closed and never branch-derived:
# 1. an explicit `TRON_BLOCK=<id>` token in the command wins — an active entry
# governs; a closed-marked entry is ungated; an id in no entry is REFUSED
# (a mistyped id must never silently disarm the gate);
# 2. else a single active entry governs every guarded command;
# 3. else (two or more active entries, no token) the command is REFUSED with the
# token form named — never passed unresolved.
#
# Guarded surfaces (defaults; override via config):
# pr — `gh pr create` → requires build..validation recorded
# CONFIG-GATED, DEFAULT OFF: inert unless `pr_pattern` is set.
# merge — `git merge`, `gh pr merge` → requires build..ci recorded
# deploy — `vercel ... --prod`, deploy runs → requires build..merge recorded
#
# Config (optional): .claude/hooks/gate-ledger.config.json
# { "ledger_dir": "myproj-meta/blocks/ledger",
# "active_block_file": ".tron-active-block",
# "pr_pattern": "...", "merge_pattern": "...", "deploy_pattern": "..." }
# Installed per project at retrofit — inert until wired into .claude/settings.json.

input=$(cat)

if ! command -v jq >/dev/null 2>&1; then
echo "gate-ledger-gate: jq not found — gate skipped, ledger UNENFORCED this call. Install jq to arm this gate." >&2
exit 0
fi

cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
[ -n "$cmd" ] || exit 0

proj="${CLAUDE_PROJECT_DIR:-.}"
cfg="$proj/.claude/hooks/gate-ledger.config.json"

active_file="$proj/.tron-active-block"
ledger_dir=""
pr_pattern=''
merge_pattern='(^|[;&|[:space:]])git([[:space:]]+-[^[:space:]]+([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+merge|gh[[:space:]]+pr[[:space:]]+merge'
deploy_pattern='vercel[[:space:]][^;|&]*--prod|gh[[:space:]]+workflow[[:space:]]+run[[:space:]][^;|&]*deploy'
if [ -f "$cfg" ]; then
v=$(jq -r '.active_block_file // empty' "$cfg" 2>/dev/null); [ -n "$v" ] && active_file="$proj/$v"
v=$(jq -r '.ledger_dir // empty' "$cfg" 2>/dev/null); [ -n "$v" ] && ledger_dir="$proj/$v"
v=$(jq -r '.pr_pattern // empty' "$cfg" 2>/dev/null); [ -n "$v" ] && pr_pattern="$v"
v=$(jq -r '.merge_pattern // empty' "$cfg" 2>/dev/null); [ -n "$v" ] && merge_pattern="$v"
v=$(jq -r '.deploy_pattern // empty' "$cfg" 2>/dev/null); [ -n "$v" ] && deploy_pattern="$v"
fi

[ -f "$active_file" ] || exit 0

stage=""
[ -n "$pr_pattern" ] && printf '%s' "$cmd" | grep -qE "$pr_pattern" && stage="pr"
printf '%s' "$cmd" | grep -qE "$merge_pattern" && stage="merge"
printf '%s' "$cmd" | grep -qE "$deploy_pattern" && stage="deploy-verify"
[ -n "$stage" ] || exit 0

# Parse the active-block file: one entry per line, `<id>` or `<id> closed`.
# Any marker that is not exactly `closed` counts as ACTIVE — fail-closed.
active_ids=""; closed_ids=""; all_ids=""
while IFS= read -r line || [ -n "$line" ]; do
# shellcheck disable=SC2086
set -- $line
[ -n "$1" ] || continue
all_ids="$all_ids $1"
if [ "$2" = "closed" ]; then closed_ids="$closed_ids $1"; else active_ids="$active_ids $1"; fi
done < "$active_file"

in_list() { case " $2 " in *" $1 "*) return 0 ;; esac; return 1; }

token=$(printf '%s' "$cmd" | grep -oE 'TRON_BLOCK=[^[:space:];&|"'"'"']+' | head -1 | sed 's/^TRON_BLOCK=//')

block_id=""
if [ -n "$token" ]; then
if in_list "$token" "$active_ids"; then
block_id="$token"
elif in_list "$token" "$closed_ids"; then
exit 0 # closed-marked: the block's evidence landed, its gates are down
else
echo "gate-ledger-gate: REFUSED — TRON_BLOCK=$token names no entry in $active_file (entries:$all_ids). A mistyped block id never disarms the gate: use the exact id of an active entry, or drop the token where a single active entry governs." >&2
exit 2
fi
else
set -- $active_ids
case $# in
0) exit 0 ;; # zero active entries → no active block → pass
1) block_id="$1" ;;
*) echo "gate-ledger-gate: REFUSED — $active_file holds $# active blocks ($active_ids) and the command names none. Prefix the command with 'TRON_BLOCK=<id> ' to say which block this $stage action advances." >&2
exit 2 ;;
esac
fi

# Locate the ledger: configured dir first, then the kit's standard homes.
ledger=""
for d in "$ledger_dir" "$proj"/*-meta/blocks/ledger "$proj/blocks/ledger" "$proj/meta/blocks/ledger"; do
[ -n "$d" ] && [ -f "$d/$block_id.ledger.md" ] && { ledger="$d/$block_id.ledger.md"; break; }
done
if [ -z "$ledger" ]; then
echo "gate-ledger-gate: REFUSED — active block '$block_id' has no ledger file ({meta}/blocks/ledger/$block_id.ledger.md). No record, no advance (skill-gate-ledger.md): create the ledger and record the completed stages before a $stage action." >&2
exit 2
fi

case "$stage" in
pr) required="build challenge validation" ;;
merge) required="build challenge validation review ci" ;;
deploy-verify) required="build challenge validation review ci merge" ;;
esac

missing=""
for s in $required; do
grep -qE "^## $s — (DONE|n/a)" "$ledger" || missing="$missing $s"
done

if [ -n "$missing" ]; then
echo "gate-ledger-gate: REFUSED — block '$block_id' is missing ledger record(s) for:$missing (ledger: $ledger). No record, no advance (skill-gate-ledger.md): append '## {stage} — DONE' with when/by/evidence for each completed stage (or the block-start '## {stage} — n/a' declaration), then retry. Skipping a stage is a defect regardless of whether the work 'was actually done'." >&2
exit 2
fi

exit 0
Loading