Skip to content
Closed
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
62 changes: 42 additions & 20 deletions .claude/hooks/block-dangerous-git.sh
Original file line number Diff line number Diff line change
@@ -1,25 +1,47 @@
#!/bin/bash
# Block genuinely destructive git operations.
#
# Design notes:
# - Matches the *command*, not the raw tool input. The previous version
# grepped the whole string, so prose in a heredoc that merely named a
# command was blocked (including this file's own replacement), while
# `git push` with two spaces or `git -C . push` slipped straight through.
# - Plain `git push` is not destructive and is no longer blocked. The
# repository's own pre-push hook is the policy-backed control; this hook
# only refuses operations that destroy history or uncommitted work.
# - Degrades safely when jq is unavailable.

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')

DANGEROUS_PATTERNS=(
"git push"
"git reset --hard"
"git clean -fd"
"git clean -f"
"git branch -D"
"git checkout \."
"git restore \."
"push --force"
"reset --hard"
)

for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qE "$pattern"; then
echo "BLOCKED: '$COMMAND' matches dangerous pattern '$pattern'. The user has prevented you from doing this." >&2
exit 2
fi
done

if command -v jq >/dev/null 2>&1; then
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')
else
COMMAND=$(printf '%s' "$INPUT" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p')
fi

[ -z "$COMMAND" ] && exit 0

# Drop heredoc bodies, so documentation or test data that names a command is
# not mistaken for an invocation of it. A heredoc body spans lines, so this
# keeps the line that opens it and discards everything after.
SCRUBBED=$(printf '%s' "$COMMAND" | awk '/<</ { print; exit } { print }')

# Only inspect commands that actually invoke git.
printf '%s' "$SCRUBBED" | grep -qE '(^|[;&|[:space:]])git([[:space:]]|$)' || exit 0

deny() {
echo "BLOCKED: '$COMMAND' looks like $1. The user has prevented you from doing this." >&2
exit 2
}

# --force rewrites remote history unconditionally; --force-with-lease is guarded.
if printf '%s' "$SCRUBBED" | grep -qE 'push([[:space:]]|$).*(--force([[:space:]]|$)|[[:space:]]-f([[:space:]]|$))'; then
printf '%s' "$SCRUBBED" | grep -q -- '--force-with-lease' || deny "an unguarded force-push"
fi

printf '%s' "$SCRUBBED" | grep -qE 'reset[[:space:]]+--hard' && deny "a hard reset (discards working-tree changes)"
printf '%s' "$SCRUBBED" | grep -qE 'clean[[:space:]]+-[a-zA-Z]*f' && deny "a forced clean (deletes untracked files)"
printf '%s' "$SCRUBBED" | grep -qE 'branch[[:space:]]+-D([[:space:]]|$)' && deny "a forced branch delete"
printf '%s' "$SCRUBBED" | grep -qE '(checkout|restore)[[:space:]]+(--[[:space:]]+)?\.([[:space:]]|$)' && deny "a bulk discard of working-tree changes"

exit 0
4 changes: 2 additions & 2 deletions .github/workflows/cargo-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
draft: ${{ steps.release_type.outputs.draft }}
changelog: ${{ steps.changelog.outputs.changelog }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0

Expand Down Expand Up @@ -84,7 +84,7 @@ jobs:
asset_suffix: macos-arm64

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- name: Get binary name and version
shell: bash
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Rust
uses: actions-rs/toolchain@v1.0.7
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/native-git-verification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ jobs:
native-git-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Run native git Docker verification
run: ./scripts/verify_native_git_e2e.sh
4 changes: 0 additions & 4 deletions docs/src/content/docs/project/roadmap.mdx

This file was deleted.

12 changes: 10 additions & 2 deletions src/cli/commands/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,21 @@ jobs:
lint-commits:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
# Full history: linting a PR needs both ends of the commit range.
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
- name: Cache the Committy build
uses: Swatinem/rust-cache@v2
with:
key: committy-cli
- name: Install Committy
run: cargo install committy --locked
- name: Lint pull request commits
run: committy --non-interactive lint --from-ref "${{ github.event.pull_request.base.sha }}" --to-ref "${{ github.sha }}"
env:
COMMITTY_NONINTERACTIVE: "1"
run: committy lint --from-ref "${{ github.event.pull_request.base.sha }}" --to-ref "${{ github.sha }}"
"#;

#[derive(Debug, StructOpt)]
Expand Down
20 changes: 17 additions & 3 deletions tests/hooks_cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,23 @@ fn hooks_install_writes_executable_hooks_and_ci_workflow() {
assert!(fs::read_to_string(&pre_push)
.unwrap()
.contains("hooks run pre-push"));
assert!(fs::read_to_string(workflow)
.unwrap()
.contains("committy --non-interactive lint"));
// The workflow must lint the PR range, and must do so without prompting.
// Non-interactive mode comes from COMMITTY_NONINTERACTIVE rather than the
// flag, which keeps the command immune to flag-placement mistakes.
let workflow_body = fs::read_to_string(workflow).unwrap();
assert!(
workflow_body.contains("committy lint --from-ref"),
"workflow must lint the pull request commit range"
);
assert!(
workflow_body.contains("COMMITTY_NONINTERACTIVE")
|| workflow_body.contains("--non-interactive"),
"workflow must run non-interactively"
);
assert!(
workflow_body.contains("fetch-depth: 0"),
"linting a range needs full history"
);
assert_ne!(
fs::metadata(commit_msg).unwrap().permissions().mode() & 0o111,
0
Expand Down
Loading