From 183e44db62eaed8f40d5302e269dfd078b4aa519 Mon Sep 17 00:00:00 2001 From: christian-westerkam Date: Wed, 21 Oct 2026 00:30:36 +0200 Subject: [PATCH 1/4] Deny Claude Code pushes to main Co-Authored-By: Claude Opus 5.5 --- .claude/settings.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index d0e7b53..dfa13b0 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,7 +16,11 @@ "Read(~/.aws/**)", "Read(~/.gnupg/**)", "Edit(.claude/settings.json)", - "Edit(.claude/settings.local.json)" + "Edit(.claude/settings.local.json)", + "Bash(git push origin main:*)", + "Bash(git push origin HEAD:main:*)", + "Bash(git push * main:*)", + "Bash(git push *:main:*)" ], "blockReadsOutsideWorkingDirectories": true, "ask": [ -- 2.43.0 From 8b08c7b27a896a345b0f8670d99d6ea052996986 Mon Sep 17 00:00:00 2001 From: christian-westerkam Date: Wed, 21 Oct 2026 00:39:56 +0200 Subject: [PATCH 2/4] Block pushes and commits to main with git hooks pre-push rejects any push that would update refs/heads/main, however the command is spelled; pre-commit and pre-merge-commit refuse commits while main is checked out. npm install enables them via core.hooksPath. The README documents the three layers of protection for main. Co-Authored-By: Claude Opus 5.5 --- .githooks/pre-commit | 7 +++++ .githooks/pre-merge-commit | 2 ++ .githooks/pre-push | 11 ++++++++ README.md | 7 +++++ package.json | 3 ++- test/hooks.test.js | 55 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 84 insertions(+), 1 deletion(-) create mode 100755 .githooks/pre-commit create mode 100755 .githooks/pre-merge-commit create mode 100755 .githooks/pre-push create mode 100644 test/hooks.test.js diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..c771a84 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,7 @@ +#!/bin/sh +# Refuse commits (and, via pre-merge-commit, merge commits) while main is checked out. +if [ "$(git symbolic-ref --short -q HEAD)" = "main" ]; then + echo "$(basename "$0"): committing on main is not allowed; create a branch first." >&2 + exit 1 +fi +exit 0 diff --git a/.githooks/pre-merge-commit b/.githooks/pre-merge-commit new file mode 100755 index 0000000..e539569 --- /dev/null +++ b/.githooks/pre-merge-commit @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$(dirname "$0")/pre-commit" diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..91b085a --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,11 @@ +#!/bin/sh +# Reject any push that would create, update or delete main on the remote, however the +# command is spelled (bare `git push`, HEAD:main, +main, refs/heads/main, --all, --mirror). +# Only a human merges into main, through a PR in Gitea. +while read -r local_ref local_sha remote_ref remote_sha; do + if [ "$remote_ref" = "refs/heads/main" ]; then + echo "pre-push: pushing to main is not allowed; push a branch and open a PR instead." >&2 + exit 1 + fi +done +exit 0 diff --git a/README.md b/README.md index ce35e50..1118054 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ A tiny todo CLI that exists to try out [Claude Code](https://docs.claude.com/en/docs/claude-code) features. ```sh +npm install # once per clone: turns on the git hooks in .githooks/ npm test npm start -- add "try Claude Code" npm start -- done 1 @@ -18,6 +19,12 @@ npm start -- ls | `.claude/skills/changelog/` | A custom skill — run `/changelog` | | `.claude/agents/test-writer.md` | A custom subagent — "use the test-writer agent to…" | +### Protecting `main` +Changes reach `main` only through a PR that a human merges. Three layers, strongest first: +1. **Gitea branch protection** on `main` (set in the repo settings on code.westerkam.org): the only layer that can't be bypassed from a clone. +2. **Git hooks** in `.githooks/` (enabled by `npm install`): `pre-push` rejects any push that would update `main`, however it's spelled; `pre-commit` / `pre-merge-commit` refuse commits while `main` is checked out. `--no-verify` skips them. +3. **Claude Code deny rules** in `.claude/settings.json` for `git push … main`: a quick early stop, but they match command text, so they're easy to get around. + ## Things to try - *"There's a bug in how ids are assigned after removing a todo — find it and fix it with a test."* diff --git a/package.json b/package.json index 150c342..e4eb851 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "bin": { "todo": "./src/cli.js" }, "scripts": { "start": "node src/cli.js", - "test": "node --test" + "test": "node --test", + "prepare": "git config core.hooksPath .githooks" }, "engines": { "node": ">=20" }, "license": "MIT" diff --git a/test/hooks.test.js b/test/hooks.test.js new file mode 100644 index 0000000..c717f37 --- /dev/null +++ b/test/hooks.test.js @@ -0,0 +1,55 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HOOKS = fileURLToPath(new URL("../.githooks/", import.meta.url)); +const SHA = "1".repeat(40); +const ZERO = "0".repeat(40); + +function prePush(...lines) { + return spawnSync("sh", [join(HOOKS, "pre-push"), "origin", "git@example:repo.git"], { + input: lines.map((l) => l + "\n").join(""), + encoding: "utf8", + }); +} + +function repoOn(branch) { + const dir = mkdtempSync(join(tmpdir(), "todo-hooks-")); + spawnSync("git", ["init", "-q", "-b", branch, dir]); + return dir; +} + +for (const [label, line] of [ + ["an update", `refs/heads/fix/x ${SHA} refs/heads/main ${SHA}`], + ["a new ref", `refs/heads/fix/x ${SHA} refs/heads/main ${ZERO}`], + ["a deletion", `(delete) ${ZERO} refs/heads/main ${SHA}`], +]) { + test(`pre-push rejects ${label} of main`, () => { + const res = prePush(line); + assert.equal(res.status, 1); + assert.match(res.stderr, /pushing to main is not allowed/); + }); +} + +test("pre-push rejects --all style pushes that include main", () => { + const res = prePush(`refs/heads/fix/x ${SHA} refs/heads/fix/x ${SHA}`, `refs/heads/main ${SHA} refs/heads/main ${SHA}`); + assert.equal(res.status, 1); +}); + +test("pre-push allows other branches, including ones named like main", () => { + const res = prePush(`refs/heads/fix/x ${SHA} refs/heads/fix/x ${ZERO}`, `refs/heads/maintenance ${SHA} refs/heads/maintenance ${SHA}`); + assert.equal(res.status, 0); +}); + +for (const hook of ["pre-commit", "pre-merge-commit"]) { + test(`${hook} rejects main and allows other branches`, () => { + const onMain = spawnSync("sh", [join(HOOKS, hook)], { cwd: repoOn("main"), encoding: "utf8" }); + assert.equal(onMain.status, 1); + assert.match(onMain.stderr, /committing on main is not allowed/); + assert.equal(spawnSync("sh", [join(HOOKS, hook)], { cwd: repoOn("fix/x") }).status, 0); + }); +} -- 2.43.0 From 24541f0602c040380110d68efa495b1129896890 Mon Sep 17 00:00:00 2001 From: christian-westerkam Date: Wed, 21 Oct 2026 00:44:30 +0200 Subject: [PATCH 3/4] Install hooks by copying into .git/hooks instead of core.hooksPath Linking core.hooksPath to the tracked, sandbox-writable .githooks/ let a sandboxed command rewrite a hook that git commit/push then run unsandboxed. Copies in .git/hooks can't be changed from the sandbox. This also keeps existing .git/hooks scripts working and lets npm install succeed outside a git checkout. pre-merge-commit now names itself in errors; hook tests clean up, ignore GIT_* env and cover the installer; docs state what each layer does and doesn't stop. Co-Authored-By: Claude Opus 5.5 --- .githooks/pre-commit | 5 +++- .githooks/pre-merge-commit | 2 +- CLAUDE.md | 3 +- README.md | 6 ++-- package.json | 2 +- scripts/install-hooks.sh | 17 ++++++++++++ test/hooks.test.js | 57 ++++++++++++++++++++++++++++---------- 7 files changed, 72 insertions(+), 20 deletions(-) create mode 100755 scripts/install-hooks.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit index c771a84..cf89b42 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,7 +1,10 @@ #!/bin/sh # Refuse commits (and, via pre-merge-commit, merge commits) while main is checked out. +# Cherry-pick, revert and fast-forward merges don't run these hooks; pre-push still stops +# their result from reaching the remote. +name=${1:-pre-commit} if [ "$(git symbolic-ref --short -q HEAD)" = "main" ]; then - echo "$(basename "$0"): committing on main is not allowed; create a branch first." >&2 + echo "$name: committing on main is not allowed; create a branch first." >&2 exit 1 fi exit 0 diff --git a/.githooks/pre-merge-commit b/.githooks/pre-merge-commit index e539569..e0d9152 100755 --- a/.githooks/pre-merge-commit +++ b/.githooks/pre-merge-commit @@ -1,2 +1,2 @@ #!/bin/sh -exec "$(dirname "$0")/pre-commit" +exec sh "$(dirname "$0")/pre-commit" pre-merge-commit diff --git a/CLAUDE.md b/CLAUDE.md index 2117ff2..88e2965 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,8 @@ A tiny zero-dependency Node.js todo CLI, used as a sandbox for trying Claude Code features. ## Commands -- `npm test` — run the test suite (built-in `node:test`, no install needed) +- `npm install` — once per clone (no dependencies): installs the git hooks that block commits and pushes to `main`. If it fails in the sandbox, ask the human to run it. +- `npm test` — run the test suite (built-in `node:test`) - `npm start -- add "something"` / `npm start -- ls` — run the CLI (data in `todos.json`, override with `TODO_FILE`) ## Conventions diff --git a/README.md b/README.md index 1118054..5b21e72 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A tiny todo CLI that exists to try out [Claude Code](https://docs.claude.com/en/docs/claude-code) features. ```sh -npm install # once per clone: turns on the git hooks in .githooks/ +npm install # once per clone, and after changing .githooks/: installs the git hooks npm test npm start -- add "try Claude Code" npm start -- done 1 @@ -22,9 +22,11 @@ npm start -- ls ### Protecting `main` Changes reach `main` only through a PR that a human merges. Three layers, strongest first: 1. **Gitea branch protection** on `main` (set in the repo settings on code.westerkam.org): the only layer that can't be bypassed from a clone. -2. **Git hooks** in `.githooks/` (enabled by `npm install`): `pre-push` rejects any push that would update `main`, however it's spelled; `pre-commit` / `pre-merge-commit` refuse commits while `main` is checked out. `--no-verify` skips them. +2. **Git hooks** from `.githooks/`, copied into `.git/hooks` by `npm install` (`scripts/install-hooks.sh`). `pre-push` rejects any push that would update `main`, however it's spelled. `pre-commit` / `pre-merge-commit` refuse ordinary and merge commits while `main` is checked out; cherry-pick, revert and fast-forwards skip them, but pre-push still stops the result leaving the machine. The hooks are copied, not linked with `core.hooksPath`, because Claude Code's sandbox can't write inside `.git/`: a sandboxed command can't rewrite the installed hooks, which matters because `git commit` / `git push` run outside the sandbox. Anyone can still skip them with `--no-verify`. 3. **Claude Code deny rules** in `.claude/settings.json` for `git push … main`: a quick early stop, but they match command text, so they're easy to get around. +Layers 2 and 3 live on your machine and can be bypassed by whoever runs the commands; only layer 1 can't. + ## Things to try - *"There's a bug in how ids are assigned after removing a todo — find it and fix it with a test."* diff --git a/package.json b/package.json index e4eb851..63ec3fc 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "node src/cli.js", "test": "node --test", - "prepare": "git config core.hooksPath .githooks" + "prepare": "sh scripts/install-hooks.sh" }, "engines": { "node": ">=20" }, "license": "MIT" diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..dd2a768 --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Copy the hooks in .githooks/ into git's own hooks directory. They are copied rather than +# enabled with core.hooksPath on purpose: Claude Code's sandbox can't write inside .git/, so +# a sandboxed command can't rewrite the installed hooks, even though git commit and git push +# (and so their hooks) run outside the sandbox. Re-run after changing anything in .githooks/. +set -e +cd "$(dirname "$0")/.." +if ! hooks=$(git rev-parse --git-path hooks 2>/dev/null); then + echo "install-hooks: not a git checkout; skipping." >&2 + exit 0 +fi +mkdir -p "$hooks" +for hook in .githooks/*; do + cp "$hook" "$hooks/" + chmod +x "$hooks/$(basename "$hook")" +done +echo "install-hooks: installed $(ls .githooks | tr '\n' ' ')into $hooks" diff --git a/test/hooks.test.js b/test/hooks.test.js index c717f37..f9feea9 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -1,28 +1,37 @@ -import { test } from "node:test"; +import { test, after } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, rmSync, cpSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -const HOOKS = fileURLToPath(new URL("../.githooks/", import.meta.url)); +const ROOT = fileURLToPath(new URL("../", import.meta.url)); +const HOOKS = join(ROOT, ".githooks"); const SHA = "1".repeat(40); const ZERO = "0".repeat(40); -function prePush(...lines) { - return spawnSync("sh", [join(HOOKS, "pre-push"), "origin", "git@example:repo.git"], { - input: lines.map((l) => l + "\n").join(""), - encoding: "utf8", - }); -} +// Run git and the hooks without inheriting GIT_DIR & co, e.g. when tests run from inside a hook. +const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))); +const run = (cmd, args, opts = {}) => spawnSync(cmd, args, { env, encoding: "utf8", ...opts }); + +const dirs = []; +after(() => dirs.forEach((d) => rmSync(d, { recursive: true, force: true }))); function repoOn(branch) { const dir = mkdtempSync(join(tmpdir(), "todo-hooks-")); - spawnSync("git", ["init", "-q", "-b", branch, dir]); + dirs.push(dir); + const init = run("git", ["init", "-q", "-b", branch, dir]); + assert.equal(init.status, 0, `git init failed: ${init.stderr}`); return dir; } +function prePush(...lines) { + return run("sh", [join(HOOKS, "pre-push"), "origin", "git@example:repo.git"], { + input: lines.map((l) => l + "\n").join(""), + }); +} + for (const [label, line] of [ ["an update", `refs/heads/fix/x ${SHA} refs/heads/main ${SHA}`], ["a new ref", `refs/heads/fix/x ${SHA} refs/heads/main ${ZERO}`], @@ -46,10 +55,30 @@ test("pre-push allows other branches, including ones named like main", () => { }); for (const hook of ["pre-commit", "pre-merge-commit"]) { - test(`${hook} rejects main and allows other branches`, () => { - const onMain = spawnSync("sh", [join(HOOKS, hook)], { cwd: repoOn("main"), encoding: "utf8" }); + test(`${hook} rejects main, names itself, and allows other branches`, () => { + const onMain = run("sh", [join(HOOKS, hook)], { cwd: repoOn("main") }); assert.equal(onMain.status, 1); - assert.match(onMain.stderr, /committing on main is not allowed/); - assert.equal(spawnSync("sh", [join(HOOKS, hook)], { cwd: repoOn("fix/x") }).status, 0); + assert.match(onMain.stderr, new RegExp(`^${hook}: committing on main is not allowed`)); + assert.equal(run("sh", [join(HOOKS, hook)], { cwd: repoOn("fix/x") }).status, 0); }); } + +test("install-hooks copies every hook into .git/hooks as an executable", () => { + const repo = repoOn("fix/x"); + cpSync(join(ROOT, "scripts"), join(repo, "scripts"), { recursive: true }); + cpSync(HOOKS, join(repo, ".githooks"), { recursive: true }); + const res = run("sh", [join(repo, "scripts", "install-hooks.sh")]); + assert.equal(res.status, 0, res.stderr); + for (const hook of ["pre-push", "pre-commit", "pre-merge-commit"]) { + assert.ok(statSync(join(repo, ".git", "hooks", hook)).mode & 0o100, `${hook} is not executable`); + } +}); + +test("install-hooks skips cleanly outside a git checkout", () => { + const dir = mkdtempSync(join(tmpdir(), "todo-hooks-")); + dirs.push(dir); + cpSync(join(ROOT, "scripts"), join(dir, "scripts"), { recursive: true }); + const res = run("sh", [join(dir, "scripts", "install-hooks.sh")], { env: { ...env, GIT_CEILING_DIRECTORIES: tmpdir() } }); + assert.equal(res.status, 0); + assert.match(res.stderr, /not a git checkout; skipping/); +}); -- 2.43.0 From 480ed6a8b20ef25cc8ba62867eccb8a91e6dd41b Mon Sep 17 00:00:00 2001 From: christian-westerkam Date: Wed, 21 Oct 2026 00:55:32 +0200 Subject: [PATCH 4/4] Install hooks into the repo's own hooks dir, ignoring core.hooksPath Co-Authored-By: Claude Opus 5.5 --- scripts/install-hooks.sh | 5 ++++- test/hooks.test.js | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh index dd2a768..4aad880 100755 --- a/scripts/install-hooks.sh +++ b/scripts/install-hooks.sh @@ -5,10 +5,13 @@ # (and so their hooks) run outside the sandbox. Re-run after changing anything in .githooks/. set -e cd "$(dirname "$0")/.." -if ! hooks=$(git rev-parse --git-path hooks 2>/dev/null); then +# Use the repo's own hooks directory, not `git rev-parse --git-path hooks`: that follows a +# (possibly global) core.hooksPath and would install these hooks for every repo. +if ! common=$(git rev-parse --git-common-dir 2>/dev/null); then echo "install-hooks: not a git checkout; skipping." >&2 exit 0 fi +hooks="$common/hooks" mkdir -p "$hooks" for hook in .githooks/*; do cp "$hook" "$hooks/" diff --git a/test/hooks.test.js b/test/hooks.test.js index f9feea9..e49d8b3 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -1,7 +1,7 @@ import { test, after } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, cpSync, statSync } from "node:fs"; +import { mkdtempSync, rmSync, cpSync, statSync, writeFileSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -74,6 +74,20 @@ test("install-hooks copies every hook into .git/hooks as an executable", () => { } }); +test("install-hooks ignores a global core.hooksPath", () => { + const repo = repoOn("fix/x"); + const elsewhere = mkdtempSync(join(tmpdir(), "todo-hooks-")); + dirs.push(elsewhere); + const globalConfig = join(elsewhere, "gitconfig"); + writeFileSync(globalConfig, `[core]\n\thooksPath = ${elsewhere}\n`); + cpSync(join(ROOT, "scripts"), join(repo, "scripts"), { recursive: true }); + cpSync(HOOKS, join(repo, ".githooks"), { recursive: true }); + const res = run("sh", [join(repo, "scripts", "install-hooks.sh")], { env: { ...env, GIT_CONFIG_GLOBAL: globalConfig } }); + assert.equal(res.status, 0, res.stderr); + assert.ok(existsSync(join(repo, ".git", "hooks", "pre-push"))); + assert.equal(existsSync(join(elsewhere, "pre-push")), false); +}); + test("install-hooks skips cleanly outside a git checkout", () => { const dir = mkdtempSync(join(tmpdir(), "todo-hooks-")); dirs.push(dir); -- 2.43.0