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
645 changes: 645 additions & 0 deletions .claude/hooks/push-guard.sh

Large diffs are not rendered by default.

360 changes: 360 additions & 0 deletions .claude/hooks/push-guard.test.sh

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|PowerShell",
"hooks": [
{
"type": "command",
"command": "sh \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/push-guard.sh",
"timeout": 20
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
Expand Down
4 changes: 4 additions & 0 deletions .claude/skills/check/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ docker compose -f docker/compose.yaml run --rm --no-deps mcp-tests
docker compose -f docker/compose.yaml --profile mcp --profile mcp-fetch down
```

The `python-tests` step also runs both hooks' tests (`tests/python/test_repo_rules_hook.py` and
`tests/python/test_push_guard_hook.py`, which runs `.claude/hooks/push-guard.test.sh`); the containers
mount `/tmp` noexec, so the guard's killed-git case is skipped there and runs only on a host.

This runs the PHP steps on compose's default image, PHP 8.5. CI runs them on 8.2, 8.3, 8.4 and 8.5
and first checks that the image's PHP minor is the one the matrix expects; to reproduce another branch,
set `BRIDGE_PHP_IMAGE` to its pinned image from `.github/workflows/tests.yml` and rerun the PHP and
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ and `docs/testing.md` are the guides. Everything builds, tests and downloads ins
| `examples/` | Local autoloader, the CLI example, the FrankenPHP route and Caddyfile. |
| `scripts/` | Model fetcher, benchmark, NER measurement, MCP stdio launchers, the LLM evaluation runner. |
| `tests/php/` | `run.php` (offline contract checks), `integration.php` (the client against the real worker and broken peers over HTTP), `model.php` (ONNX smoke). |
| `tests/python/` | The worker's unittest suite, including the workflows' runner routing and the post-edit hook. |
| `tests/python/` | The worker's unittest suite, including the workflows' runners and both hooks (the pre-call push guard's own cases are in `.claude/hooks/push-guard.test.sh`). |
| `tests/mcp/` | The MCP server's suite, offline against hash-pinned wheels; its fixtures and LLM evaluations. |
| `tests/*.py` | Cross-boundary tests: `integration.py` (through the FrankenPHP worker), `model_integration.py`, `mcp_model_probe.py`, and `hostile_server.py`, a broken peer. |
| `docker/compose.yaml` | Every service, profile and limit. |
Expand Down Expand Up @@ -77,7 +77,9 @@ and update both together. Path-scoped detail is in `.claude/rules/`.
independent review ran and its blocking findings are fixed, a security read of the diff has nothing
open, and the head being merged is the commit all of that ran on. Merge `main` in only for a conflict,
or once before those final checks and review. What could not be tested is said in the PR and waits for
the owner. Never `--auto` or `--admin`, never push to `main`: hand over the command.
the owner. Never `--auto` or `--admin`, never push to `main`: hand over the command (the pre-call
hook `.claude/hooks/push-guard.sh` refuses these in the spellings it can read and an unreadable command
that mentions them; script files and other programs are not read: the ruleset on `main` is the guard).
- **Reviews are text, commits are changes.** A finding is blocking when it is a defect this branch brings
in, a missing test for what it changes, a broken house rule or a security hole; fix it with the smallest
change. Everything else (problems already on `main`, improvements, style) becomes an issue linked from
Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
Claude-specific: path-scoped rules are in `.claude/rules/` (the PHP client, the worker, the MCP server),
`/check` runs what CI runs, and the post-edit hook reports a PHP file without `declare(strict_types=1)` or
with tabs, and a worker module that imports a model dependency at top level: fix it before moving on.
The pre-call hook (`push-guard.sh`) reads every Bash and PowerShell command and refuses a push that
would land on `main` or a tag in the spellings it can read (a bare `git push` on `main`, `HEAD`,
aliases, shells within shells), a force push, `git tag`, `gh release`, `gh pr merge --auto` or
`--admin`, and writes through `gh api` to releases or refs; a command it cannot read that mentions one
of those is refused too. It does not open script files or other programs, so it is a second layer:
the ruleset on `main` is the guard. Those are the owner's steps: hand over the command, do not look
for another spelling.
Permissions in `.claude/settings.json` allow the exact compose commands CI runs and read-only git and gh,
ask before `gh workflow run`, `gh pr create`, `gh pr merge` and the model `fetcher`, and deny pushes to
`main`, force pushes and reading `.env`. Your own notes go in `CLAUDE.local.md` (ignored).
Expand Down
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ PHP branch support was verified locally on 2026-09-17 on PHP 8.2.33, 8.3.33, 8.4

| Layer | Scope and counting |
| --- | --- |
| Python worker | 127 tests: lifecycle, validation and HTTP, process crashes, cancellation, timeouts, retention, allocation/exit races, redaction rules including IPv6, lower-case IBANs and email addresses in any script and their speed on hostile input, NER aggregation, name joining, the second title-cased reading, the uncased model's reading of the whole text and the merge driven by stubs, the fetcher's pins and the model directories an ONNX worker checks for before it starts, batched cross-encoder scoring, the NER measurement's sample and scoring, the workflows' runners, progress bounds, and the model-name table every result reports from |
| Python worker | 139 tests: lifecycle, validation and HTTP, process crashes, cancellation, timeouts, retention, allocation/exit races, redaction rules including IPv6, lower-case IBANs and email addresses in any script and their speed on hostile input, NER aggregation, name joining, the second title-cased reading, the uncased model's reading of the whole text and the merge driven by stubs, the fetcher's pins and the model directories an ONNX worker checks for before it starts, batched cross-encoder scoring, the NER measurement's sample and scoring, the workflows' runners, both Claude Code hooks (the post-edit rules, and the pre-call push guard through its own cases), progress bounds, and the model-name table every result reports from |
| MCP server | 119 tests, offline with hash-pinned wheels: the protocol client against an in-process worker, every tool through the SDK's in-memory client with test tasks enabled, the entry point as a subprocess, result validators on synthetic data, the index builder (chunking, the format and each corruption it refuses, building against the worker, caching, its command line) `bridge_search` (the model gate, path confinement, reloads, rerank on and off), the reachability of every answer in the LLM evaluation, that a default search sees only part of its corpus and that its two-step question takes two steps, that the demo worker reaches none of the semantic evaluation's answers, that a tool waits out a full worker, before each of a search's jobs and within one budget, and says so when it stays full, the evaluation runner's command, isolation checks, scoring and per-backend launcher and index check without starting `claude`, a check that the server's model names match the worker's, that every path parameter says where it resolves and every position in a result says what it counts, and a check that every timing claim in a tool description is a measured median |
| PHP contracts + HTTP | 134 checks per PHP version; this **includes** the 101 contract checks, not 134 + 101 |
| FrankenPHP | 12 jobs submitted by six concurrent test threads through a reused PHP worker, a 512-document `top_k` request, embedding and redaction round trips, and invalid-input, missing-job and multibyte boundary checks |
Expand Down
40 changes: 40 additions & 0 deletions tests/python/test_push_guard_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""The pre-call hook in .claude/hooks/push-guard.sh refuses, before a Bash or PowerShell command runs, a push that
would land on main or a tag, a force push, git tag, gh release, gh pr merge --auto or --admin, and a write through
gh api to releases or refs, in every spelling it can read; what it cannot read and mentions one of those, it refuses
too. Its cases live beside it in push-guard.test.sh, as in the owner's other repositories; this runs them, and holds
the hook's wiring in settings.json in place.
"""

import json
import shutil
import subprocess
import unittest
from pathlib import Path

REPO = Path(__file__).resolve().parents[2]
HOOK = REPO / ".claude" / "hooks" / "push-guard.sh"
CASES = REPO / ".claude" / "hooks" / "push-guard.test.sh"
SETTINGS = REPO / ".claude" / "settings.json"


class PushGuardHookTests(unittest.TestCase):
def test_the_hook_and_its_cases_exist_and_are_lf(self):
for path in (HOOK, CASES):
with self.subTest(file=path.name):
self.assertTrue(path.is_file())
self.assertNotIn(b"\r", path.read_bytes())

def test_settings_run_the_hook_before_every_bash_and_powershell_call(self):
hooks = json.loads(SETTINGS.read_text(encoding="utf-8"))["hooks"]["PreToolUse"]
commands = [hook["command"] for entry in hooks if entry["matcher"] == "Bash|PowerShell" for hook in entry["hooks"]]
self.assertEqual(commands, ['sh "$CLAUDE_PROJECT_DIR"/.claude/hooks/push-guard.sh'])

def test_the_hooks_own_cases_pass(self):
# Several hundred cases, each a sh and a few git processes: seconds on Linux, minutes under Git Bash.
result = subprocess.run([shutil.which("sh") or "sh", str(CASES)], capture_output=True, text=True, timeout=900)
self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
self.assertRegex(result.stdout, r"push-guard\.test: ok \(\d+ cases\)")


if __name__ == "__main__":
unittest.main()
Loading