# JA4proxy Agent Protocol This document defines the mandatory operational standards for AI agents working on the JA4proxy project. Adherence to these rules ensures architectural consistency, security, and a high-quality handoff for human developers. --- ## ๐Ÿ“‹ Mandatory Planning & Concurrency Protocol โ€” Read First **When asked to perform any work, the agent MUST follow this sequence to prevent overlapping work with other parallel agents.** ### Step 1 โ€” Claim the Task (Remote-Branch Lease) Before writing any code or plans, ensure no other agent already owns the task. **The remote branch is the lock โ€” creating it is the atomic claim.** 1. Sync: `git checkout main && git pull origin main --rebase`. 2. Check `docs/phases/manifest.yaml`. If the phase is `IN_PROGRESS`, **STOP** โ€” another agent owns it. 3. Check for an existing lease: `git ls-remote --heads origin 'phase-XX*'`. If any branch matches, **STOP** โ€” someone is already on it. 4. Claim it: create `phase-XX-` and push it immediately (an empty commit, `git commit --allow-empty`, is fine). The push **is** the claim โ€” if two agents race, git rejects the loser's push, serialising them for free with no CI cost. 5. Mark ownership in your first real commit by setting that phase's status to `IN_PROGRESS` in the manifest (human/agent visibility). Do **not** open a separate status-only PR โ€” that burns a full CI run to flip one line. ### Step 2 โ€” Write the Plan 1. Create the phase document at `docs/phases/PHASE_XX.md` using the standard template: Goal, Scope, Implementation plan, Test strategy, Acceptance criteria, Out of scope. 2. Present the plan to the user with a brief summary: *"Here is the plan โ€” please review before I begin."* ### Step 3 โ€” Wait for explicit approval Do **not** proceed until the user gives a clear go-ahead (e.g., "looks good", "proceed", "yes"). If changes are requested, update `PHASE_XX.md` and re-present. ### Step 4 โ€” Implement Only after written approval: create your feature branch, write code, write tests, and follow the Phase Close-Out Checklist. --- ## ๐Ÿณ Container-Strict Execution (No Host Python) **Virtual environments on the host are strictly forbidden.** The host runs Python 3.10, but production targets Python 3.15.* (the test/tools images are pinned to `python:3.15`). Running tests, linters, or scripts on the host leads to severe version-skew bugs and polluted state. - **Rule 1:** NEVER create a virtual environment (e.g., `uv venv`, `python -m venv`) on the host. - **Rule 2:** NEVER run `pip install`, `pytest`, `ruff`, or `mypy` directly on the host machine. - **Rule 3:** Run Python through the **`make` targets** โ€” they execute inside the pinned `ja4proxy-tools` image (`Dockerfile.tools`, Python 3.15) via `docker run`, building it on demand. This is the canonical, CI-identical path. - *Wrong:* `python3 -m pytest tests/` - *Right:* `make test` (Go native + Python in-container) ยท `make test-unit` ยท `make test-chaos` ยท `make lint` ยท `make sync` - Need an ad-hoc command? `docker run --rm -v "$PWD":/src -w /src ja4proxy-tools pytest ` (this is what `$(TOOLS_RUN)` expands to). - **Rule 4:** The full multi-service integration stack (`deploy/docker/docker-compose.test.yml`) is for integration/chaos tests that need Redis + the Go proxy; drive it with `docker compose exec โ€ฆ`, not for unit/lint runs. - **Note on Go:** Go tests run **natively** (`go test ./...`) โ€” Go has no venv fragility, so the container rule is Python-only. --- ## ๐Ÿ› ๏ธ Tool Usage & Communication - **Bash Tool:** Under **opencode**, omit the `description` field โ€” it currently triggers a validation error there. Under **Claude Code** the `description` field is supported and *should* be used (it surfaces intent in the permission prompt). - **High-Signal Output:** Adopt a Senior Engineer persona. Be concise, direct, and technical. Avoid conversational filler. - **Efficiency:** Parallelize independent searches (`Grep`, `Glob`) and file reads. --- ## Roadmap & Task Management The project uses a **Manifest-Driven Roadmap**. - **Single source of truth:** `docs/phases/manifest.yaml`. - **Validation:** Run `make lint-phases` (which wraps Docker execution) to catch broken action_plan paths and stale status values. Must exit 0 before close-out. ### Phase Documentation Rules **Rule 1:** Phase number lives in the FILENAME only (e.g., `# Core Features`, not `# Phase 22: Core Features`). **Rule 2:** Status lives in `docs/phases/manifest.yaml` only. Do NOT include a `Status:` line in phase doc files. **Rule 3:** To rename/renumber, rename the file, update the `action_plan:` path in the manifest, and run `make lint-phases`. **Rule 4:** When a phase reaches a terminal status (`COMPLETE`, `CANCELLED`), move it to the corresponding subfolder (`docs/phases/complete/`) and update the manifest path in the exact same commit. ### Phase Close-Out โ€” use `/close-phase` (mandatory) Use the `/close-phase` slash command. If running outside Claude Code, run `bash scripts/close-phase.sh` and follow the manual steps: 1. **Pre-flight:** Feature branch active, working tree clean, documentation fragments created, manifest has `status: COMPLETE`. 2. **Local gate:** `bash scripts/close-phase.sh`. **Must exit 0.** (Ensure this script wraps containerized commands). 3. **Push & PR:** `gh pr create --base main`. (`main` is branch-protected; direct pushes are rejected). 4. **CI Checks:** Meta-Validation, Full Lint, Full Test, Security Scan must pass. 5. **Merge:** `gh pr merge --auto --squash --delete-branch`. **CHANGELOG โ€” use news fragments (not direct edits):** `CHANGELOG.md` is the worst parallel-conflict magnet โ€” every phase prepends under the same `## [Unreleased]` heading. So **do NOT edit `CHANGELOG.md` directly**: 1. Drop a uniquely named fragment in `docs/fragments/phase-XX-.md` containing your `- ...` bullet(s). Unique filenames never collide. See `docs/fragments/README.md`. 2. It's folded into `CHANGELOG.md` at release by `make changelog-assemble` (a serialized step). `tests/unit/test_changelog_fragments.py` (in the `make test` gate) keeps fragments well-formed. `README.md` / `docs/reference/REDIS_SCHEMA.md` are *not* fragmented โ€” edit them directly in your own section; conflicts there are rare and resolve trivially. The roadmap files (`docs/phases/TODO.md`, `docs/reference/PROJECT_STATUS.md`) are **generated build artifacts, not committed source** (Phase 332): they are gitignored and regenerated from `manifest.yaml` by `make sync` (CI publishes them as an artifact). Never `git add` them โ€” there is nothing to commit and therefore nothing to conflict on. Edit `manifest.yaml`; run `make sync` to preview locally. --- ## Git & Version Control - **Pre-Flight Synchronization:** `git checkout main && git pull origin main --rebase`. - **Strict Branch Naming:** Format: `phase--` (e.g., `phase-131-tls-fuzzing`). Use hyphens throughout โ€” never `phase_131`. - **Atomic Commits:** One commit per phase or logical sub-task. Use `type(scope): brief description`. > ### โš  Branch protection is ENFORCED on `main` โ€” THREE independent layers > > `main` is protected by **three separate GitHub mechanisms that must all be > satisfied** โ€” disabling one is not enough for anything that needs to > bypass protection (a force-push, a direct push). This was learned the hard > way during Phase 811's git-history-rewrite force-push, which needed all > three down *simultaneously* before it would go through: > > 1. **Classic branch protection, `enforce_admins: on`.** Binds *everyone*, > admins included, to required reviews and required status checks. A > direct `git push origin main` is rejected and there is no admin > direct-merge shortcut. > 2. **Classic branch protection, `allow_force_pushes: false`.** This is a > **separate setting from `enforce_admins`** โ€” disabling `enforce_admins` > does **not** exempt admins from it. There is no dedicated REST > sub-resource for this field either (unlike `enforce_admins`); changing > it requires a full `PUT .../branches/main/protection` call carrying > every other existing setting unchanged, or the call silently resets > them to defaults. > 3. **A repository ruleset** (`main branch protection ruleset`, id > `17259177`, target `~DEFAULT_BRANCH`), independent of both classic > settings above. Its rules are `deletion` and `non_fast_forward`, with > `bypass_actors: []` โ€” nothing bypasses it by default. Rulesets are a > newer GitHub feature layered on top of classic branch protection, not a > replacement for it; this repo has both. > > **Merging (Phase 332; corrected 2026-08-12).** Land work with > `gh pr merge --auto --squash --delete-branch`; it merges once the required > checks pass. **`main` IS strict** โ€” require-branches-up-to-date is **ON** > (verified via the API 2026-08-12: `required_status_checks.strict: true`; > an earlier version of this section claimed it was off โ€” that was wrong or > has since changed). Consequence: every time `main` advances, open PRs go > `BEHIND` and auto-merge will **not** fire until the branch is updated. > On an active day this is a rebase treadmill โ€” a PR can go stale inside its > own CI window. The fast recovery loop: > > ```bash > gh pr update-branch --rebase # server-side rebase onto main, no local checkout > gh pr checks --watch --interval 60 # then auto-merge fires on green > ``` > > Two gotchas learned the hard way: `gh pr checks --watch` can report the > *previous* head's green suite while the post-rebase suite is still > registering โ€” always re-check `gh pr checks` output for `pending` before > assuming you're clear; and `mergeStateStatus: BEHIND` (not conflicts) is > the usual blocker. You only need a *local* rebase if the server-side > update reports a genuine conflict. *(A GitHub merge queue would automate > even that, but merge queue requires an **organization-owned** repo and this one > is personal-account-owned, so it is unavailable โ€” see `docs/phases/complete/PHASE_332.md`.)* > > **Required checks (the merge gate).** The full required set is Meta-Validation, > Full Lint, Full Test, Scan Regression (surfaces touched), Secrets scan, SAST, the > Python/Go dependency audits, Traceability, lychee, and Go Build + Compose > Validate. Full Lint and Security Scan were run as **non-required, > advisory-only** for a time-boxed two-week Phase 332 trial (2026-06-15 โ†’ > 2026-06-29); the trial expired with zero lint/scan regressions reaching `main` > undetected, and both were **restored to required on 2026-07-01**. **Phase 847c > then split the CVE gate**: `Scan Regression (surfaces touched)` > (`make scan-regression`) is the required PR check โ€” it scans only the surfaces a > PR touches and fails only on findings absent from `security/baseline.json`, so an > upstream CVE disclosed overnight cannot redden an unrelated PR. The full > `Security Scan (make scan)` job is **de-required**: it still runs on push and > schedule, and on a PR only when the waiver set moved. Do not add it back to the > required contexts โ€” but state the reason precisely, because the > obvious-sounding one is false. A job skipped by an `if:` inside a workflow that > *did* run reports `skipped`, and `skipped` **satisfies** a required context: > the proof is in this repo, where the required `Cold-start smoke test > (start-poc.sh)` is skipped on every PR that does not touch the cold-start path > (e.g. #621 merged with it skipped). What genuinely blocks forever is a required > check the PR's workflows never create at all โ€” a *workflow*-level `paths:` > filter (see the comment on `poc-cold-start` in ci.yml), or one whose job does > not exist in the base branch. A skipped check therefore does not block merging; > it just gates nothing โ€” on most PRs a required `scan` would report green > without scanning while still reading as a full CVE scan. The requirement > belongs to `scan-regression`, which runs on every PR's diff. Run **`make > preflight`** (lint + scan + test) locally before opening any PR regardless โ€” it's > the same gate CI now enforces, and catching a break locally is faster than > waiting on CI. Because `main` is strict (branches must be up to date), run > `make preflight` against a reasonably current branch โ€” and expect to > `gh pr update-branch --rebase` if `main` advances before your checks finish. > > **Emergency override** (use only when `main` is broken and a fix cannot wait > for normal CI, or for a planned operation that genuinely needs a direct push โ€” > e.g. a git history rewrite, where the change has no meaningful PR diff to > review): disable **all three layers above simultaneously**, land the fix, > then restore all three immediately in the same sitting. Never leave any of > them disabled, and never disable only one and assume that's sufficient. > > ```bash > # 1. Fetch current classic-protection config and build a one-off payload > # from it (see below) โ€” don't hand-write one from memory, a dropped > # field silently resets a setting you didn't mean to touch. > gh api repos/seanpor/JA4proxy/branches/main/protection > /tmp/current_protection.json > > # 2. Disable all three, together: > gh api -X PUT repos/seanpor/JA4proxy/branches/main/protection \ > --input protection_unlock.json # built from step 1's output, with > # allow_force_pushes=true, enforce_admins=false, > # everything else unchanged > gh api -X PUT repos/seanpor/JA4proxy/rulesets/17259177 -f enforcement=disabled > > # 3. Land the fix / force-push immediately. > > # 4. Restore all three, immediately, same sitting: > gh api -X PUT repos/seanpor/JA4proxy/branches/main/protection \ > --input protection_restore.json # same payload, allow_force_pushes=false, > # enforce_admins=true > gh api -X PUT repos/seanpor/JA4proxy/rulesets/17259177 -f enforcement=active > ``` > > Note: `enforce_admins`'s dedicated sub-resource endpoint uses `POST` to > re-enable it (`gh api -X POST repos/seanpor/JA4proxy/branches/main/protection/enforce_admins`) > โ€” not `PATCH`, which 404s. > > Confirm the restore actually took by reading each setting back via the API > (`gh api .../protection --jq '{allow_force_pushes: .allow_force_pushes.enabled, > enforce_admins: .enforce_admins.enabled}'` and > `gh api .../rulesets/17259177 --jq .enforcement`), not just by having > issued the restore command. See `docs/phases/complete/PHASE_811.md` > "Landing attempt" for the full incident this section is based on. --- ## Testing, Linting & Validation (TDD) - **Zero-Tolerance Policy:** No skipped tests without explicit approval. Zero warnings, zero errors in linters. - **Approved Exception Workflow:** Present justification, get user approval, log in `docs/security/EXCEPTIONS.md` (skipped tests / ignored warnings), and annotate the code with the exception ID. CVEs that fail CI are tracked separately in `docs/security/CVE_EXCEPTIONS.md` (90-day expiry). ### Web Service / Python UI TDD (Management & Wizard) Every Python web service must include: 1. **HTML page rendering tests:** GET route with auth (`assert status_code == 200`, `"text/html"` in headers), and without auth (`assert status_code < 500`). 2. **Container config parity:** Read compose files and verify `REDIS_URL` or database strings correctly map password environment variables. 3. **Mocking:** Unit tests in `tests/unit/` **must not** reach any external process. Patch all clients. `os.access` mocks must use bitmask matching (`mode & os.W_OK`). ### Pre-PR gate: `make preflight` (mandatory) Before opening **any** PR, run **`make preflight`** locally and get it 100% green. It runs the full required-check set โ€” `make lint`, then `make scan`, then `make test` โ€” the same gates CI enforces. This is the shift-left half of the Phase 332 CI trial: the heavy checks (lint, scan) run on your machine so the PR path can stay fast. Do not push a branch / open a PR until `make preflight` passes. If `make test` executes Python tooling, it MUST be configured in the Makefile to execute inside the Docker container. ### Security-fix self-review checklist (mandatory for HIGH/CRITICAL findings) Before pushing a PR that fixes a HIGH or CRITICAL severity finding, complete this checklist and include the results in the PR description: 1. **Propagation sweep:** grep the codebase for the same bug pattern in other files. Document what you checked and the result (e.g., "TLS header grep: 2 sites, only reassembleClientHello โ€” clean"). 2. **Regression test:** the test must assert the **fixed** behaviour (not just log it). If the fix is reverted, the test must fail. 3. **closed_commit:** populate `closed_commit` in `findings.yaml` with the merge commit SHA after merge. 4. **findings.yaml validation:** run `python3 scripts/findings_register.py validate` โ€” must exit 0. 5. **Cross-reference existing findings:** check `findings.yaml` for prior findings in the same CWE family to avoid duplicating fixed work. Do **not** auto-merge HIGH/CRITICAL security PRs without at least one human review or an explicit agent self-review completing the above checklist. ### Python Import Hygiene (Containerized) Every new `.py` file must pass ruff immediately, via the tools image: ```bash docker run --rm -v "$PWD":/src -w /src ja4proxy-tools ruff check --select I001 --fix docker run --rm -v "$PWD":/src -w /src ja4proxy-tools mypy ``` (Or just `make lint`, which runs the same checks in the same image.) --- ## Go Proxy (the production runtime) The proxy is **Go-only** (`cmd/ja4pd`, `internal/`). The former Python proxy (`proxy.py`) was deprecated and deleted โ€” there is no cross-language parity to maintain. Python that remains is **not** the proxy: the Management API (`management/`), analytics, and supporting tooling under `src/`. - **GOROOT gotcha:** snap-installed Go sets `GOROOT=/usr/share/go`, which is wrong and breaks `go build`/`go test`. Use the snap path: ```bash export GOROOT=/snap/go/current ``` The Makefile already threads `GOROOT=$(GOROOT)` through Go targets; export it in your shell (`~/.bashrc`) so direct `go` invocations work too. - **Go tests run on the host** โ€” `go test ./...` has no virtualenv fragility, so the container-strict rule above applies to Python only, not Go. - **Go via Docker (when Go is not installed):** If Go is not available on the host (no snap, no apt), run build/test/vet through Docker: ```bash docker run --rm -v "$PWD":/src -w /src golang:1.26.4-alpine go build ./... docker run --rm -v "$PWD":/src -w /src golang:1.26.4-alpine go test ./... -count=1 docker run --rm -v "$PWD":/src -w /src golang:1.26.4-alpine go vet ./... ``` Race detector requires non-alpine image with cgo: ```bash docker run --rm -v "$PWD":/src -w /src -e CGO_ENABLED=1 golang:1.26.4 go test -race ./... ``` --- ## Security Bug Hunt Workflow When executing a bug hunt phase (Phase 500-series or similar): ### Log findings BEFORE fixing 1. **Register the finding** โ€” `python3 scripts/findings_register.py add ...` This allocates a canonical `JA4PROXY-YYYY-NNNN` ID, writes the entry to `docs/security/findings.yaml` with `status: OPEN`, and **automatically opens a GitHub issue** titled `JA4PROXY-YYYY-NNNN: `. The issue number is written back to `findings.yaml` as `github_issue`. - Use `--no-issue` only when offline or in CI where `gh` is unavailable. 2. **Fix the code** and write regression tests. 3. **Update the finding** โ€” set `status: FIXED`, populate `regression_test`, and record `closed_commit` once the PR merges. Use `Fixes #NN` in the PR body so GitHub closes the issue automatically on merge. 4. **Sync if needed** โ€” if you update `status` without a PR merge (e.g. bulk triage), run `python3 scripts/findings_register.py sync-issues` to reconcile GitHub issue state with the YAML. This creates a proper audit trail. Never fix-then-forget โ€” every finding must have a corresponding GitHub issue and YAML entry, even if the fix lands in the same PR. ### Critical review (mandatory for security work) After completing a bug hunt phase, spawn **parallel expert reviewers**: - **Security expert** โ€” findings accuracy, CVSS vectors, fix completeness - **Concurrency expert** โ€” race conditions, goroutine leaks, deadlock potential - **Code quality expert** โ€” style, patterns, test quality All three must APPROVE before merging. If any requests changes, fix and re-review. ### Code patterns to enforce - **Permission checks BEFORE `os.ReadFile`**, not after. Key material should never be in memory if the file fails the permission check. - **HTTP response body limits**: wrap `resp.Body` with `io.LimitReader` before JSON decoding. Use 256 KiB for external APIs (responses are typically <10 KB). - **Every `select/default` drop needs a metric counter**. Silent fail-open paths are invisible to operators. Pattern: `metrics.XxxDroppedTotal.Inc()`. - **YAML compose files**: verify no duplicate keys after editing. Use `rg 'key_name' file.yml -n` to check. Consider a yamllint CI check.