Author SHA1 Message Date
christian 2088568b5e Merge pull request 'Protect main: git hooks + Claude Code deny rules' (#3) from chore/deny-push-main into main
Reviewed-on: #3
2026-10-21 00:58:30 +02:00
christianandClaude Opus 5.5 480ed6a8b2 Install hooks into the repo's own hooks dir, ignoring core.hooksPath
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:55:32 +02:00
christianandClaude Opus 5.5 24541f0602 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 <noreply@anthropic.com>
2026-10-21 00:44:30 +02:00
christianandClaude Opus 5.5 8b08c7b27a 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 <noreply@anthropic.com>
2026-10-21 00:39:56 +02:00
christianandClaude Opus 5.5 183e44db62 Deny Claude Code pushes to main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:30:36 +02:00
9 changed files with 159 additions and 3 deletions

No files matched your search

+5 -1
View File
@@ -16,7 +16,11 @@
"Read(~/.aws/**)", "Read(~/.aws/**)",
"Read(~/.gnupg/**)", "Read(~/.gnupg/**)",
"Edit(.claude/settings.json)", "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, "blockReadsOutsideWorkingDirectories": true,
"ask": [ "ask": [
+10
View File
@@ -0,0 +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 "$name: committing on main is not allowed; create a branch first." >&2
exit 1
fi
exit 0
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
exec sh "$(dirname "$0")/pre-commit" pre-merge-commit
+11
View File
@@ -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
+2 -1
View File
@@ -3,7 +3,8 @@
A tiny zero-dependency Node.js todo CLI, used as a sandbox for trying Claude Code features. A tiny zero-dependency Node.js todo CLI, used as a sandbox for trying Claude Code features.
## Commands ## 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`) - `npm start -- add "something"` / `npm start -- ls` — run the CLI (data in `todos.json`, override with `TODO_FILE`)
## Conventions ## Conventions
+9
View File
@@ -3,6 +3,7 @@
A tiny todo CLI that exists to try out [Claude Code](https://docs.claude.com/en/docs/claude-code) features. A tiny todo CLI that exists to try out [Claude Code](https://docs.claude.com/en/docs/claude-code) features.
```sh ```sh
npm install # once per clone, and after changing .githooks/: installs the git hooks
npm test npm test
npm start -- add "try Claude Code" npm start -- add "try Claude Code"
npm start -- done 1 npm start -- done 1
@@ -18,6 +19,14 @@ npm start -- ls
| `.claude/skills/changelog/` | A custom skill — run `/changelog` | | `.claude/skills/changelog/` | A custom skill — run `/changelog` |
| `.claude/agents/test-writer.md` | A custom subagent — "use the test-writer agent to…" | | `.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** 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 ## Things to try
- *"There's a bug in how ids are assigned after removing a todo — find it and fix it with a test."* - *"There's a bug in how ids are assigned after removing a todo — find it and fix it with a test."*
+2 -1
View File
@@ -6,7 +6,8 @@
"bin": { "todo": "./src/cli.js" }, "bin": { "todo": "./src/cli.js" },
"scripts": { "scripts": {
"start": "node src/cli.js", "start": "node src/cli.js",
"test": "node --test" "test": "node --test",
"prepare": "sh scripts/install-hooks.sh"
}, },
"engines": { "node": ">=20" }, "engines": { "node": ">=20" },
"license": "MIT" "license": "MIT"
+20
View File
@@ -0,0 +1,20 @@
#!/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")/.."
# 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/"
chmod +x "$hooks/$(basename "$hook")"
done
echo "install-hooks: installed $(ls .githooks | tr '\n' ' ')into $hooks"
+98
View File
@@ -0,0 +1,98 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
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";
const ROOT = fileURLToPath(new URL("../", import.meta.url));
const HOOKS = join(ROOT, ".githooks");
const SHA = "1".repeat(40);
const ZERO = "0".repeat(40);
// 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-"));
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}`],
["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, 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, 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 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);
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/);
});