# Access-validation implementation (lane v1) Implementation record for the coherent staged access-validation lane required by `docs/ACCESS-VALIDATION.md` (owner requirement 2026-10-13, contract committed as 68259ec0 on `oss-oo/pve-backend-caddy-01a13586`). Everything lives under `scripts/access-validation/`; one report schema, one client layer, three execution stages with strictly increasing blast radius — and a safety model where the default is always inert. ## Truthfulness first (what existing proof actually proves) - `scripts/e2e-local.sh` proves authenticated Git clone/push over **loopback HTTP** — not Git SSH, not public authenticated HTTPS transfer. Stage-2 reports therefore reference e2e evidence only under transport-honest ids (`git/e2e-loopback-http-push`). - The remote MCP server (`agent-gateway/remote_server.py`) is **stateless POST JSON-RPC**: `GET /mcp` and `DELETE /mcp` answer 405; there is no SSE stream and no session capability. No check or adapter in this lane requires or claims either. Batches are refused (MCP 2025-06-18), wrong Content-Type gets 415, anonymous requests get 401 before any JSON-RPC processing. - A health response (HTTP 200, `/mcp/healthz`) proves liveness only. MCP acceptance everywhere requires initialize + `tools/list` + a real `tools/call` read (`get_file`). - Cookie-jar (HTTP session) e2e evidence can never mark a browser check passed; browser checks stay `unsupported` until a rendered-browser proof (`kind: browser-run`) exists, and real-client checks (Codex/ChatGPT/ZCode) only pass on verified `client-run` receipts — raw HTTP is structurally incapable of flipping them. ## Stages | Stage | Command (all `python3 -I`, any cwd) | What it proves | Runs where | |---|---|---|---| | 1 `stage1-contract` | `scripts/access-validation/stage1_contract/run_stage1.py --report-out r.json` | client contracts against in-process loopback fakes (real git binary + askpass, real JSON-RPC, real MQTT-WS frames, verified TLS against test-only fixture CA) + the anonymous public-MCP catalog contract (13 `public-mcp/*` rows against `FakePublicMcp`, hostile truncated/corrupt-transfer drills) | anywhere; forge CI lane `access-validation-contract` | | 2 `stage2-synthetic` | `scripts/access-validation/stage2_synthetic/run_stage2.py --config --report-out r.json` | authenticated E2E against the isolated stack (plus an OPTIONAL anonymous `public_mcp` section — 7 read-only rows, inert without it): REST identity/scope/private-separation (independent outsider user), git fetch on the public fixture + public-anonymous legitimacy row + wrong-token negative on the distinct private fixture + synthetic canary push with native-schema (`commit.id`) API readback + verified cleanup, MCP surface + method refusals, MQTT-WS CONNACK + authorized SUBSCRIBE + canary event through the product path + private-topic silence, optional SSH overlay (banner/hostkey + pinned transport: authorized fetch, wrong-key and unauthorized-repo negatives, synthetic canary push with transport readback + verified cleanup), LFS batch-route / package-API-v1 presence probes, transport-honest e2e evidence references | VM1005 only, after `scripts/e2e-local.sh` (see below) | | 3 `stage3-realroute` | `scripts/access-validation/stage3_realroute/run_stage3.py --config --target [--authorize-canary] [--local-archive dir] --report-out r.json` | exact-release pre/post-publication real-route checks (plus OPTIONAL anonymous `public_mcp` environment section — 7 read-only rows, inert without it): DNS/TLS/HTTPS/public fetch, REST auth + 401 negative, MCP surface incl. GET/DELETE 405, MQTT-WS CONNACK + negative, LFS/registry presence, SSH banner + host-key pins (verification independent of reachability) + parameterized pinned SSH transport legs (authorized fetch, wrong-key negative) + gated SSH canary push behind the same fail-closed chain, gated disposable HTTPS canary push with verified cleanup, client receipts | explicitly invoked, read-only by default; never from CI | Exit codes (all stages): 0 = no `failed`; 1 = at least one `failed`; 2 = usage/config/binding error. `notrun` (with reason) and `unsupported` are honest states, never greens. ## Fixture contract: the outsider is an independent synthetic user The permission-separation negative (`rest/private-repo-separation`, native VM1005 proof: outsider REST 404 vs owner 200 on the private repo) requires a token whose identity genuinely has NO access. Minting it from the operations account itself — even with reduced scopes — is an INVALID fixture: the token still belongs to the owner identity and can never demonstrate separation. The v3 handoff made exactly that mistake; the native SOL run corrected it live. The binding fixture contract (pinned offline by `stage2_synthetic/tests/test_fixture_contract.py`): ```bash # 1. CREATE the outsider as its own unprivileged user (never mint its # token under the operations account): $COMPOSE exec -T -u git forgejo forgejo admin user create \ --username av-outsider --password '' \ --email av-outsider@invalid --must-change-password=false # 2. Mint the outsider token UNDER that user (read:repository is fine — # the identity, not the scope, provides the separation): $COMPOSE exec -T -u git forgejo forgejo admin user generate-access-token \ -u av-outsider -t av-stage2-outsider --raw --scopes read:repository # 3. Export it as rest.outsider_token_env — a DIFFERENT env name than # the owner and scoped tokens (three distinct env names, always). ``` The same independence rule applies to every negative identity in the lane: the SSH wrong key is a never-registered key, the MQTT negative credential is deliberately invalid, and no owner/account credential is ever reused as a stand-in for an outsider. ## Stage-2 owner-token scope contract (native final acceptance 2026-10-13) The synthetic Stage-2 OWNER token (`rest.rest_token_env`, reused by the git, MCP and MQTT event-fire legs) is minted under the operations account with exactly five scopes — `read:package` is required, and an all-scope token is never the fixture: ```bash $COMPOSE exec -T -u git forgejo forgejo admin user generate-access-token \ -u oss-oo-operations -t av-stage2-full --raw \ --scopes read:user,read:repository,write:repository,write:issue,read:package ``` Native provenance (Root's VM1005 final acceptance of tree `55317f73…`): the earlier four-scope list (no `read:package`) passed every row except `registry/endpoint-presence`, which the forge refused with HTTP 403 — the package-API list route `GET /api/v1/packages/{owner}` is scope-gated. Controlled diagnosis: the four scopes → 403; exactly those four plus `read:package` → 200 `[]`. The corrected token then passes the full Stage-2 board (25/0/3) with no other change. The negatives deliberately stay narrow and gain nothing: the scope-limited token keeps `read:repository` only (its 403 on /user IS the check), and the outsider keeps its independent identity with `read:repository` (separation comes from the identity, not the scope — see the fixture contract above). Fidelity follow-up CLOSED (integration round 2026-10-13, Root-authorized): the offline fake's package-list route (`fakes_stage2._packages_list`) now models the `read:package` scope gate — an authenticated token without it is refused with 403 naming the missing scope, exactly the native refusal shape — and the offline rig's owner fixture token carries exactly the five scopes above. Regression-pinned by `RegistryScopeGateFidelityTests` (red on the unfixed state: the gate test failed because the fake answered 200; the fixture test failed on the scope list) plus the `OwnerScopeContractTests` doc pins; the negatives stay narrow. History: the gap existed because the original fake authenticated but never modeled the gate, so the omission stayed green offline and only surfaced at native VM1005. ## Safety model - **Inert defaults**: stage 2 without a `stage2` config section and stage 3 without `--target` write all-`notrun` reports and exit 0. CI is detected (`CI`, `GITHUB_ACTIONS`, `GITEA_ACTIONS`, `FORGEJO_ACTIONS`, `ACT`) and both stages refuse real-route execution entirely (all `notrun`, reasons name `ci`). - **Canary writes (stage 3)** are fail-closed behind `av_common.config.canary_gate` — config `canary.enabled` AND CLI `--authorize-canary` AND not-CI AND an exact release binding AND `canary.environment` == `--target` AND a non-empty disposable token env AND the authorization file on disk — re-asserted immediately before the push (`stage3_realroute/canary.py`). Cleanup is verified by API 404 readback; any leftover fails the check (`cleanup-failed`). A refused gate is an honest `notrun`, and no CI path can ever reach the write. - **Release binding**: stage 3 never validates "whatever is live" — a run with a target requires a pinned binding (tag + 64-hex sha256 map, optional commit pin); `--local-archive` verifies the pin against local artifacts before publication, otherwise the live release API answer must match the pin or the run refuses (exit 2). - **Credentials**: values are read from env vars named by the config, at call time; never in argv, remote URLs, config files, or reports. Git uses a generated `GIT_ASKPASS` helper under a neutral env name with `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`, `GIT_ALLOW_PROTOCOL=http:https`, `GIT_TERMINAL_PROMPT=0` — no station credential helper can interfere or leak. stderr is scrubbed of URLs and token values. Reports are key-scrubbed and refuse token-shaped (40-hex) strings in all free text. - **Deadlines**: every network operation derives connect/read timeouts from a per-check `netutil.Deadline`; there is no total-pipeline time cap anywhere in the lane. ## Report schema v1.1 (2026-10-27 round) ```json { "schema": "oss-oo.access-validation.report/v1.1", "stage": "stage1-contract | stage2-synthetic | stage3-realroute", "binding": {"source_sha": "…", "source_tree": "…", "origin": "…", "dirty": true, "target": {"…": "…"}, "artifact": {"release": {"tag": "…", "expected_sha256": {}}}}, "environment": {"ci": false, "python": "3.12.x"}, "checks": [{"id": "mcp/surface", "route": "mcp", "status": "passed|failed|notrun|unsupported|unknown", "detail": "…", "evidence": [], "duration_ms": 0, "deadline_ms": 60000, "authorization": {"mode": "…", "explicit": false}, "identity": {"class": "…", "detail": "…"}, "operation": {"transport": "…", "target": "…", "action": "…"}}], "summary": {"passed": 0, "failed": 0, "notrun": 0, "unsupported": 0, "unknown": 0} } ``` The v1.1 additions (2026-10-27 access-validation round): the `unknown` state (outcome undeterminable from recorded evidence — distinct from `unsupported` = by-design out of scope and `notrun` = never executed; `skip()` cannot produce it, only `finish()` with evidence) and the per-check `identity` / `operation` facts, filled for every inventory row from drift-guarded `ROW_FACTS` tables. The acceptance-level aggregation over these reports is schema `oss-oo.access-validation.acceptance/v1` (`scripts/access-validation/acceptance/`; human matrix: `docs/ACCESS_VALIDATION_MATRIX.md`). Authorization modes: `read-only`, `authenticated-read`, `synthetic-write` (isolated stack), `canary-write` (gated real route, always `explicit: true`). Client-receipt slots echo `client_version`/`environment`/`transport_auth` when the manifest carries them (informational; pass semantics unchanged). ## CI integration `.forgejo/workflows/access-validation-contract.yml` mirrors `ci.yml` (alpine:3.20 pinned container, `oss-oo-internal` runner, dependency-free checkout): installs `bash git python3` and runs all offline test files of stages 1-3 plus the acceptance aggregator under `python3 -I` (glob `scripts/access-validation/*/tests/test_*.py`; 24 files / 551 cases after the 2026-10-27 access-validation round — stage1 195 incl. the login-HTML/vhost negatives and row-facts pinning, stage2 128, stage3 213, acceptance 15; before it: 18 / 502 after the 2026-10-15 target-contract correction: stage1 176, stage2 125, stage3 201 — before that: 18 / 477; originally 17 / 402), then prints three runner reports: the stage-1 runner (contract checks against in-process fakes, 42 rows after the 2026-10-27 round added the login-HTML and vhost-routing negatives) plus the stage-2 and stage-3 runners invoked with NO `--config` and NO `--target` — inert by construction, all-`notrun` (35 and 26), exit 0 — and finally the acceptance aggregator step, which produces one acceptance artifact (`acceptance.json` + rendered `acceptance.md`, printed in full, pinned as a tarball sha256). Loopback-only and credential-free by design; LIVE stage-2/3 execution, credentials, and canary writes stay structurally CI-impossible (no config, no target, CI detection, config gate order). The synthetic stage-2/3 runner TESTS isolate the CI marker variables per test (the stage-3 `CI_KEYS_OFF` precedent, applied to the stage-2 rig after the native PR127 CI run) so they exercise synthetic behavior even inside a CI job; the production refusal itself stays pinned by tests running the full synthetic config under the complete documented marker mix (all checks notrun, reasons name ci). **The lane merged to main via PR #126 and now fires on every push to main and every pull request.** ## Route coverage map (what is proven where) | Route | Stage 1 (contract) | Stage 2 (isolated stack) | Stage 3 (real route) | |---|---|---|---| | REST | auth/negative shapes | identity, 401, scope 403, private separation (independent outsider user) | version, authenticated read + 401 | | Git HTTPS | askpass plumbing, wrong token, push shape | fetch on the public fixture, public-anonymous legitimacy row, wrong-token negative on the DISTINCT private `git.negative_repo` (auth-shaped refusal required), canary push + `commit.id` readback + cleanup | public fetch; gated canary push | | Git SSH | keyscan parse, substitution detection, banner read; transport contract via injected runner (exact `GIT_SSH_COMMAND` composition, fail-closed pinning with zero spawns on any mismatch, `[host]:port` normalization, identity/deadline refusals, temp known_hosts hygiene, stderr scrubbing) | optional overlay: banner, host-key pins, authorized fetch, wrong-key negative, unauthorized-repo separation negative, synthetic canary push with transport ref+object readback and verified cleanup | authorized fetch, wrong-key negative, gated canary push (identity-ref variant of the credential leg, re-asserted before the write) | | MCP (authenticated `/mcp`) | full surface incl. negatives | surface + 405/400/415 + anonymous | surface incl. 405 facts | | Public MCP (`/mcp/public`, anonymous) | 15 rows: initialize, exact four-tool scope, paged search (full page never complete), repo-info projection, tree cursor continuity, complete chunked file retrieval with git blob digest + 0-byte case, unknown-tool refusal (no upstream call), uniform refusal, cursor tamper/mismatch, BOTH method surfaces (direct gateway: 405/Allow + OPTIONS 204; public edge: every non-POST 405, no Allow; no CORS on either), budget 429 + Retry-After, truncated/corrupt transfer FAILS, distinct snapshot-expired refusals FAIL the walk, credential-freedom from the receiving journal | optional `public_mcp` section with REQUIRED `target` (`direct-gateway` \| `public-edge`): surface, search pages, tree walk, file chunks, unknown-tool, uniform refusal, target-bound method surface (7 rows; no credential envs exist on this surface) | same 7 rows behind the environment's `public_mcp` section (target required) | | MQTT-WS | CONNECT/CONNACK + rc 5 | CONNACK + SUBSCRIBE + canary event + private silence | CONNACK + negative | | LFS / registry | — | presence-only probes: LFS via the protocol POST `.../info/lfs/objects/batch` (empty download request, `application/vnd.git-lfs+json`), registry via the `/api/v1/packages/{owner}` list route — both explicitly claim NO content/OCI transfer | presence-only probes (same protocol shapes) | | Browser/session | — | transport-honest e2e references (`unsupported` for browser) | — | | Clients | — | — | `client-run` receipts only | Known, deliberately honest gaps (registered follow-ups, not silent): the git-SSH **transport** is now implemented and wired (a dedicated opt-in seam in `av_common/clients/git_ssh.py` — `GIT_SSH_COMMAND` pinned fail-closed to the approved `host_key_pins` via the captured keyscan, `BatchMode`/`IdentitiesOnly` noninteractive identity-file reference, `GIT_ALLOW_PROTOCOL=ssh` for this seam only; stage-2/3 legs above), but it has **hermetic injected-runner proof only** — no real SSH handshake, ssh binary, or git-over-ssh has ever executed on a workstation (by constraint). The first native proof is the VM1005 SSH overlay leg in the VM handoff; until then every transport row on a real machine reads `notrun`, and wrong-key/unauthorized negatives fail (not pass) on local refusals so a fail-closed state can never fake a server-side verdict. LFS/registry content round-trips and the browser proof require configured targets that do not exist yet. Canary MQTT events on a real route would need an explicitly provisioned publisher credential (stage 2 proves the path on the isolated stack). ## ZCode client integration (adapter contract) Remote door: `https://oss-oo.io/mcp`, streamable HTTP (MCP 2025-06-18), **stateless** — one JSON-RPC 2.0 object per POST, one JSON response. Configuration shape (works today, zero install): ```json {"mcpServers": {"oss-oo": {"url": "https://oss-oo.io/mcp", "headers": {"Authorization": "Bearer "}}}} ``` - **Auth**: Bearer (or `token`) forge token on every POST; the token acts with exactly its own scopes; anonymous/unknown/dead tokens get HTTP 401 before JSON-RPC processing; pre-flight counts against the read-class budget. - **What ZCode clients must NOT need**: server-initiated SSE streams (`GET /mcp` → 405), sessions (`DELETE /mcp` → 405), JSON-RPC batching (400), bodies over 1 MiB (413), non-JSON Content-Type (415). The 2024-11-05 / 2025-03-26 / 2025-06-18 protocol versions are negotiated; anything else answers 2025-06-18. - **Budget**: per-token sliding window, 600 read-class + 60 write-class per minute; exhaustion → HTTP 429 with `Retry-After` (pre-flight) or a 429-shaped tool error. - **Acceptance path** for any client (this lane's `mcp/surface` checks): `initialize` → `tools/list` (16 tools incl. `get_file`, `list_repositories`, `memory_read`) → `tools/call get_file` — health alone proves nothing. - **Gaps recorded for a future adapter/plugin round** (outside this lane; `agent-plugin` verification is SOL/owner territory): no OAuth discovery (token must be provisioned via sponsor console / open join / registrar), no stream capability for clients that require server-push notifications, and per-tool permission prompts are the client's own concern. No ZCode configuration was modified by this lane. ## v4 contract corrections (2026-10-13, native v3 evidence) Five bounded corrections driven by Root's native VM1005 acceptance of the v3 source (ACCEPTANCE-v3 / NATIVE-DIAGNOSTICS, `z-access-native-v2`); no protocol claim was weakened — every fix makes the probe match what the forge actually answers: 1. **Wrong-token negative retargeted to a distinct PRIVATE fixture** (`git.negative_repo`, falling back to `rest.private_repo`). The old leg aimed at the public `git.repo`, where anonymous reads are LEGITIMATE (native: garbage token, public repo → exit 0 with refs) — an unsuitable negative. The evaluator now additionally demands an AUTH-SHAPED refusal (native: exit 128 `Authentication failed` / `Credentials are incorrect`); a not-found or local failure can no longer fake a pass. The new `git-https/public-anonymous-read` row documents the public fixture's legitimate anonymous readability so the negative can never drift back onto it. Nothing ever demands denial of public anonymous readers. 2. **Branch readback reads the native schema**: the Forgejo branch API carries `commit.id` (40-hex), never `commit.sha` (native `main-branch-shape`: `commit_id` set, `commit_sha` null). The GitHub-shaped readback now FAILS the identity proof instead of being the only shape that passed. 3. **LFS presence via the protocol batch route**: POST `.../info/lfs/objects/batch` with the native empty-download body and `application/vnd.git-lfs+json` media types (native: 401 → endpoint proven; the old GET on the LFS root answers 404 because it is not a route). Presence-only wording is explicit: no LFS content transfer is attempted or claimed. 4. **Package registry via `/api/v1/packages/{owner}`** (native: 200 `[]`; the unversioned `/api/packages` route answers 404). The report row states API-presence only — an empty package-list 200 is never labeled an OCI transfer proof. 5. **Outsider fixture is an independent synthetic user** (see the fixture-contract section above): the v3 handoff minted the outsider token from the operations account itself — invalid for permission separation; the native SOL run had to correct it live. The example config, this doc, and the new `stage2_synthetic/tests/test_fixture_contract.py` pin the honest shape (user create + mint under `av-outsider`; three distinct env names; `git.negative_repo` present). Post-review hardening (independent native review, verdict WITH-FINDINGS, both findings LOW): (1) a typo'd `git.negative_repo` (repo the owner cannot see) now FAILS the wrong-token check before the transport runs — the runner cross-checks the target's visibility via the REST owner token whenever available (regression-pinned by `test_wrong_token_negative_must_be_an_existing_repo_for_owner`; offline-previously the fake's unknown-repo 401 masked the misconfiguration); (2) registered follow-up, deliberately NOT touched in this bounded round: `stage3_realroute/release_binding.py` silently skips the commit-pin comparison when the live release API payload carries no `sha` at all (pre-existing v3 code outside the v4 change set; behind pinned binding + canary gates; should refuse loudly in a future round) — **closed by the v4.1 follow-up round below**. The test fixtures themselves were the biggest honesty hole: the old fake stack ANSWERED the three wrong probes (GET `/info/lfs` → 200, `/api/packages` → 200, branch readback → `commit.sha`) — implementation-mirror mocks that manufactured the desired answers. The fakes now mirror the native shapes (404s on wrong routes, 401 on the LFS batch, `commit.id` branch objects, public repos anonymously readable, private repos refusing every non-authorized credential), and `10-red-green` evidence records the same v3-shaped configuration failing exactly the four native failures against the unfixed source (red) and passing clean after the fixes (green). Stage 3 carries the identical LFS/registry request corrections (same bugs, real-route side). ## Local verification actually run (v4 round, offline) Carried history from earlier rounds (still true, still tested): the HTTPS canary `push_fn` result runs through the same `_gate_result` adapter as the SSH canary — a LIVE git exit 0 previously read as `push-failed` under `execute_canary`'s 2xx contract (v2 delta, regression-tested with a git-shaped fake, tripwire-verified); the CI workflow YAML was parsed and shape-mirrored against `ci.yml` (v1). - 15 unittest files across three stages: **all OK** (stage1 82, stage2 96, stage3 123 — 301 tests; the v4 wave adds the ContractBugReproductionTests, the fixture-contract tests, the native-shape evaluator matrix, and the post-review negative-target existence pin). - Red/green evidence (`z-access-validation-v4-contract-fixes/ 10-red-green/`): red-demo against the unfixed v3 source reproduced all four native failures verbatim (`wrong token unexpectedly succeeded`, `branch readback carried no commit object id`, LFS 404, registry 404); the identical configuration runs green on the fixed source (19 passed / 0 failed / 9 notrun). - Stage-1 runner: 24/24 passed against in-process fakes, exit 0. - Stage-2 runner inert: 28 `notrun` (one new inventory row: `git-https/public-anonymous-read`), exit 0; config error → exit 2 (including the new `git.negative_repo` owner/repo shape). - Stage-3 runner inert: 19 `notrun`, exit 0. Not run here (by constraint): `scripts/e2e-local.sh`, any compose stack, forge CI, any real-route execution. Those are the VM gate (`90-vm-handoff` evidence / controller handoff) and post-authorization work; **nothing is committed until Root returns verified VM results.** ## v4.1 release-binding + negative-fixture closure (2026-10-13, Root release directive) Two bounded corrections Root required before final v4 delivery (`z-access-validation-v4-release-followup/root-release-directive.md`); both close the original review's findings. Contract semantics were derived from official primary documentation only — the live swagger of a public Forgejo instance (`Gitea API` lineage; saved with SHA-256 provenance under the round's `10-contract-evidence/`, which carries the source URL) — never from a production probe: - The documented **Release schema has no `sha` property at all** (properties: assets, author, body, created_at, draft, html_url, id, name, prerelease, published_at, tag_name, tarball_url, target_commitish, upload_url, url, zipball_url) — Root independently confirmed the same shape on the production v1.74.0 release. A release payload without a sha is NORMAL, not an error. - The documented **Tag schema** (`GET /api/v1/repos/{owner}/{repo}/ tags/{tag}`) carries `commit.sha` ("SHA is the commit SHA hash") and `id` ("The ID (SHA) of the tag" — the tag OBJECT id for annotated tags, equal to the commit only for lightweight ones). **Correction A — release/tag binding (closes review finding 2; refined by Root's native captures).** When a pin carries `release.target_sha` and the release payload cannot prove it (the normal no-sha shape), `run_stage3.verify_remote_release` resolves the immutable tag→commit evidence through the NATIVE evidence trio and requires all legs to agree: the tag API (`bind_tag_from_tag_api`: commit is `Tag.commit.sha`; a `Tag.id` differing from it marks an annotated/nested form — refused), the git refs API (`bind_ref_from_git_api`: the answer is a LIST — exactly ONE member, binding EXACTLY `refs/tags/`, with `object.type == "commit"`; a "tag" object type is refused as not natively proven), and the git commits API (`bind_commit_from_git_api`: a DICT whose TOP-LEVEL sha must equal the resolved commit). `crosscheck_tag_evidence` demands three-way agreement; `attach_tag_evidence` then attaches the proof (provenance `target_source="tag.commit.sha"`). The shapes and the direct-form rule come from Root's READ-ONLY native captures (`z-access-release-native-shapes-v1`, real public v1.74.0 payloads: tag id == commit.sha == ref object.sha == commit sha; ref `object.type "commit"`; release WITHOUT sha and with a BRANCH `target_commitish`) — fixture projections use synthetic values with the exact captured types/structure and cite that provenance. `compare_release_binding` REFUSES — a loud reason, never a silent skip — when a pinned commit has no live proof. `target_commitish` is never commit identity (even a 40-hex-shaped value: the field may name a moving branch), so `bind_release_from_api` no longer reads it as a target at all; a payload that does carry its own 40-hex `sha` keeps direct proof (`target_source="release.sha"`). Missing routes, unusable or name-mismatched payloads, ambiguous ref cardinality, annotated/nested tag forms, sha/tag disagreement, or a mismatched resolved commit all refuse the run (exit 2). A normal no-sha release WITH the correct direct-form tag/commit evidence verifies (supported, not universally rejected); no branch fallback exists anywhere. **Correction B — fake negative-fixture existence fidelity (closes review finding 1, fixture side).** The stage-2 fake stack now answers an UNKNOWN repo (not in its repo store) not-found-shaped (HTTP 404 on `info/refs`) instead of auth-shaped 401 — existence and visibility are separate facts. The evaluator's auth-shape requirement then FAILS the wrong-token check honestly on a typo'd target even when the REST owner-visibility cross-check cannot run (no REST token configured); both legs are regression-pinned (`test_wrong_token_unknown_target_fails_without_rest_guard`, `test_fake_separates_unknown_from_private_at_git_transport`: unknown → `not found`, existing-private with bad credential → `Authentication failed`, public → anonymous read succeeds). The existing guard test and the private-target positive denial stay green unchanged. Local verification actually run (v4.1+v4.2 rounds, offline): red/green evidence under `z-access-validation-v4-release-followup/ 20-red-green/` — the new tests fail against the unfixed source for exactly the intended reasons (the compare test got `[]` reasons where a refusal is required; the unknown-target leg ran the full round green, reproducing the review's probe D; the transport probe saw `Authentication failed` on an unknown repo; and, for the v4.2 native refinement, the annotated id≠sha form raised no refusal and the ref/commit parsers did not exist), and pass after the corrections. Full offline suite: **15 files / 346 tests, all OK** (stage1 82, stage2 98, stage3 166); stage-1 runner inert 24/24, stage-2 inert 28 `notrun`, stage-3 inert 19 `notrun`, all exit 0 — the inert inventories are unchanged from v4. Still not run here: any real route, VM, or production access; release/tag resolution is proven against fixtures built from the documented shapes and Root's read-only native captures only. ## Redirect policy + operator cancellation (2026-10-14, impl-01 round) Bounded additive round on top of `4cc434a` (PR #126/#127 lane state; the same commit Root live-accepted 2026-10-14T05:47:19Z). Two deltas, both pinned by red/green evidence; the full route/contract inventory with per-commit source anchors now lives in [ACCESS-VALIDATION-ROUTES.md](ACCESS-VALIDATION-ROUTES.md) — that doc is the anchor of record for "what is proven where at which commit". 1. **Explicit redirect & authentication-boundary policy** (`av_common/clients/http.py` `open_with_policy`, applied identically by `mcp.py`; git pins `http.followRedirects=initial` in its argv — official git-config(1) citation in the ROUTES doc). Replaces bare `urlopen` default following: authenticated requests never follow a cross-origin redirect (deliberate `unsafe-redirect` refusal carrying the 3xx answer; the header provably never reaches the other origin); https→http downgrades refused for any request; same-origin redirects followed under a small hop bound (`DEFAULT_MAX_REDIRECTS = 3`, a SAFETY bound against loops — not an invented performance cap — with a per-call override); Locations with userinfo or non-http(s) schemes and 3xx-without-Location refused; the followed chain recorded in `meta["redirects"]` scrubbed (no query strings). The loopback carve-out documented precisely: none for credentials; unauthenticated same-scheme cross-origin redirects are followed (no credential boundary exists to cross), proven on loopback fakes. One derived fix worth recording: the no-follow opener is built per conversation because urllib's HTTPS handler snapshots its SSL context at construction — a module-level opener would have frozen the trust store at import time and ignored operator `SSL_CERT_FILE` changes. 2. **Honest operator cancellation** (`av_common/cancellation.py`; all three runners): SIGINT/SIGTERM interrupt at the next interpreter boundary; the report is finalized immediately (in-flight row + every never-run inventory row → `notrun`/`cancelled-by-operator`, completed verdicts kept, pre-existing honest notrun reasons never relabeled), written, and the process exits **130** for both signals. `OperatorCancelled` derives from `BaseException` precisely so no `except Exception` probe boundary can swallow it into a fake "failed check"; temp hygiene survives because the clients' askpass/ known_hosts cleanups live in `finally` blocks. Stage 1 gained the `INVENTORY` constant (drift-pinned against a complete run by `Stage1InventoryTests`); stage 3 cancelled before any check ran writes no report rather than inventing one. Red/green evidence (`10-red-green/` under the round's evidence dir, outside git): the redirect legs failed 10/10 against the unmodified `4cc434a` clients for exactly the intended reasons (the unmodified client FOLLOWED the cross-origin redirect — the far-origin sink was hit and answered 404 — and recorded no chain); the cancellation legs failed 6/6 (missing module/INVENTORY, runner death by default SIGTERM disposition instead of exit 130). All green after the two deltas. Offline suite after the round: **16 files / 370 tests, all OK** (stage1 99, stage2 105, stage3 166); before the round, measured on `4cc434a` itself: 15 files / 353 (stage1 82, stage2 105, stage3 166 — the "346 / stage2 98" recorded above in the v4.1 section had drifted; the PR #127-era additions were already in the base). Stage-1 runner 24/24, stage-2 inert 28 notrun, stage-3 inert 19 notrun, exit 0. New evidence about scope: the redirect refusals are pinned against in-process loopback fixtures — a live cross-origin refusal drill would need a deliberately hostile route Root has not authorized (recorded as an honest gap in the ROUTES doc). ## Review-fix round (2026-10-14, impl-02) The independent review (rev-01, verdict ACCEPT-WITH-FINDINGS; full report in the round evidence dir outside git) returned seven findings on the impl-01 round. Dispositions, all landed on this branch: - **REV-01 (major — fixed, commit `5c53b1c`)**: a redirect Location with a syntactically invalid port (`https://host:bad/`) or an out-of-range port (`http://h:99999/`) was ACCEPTED by `_redirect_target` (it checked scheme/hostname/userinfo but never the port) and then crashed with an uncaught `ValueError` at `_origin_of`'s `parts.port` — escaping `rest_request`/`mcp_raw_post` whose contract is that status/error/data are reported, never raised (runtime-proven by the reviewer on loopback through both clients). The port is now validated once inside `_redirect_target`, refusing with the reason "redirect Location port is invalid" (never quoting the Location); red/green evidence in the round evidence dir (`10-red-green/red-rev01-port.txt` against the unfixed engine — exactly the reviewer's `ValueError: Port out of range 0-65535` trace — and `green-rev01-port.txt` after). Operator-supplied base URLs keep exactly their base behavior, A/B-probed against a `4cc434a` tree extraction: a nonnumeric port still raises `http.client.InvalidURL` from urllib's own parsing and an out-of-range port still maps to a status-0 `ClientResult` — the new check guards only redirect Location values, and no unvalidated `.port` access remains on the redirect path (`_origin_of`/ `_chain_entry` now only ever see operator URLs or already-validated targets). - **REV-02/REV-03 (minor — pinned, commit `06136f8`)**: the policy's ALLOW path and method semantics are now journal-pinned (asserted from what the scripted server actually received, never client-side echoes): the same-origin follow while token-authenticated provably delivers the Authorization header on the followed hop; POST with a JSON body through 303/302 arrives as GET with empty body and no Content-Type; through 307/308 it arrives with method, body, and Content-Type preserved exactly; 3xx-without-Location and non-http(s)-scheme Locations each have a refusal leg. - **REV-04 (minor — docstring correction only)**: the reviewer's premise that a previous-default SIGTERM disposition "can never be restored" is false on this station. Probed with `python3 -I` (3.12.3): `signal.signal(SIGTERM, SIG_IGN)` over a default disposition RETURNS `Handlers.SIG_DFL` — an enum, fully restorable by `restore()`; only a `None` return marks a handler installed outside Python (a C-level disposition `signal.signal` cannot read back), which `restore()` leaves as is. The `install()`/`restore()` docstrings in `av_common/cancellation.py` now state exactly that; no behavior change (none was needed). - **REV-05 (info — recorded)**: an operator cancellation landing inside a LIVE gated canary's push→delete window can leave the disposable canary ref behind — `OperatorCancelled` derives from `BaseException` precisely so probe boundaries cannot swallow it, and by that same property it skips `execute_canary`'s `except Exception` cleanup loop (stage3 `canary.py`). How to check: the branch API 404 probe documented in the canary plan's cleanup steps. Recorded in `cancellation.py`'s module docstring and here. Pre-round SIGTERM was strictly worse: a hard kill mid-push with no finalized report at all. - **REV-06 (info — fixed cheaply, commit `5c53b1c`)**: `_chain_entry` re-brackets IPv6 literal hosts (`urlsplit`'s `.hostname` strips the brackets) so scrubbed `meta["redirects"]` entries like `https://[::1]:8443/a` stay re-parseable; unit-pinned. - **REV-07 (info — recorded, deliberately not engineered around)**: SIGINT/SIGTERM arriving after `install()` but before the guarded try (argparse/config load), or during the final report write, escapes as an uncaught traceback (exit 1) — narrow windows with no verdict at stake (no report exists yet in the first case; it is already finalized in the second). Verification measured this round (offline, from the worktree): full suite **16 files / 380 tests, all OK** (stage1 109, stage2 105, stage3 166 — 370 before this round, the +10 being the new redirect legs); stage-1 runner 24/24 passed, stage-2 inert 28 `notrun`, stage-3 inert 19 `notrun`, all exit 0. `scripts/e2e-local.sh` was not run by this pinky — the Brain's concurrent `pav-e2e-r2` gate covers it — and no podman, compose, real-route, or credential-store access happened. ## Independent acceptance corrections (2026-10-15) The independent acceptance of native candidate `c0b6ad7` reproduced all 380 offline tests and identified redirect gaps beyond that suite. The source corrections are: - HTTPS Git targets (including anonymous reads) and credential-bearing HTTP Git calls now pin `http.followRedirects=false`, both generally and for the exact operator URL. `initial` was not a credential boundary: real Git followed A→B→C and supplied a synthetic askpass secret to C; a verified HTTPS→HTTP redirect also reached the HTTP server with that secret. All authenticated redirects now fail, including same-origin canonicalization. Operators must supply the final canonical repository URL. The URL-scoped pin also prevents a local URL-scoped `followRedirects=true` from defeating the general command-line setting. Only anonymous reads from explicitly named HTTP targets retain `initial`, with no single-hop or single-origin guarantee. A separate real-binary HTTPS→HTTP anonymous probe had also returned success at the earlier acceptance tree `b7b1cf4`; it now refuses without contacting the HTTP sink, while direct verified anonymous HTTPS reads remain successful. - Git operations execute with an owned neutral temporary cwd and HOME, with system/global config disabled and ancestor discovery fenced. Caller-local credential helpers, askpass programs and HTTP extra headers and `.netrc` credentials are not inherited. Actual synthetic `.netrc` bait replaced both anonymous and selected-token authentication before HOME isolation; both cases are now pinned against the real Git/libcurl challenge. SSL_CERT_FILE and SSL_CERT_DIR references remain explicitly preserved. Pushes use a temporary local shared mirror of the explicit source repository, preserving all its refs/objects without copying configuration or hooks and without mutating source files/config. Preparation uses the existing check Deadline. Synthetic bait tests fail against the former caller-cwd behavior for anonymous helpers, askpass and extra headers, selected-token reads, and actual pushes; ordinary and linked-worktree subdirectory sources work. An independent cross-review found that the preceding `--bare` preparation dropped remote-tracking and notes refs. `--mirror` preserves every explicitly addressed local namespace. Real receiving- server tests pin heads/tags/remotes/notes, unchanged source bytes and hooks/config isolation; the mirror's own `remote.origin.mirror=true` does not turn the later explicit URL/refspec push into a mirror push or alter unrequested destination refs. - HTTP/MCP redirect answers reach the explicit policy before urllib's inherited redirect handler can parse Location. Invalid IPv6 brackets, malformed authorities, missing absolute-URL hosts, and invalid ports return `unsafe-redirect` ClientResults. URI references must be ASCII; spaces and non-ASCII characters require percent-encoding. Refusal text never includes the Location value. - Header-name handling is case-insensitive. Lowercase or uppercase Authorization cannot bypass the shared engine's cross-origin refusal. A rewrite to GET removes Content-Type, Content-Length, and Transfer-Encoding regardless of their casing. MCP HTTP-error results retain the scrubbed followed chain. `stage1_contract/tests/test_redirect_boundaries.py` adds twenty-two receiving- server regressions, including the real Git binary, real authentication challenge, verified fixture TLS, and local URL-scoped Git configuration. The new tests fail against the native candidate for the observed mechanisms; the anonymous Git chain remains compatible. Cancellation source behavior is unchanged. Additional actual SIGINT/SIGTERM probes against stage-2/3 guarded runner paths preserve completed and honest notrun rows, cancel the remaining rows, and exit 130; stage 3 cancelled before any check writes no invented report. The native `e2e-local-r2.log` is successful pre-correction evidence: its last write at 2026-10-14T10:08:36Z precedes the final native review-fix commits at 10:22–10:25Z, and it carries no exact source/tree receipt. The required full gate must therefore be run by Root on the final acceptance tree before a commit. This source work is not a deployment, live authenticated acceptance, or evidence that the full gate passed. ## Public MCP integration (2026-10-15, on base `a02eb9d`) The deployed, Root-accepted anonymous public catalog (`POST https://oss-oo.io/mcp/public`, merged `ad99aae`, live acceptance 2026-10-15 — contract inventory in the ROUTES doc) is integrated into the existing three-stage lane as OPTIONAL checks. No new framework, no SSE, no sessions, no auth on the public surface; every existing authenticated `/mcp` check is unchanged (the full pre-existing suite stayed green before the new rows were added). - **Client seam** (`av_common/clients/mcp.py`): `public_raw_post` / `public_initialize` / `public_list_tools` / `public_call_tool` mirror the authenticated helpers' shapes with NO token parameter anywhere (credential-freedom is structural). `public_read_file` walks `public_get_file` cursors to eof — base64-decoding each chunk, verifying offset continuity, per-chunk echo stability (owner/repo/path/commit/blob_sha/total_bytes), then the assembled git blob digest (`sha1("blob \0" + bytes)`) and total length; a truncated transfer (non-eof chunk without a cursor), a non-advancing cursor, or a digest mismatch raises `McpError` and can never pass. `public_list_tree` walks pages verifying one pinned commit, consecutive non-overlapping offsets, no duplicates, byte-wise order across the whole listing, and exact `has_more`/`next_cursor` termination. Both walkers are bounded only by the per-check Deadline (the genuine resource check) — no size or count caps were added. `av_common/clients/http.py` gained `header_facts` (Allow, Retry-After values + the lowercased header-NAME set, which proves ABSENCE of Access-Control-Allow-* — names only, never values), surfaced into `rest_request`/`mcp_raw_post` result meta. - **Stage 1**: `FakePublicMcp` in `stage1_contract/fakes.py` mirrors `mcp_public.py` + the `/mcp/public` serving (exactly four tools, HMAC cursors, uniform refusal, budget-before-upstream, POST-only method surface, no CORS) with hostile knobs (`truncate_chunks`, `corrupt_blob`, `rate_read`). 13 new `public-mcp/*` rows (`stage1_contract/run_stage1.py`) + 35 contract tests (`tests/test_public_mcp.py`). Stage-1 report grows 24 → 37 rows. - **Stage 2**: optional `public_mcp` config section {`url`, `query`, `repo`, `file_path`, `private_repo`} — fixture NAMES only; no credential envs exist on this surface. 7 new read-only rows (`run_public_mcp`), inert without the section, per-row honest notrun on partial config, validated shapes (owner/repo). Inert report grows 28 → 35 rows, exit 0. - **Stage 3**: optional per-environment `public_mcp` section (same keys) driving the same 7 read-only rows against the real route through its edge; inert without the section and without `--target`. Inert report grows 19 → 26 rows, exit 0. - **No caps added**: the walkers carry only the per-check deadline and a progress guarantee (a non-final page/chunk that cannot advance is refused) — genuine protocol-safety checks, not invented limits. - **Offline verification**: 18 files / 477 cases all green (stage1 166 = 131 + 35 new; stage2 119 = 105 + 14; stage3 192 = 166 + 26), plus `agent-gateway/test_public.py` (81) and `test_listing.py` (21) green; stage-1 runner 37/37 passed; stage-2 inert 35 notrun exit 0; stage-3 inert 26 notrun exit 0. - **Not claimed**: no configured stage-2 execution against a standing stack, no stage-3 real-route run of the new rows (both report `notrun` until such a round), and no client-acceptance claim of any kind (Codex-native proof is a separate package; the parent tool registry has not exposed these tools). ## Target-contract correction (2026-10-15, on base `64c979e`) Root's acceptance review of `64c979e` (CHANGES-REQUIRED, `ROOT-METHOD-TARGET-FINDING.json`) confirmed a real defect with a live read-only observation of `https://oss-oo.io/mcp/public`: the stage-2/3 method-surface evaluators unconditionally required the DIRECT-gateway shape (OPTIONS 204 + `Allow: POST`), but the deployed public EDGE answers GET and OPTIONS both 405 with NO `Allow` header and no CORS headers — so a stage-3 run configured at the real edge would have failed against already-accepted behavior. The independent review of `64c979e` (ACCEPT-WITH-FINDINGS) folded in the same round. - **Explicit target model**: the stage-2 `public_mcp` config section and the stage-3 environment `public_mcp` section now carry a REQUIRED `target` key — `direct-gateway` | `public-edge`. Anything else (or a missing target on a configured section) refuses the run with exit 2; inertness without the section is unchanged. The evaluator (`stage2_synthetic/checks.py`, `stage3_realroute/checks_public.py`) binds the FULL method contract to the declared target: direct-gateway = GET/DELETE 405 with `Allow: POST` + OPTIONS 204; public-edge = GET/DELETE/OPTIONS all 405 with NO `Allow` header (caddy's `respond` structurally sends none); NO `Access-Control-Allow-*` on ANY answer for either target (strengthened from OPTIONS-only to all three probes). Report detail text NAMES the target ("direct gateway target: …"/"public edge target: …"), so receipts are labeled and can never be misread as the other surface. The evaluator accepts neither arbitrary 204/405 nor a relabeled surface. - **Stage 1 pins BOTH surfaces**: `FakePublicMcp` gained an `edge_mode` knob mirroring the caddy edge (every non-POST — OPTIONS included — 405 text/plain, structurally no `Allow`, no CORS; POST and the `/mcp/healthz` liveness route still pass through to the catalog logic). New runner rows `public-mcp/edge-method-surface` (stage-1 report 37 → 39 rows) and mis-pairing negatives in both directions through the stage-2/stage-3 runner rigs (declared public-edge + answered 204 → FAIL; declared direct-gateway + answered 405 on OPTIONS → FAIL; Allow present at a declared edge → FAIL; Allow missing at a declared gateway → FAIL; unknown target → FAIL) plus positive pairings for both targets. - **Review finding 1 (LOW)**: `FakePublicMcp` now mirrors the service's snapshot-expiry cursor semantics — a continuation whose pinned snapshot vanished answers the DISTINCT bounded refusals (`snapshot expired; restart the listing` for list_tree; `snapshot expired; restart the file transfer` for get_file — the exact `mcp_public.py` texts), never the uniform not-found. Hostile `expire_snapshot` knob + new runner row `public-mcp/snapshot-expired-fails` + tests proving the client walk surfaces it as a failed walk with the distinct text (never a pass, never mislabeled as corruption). - **Review findings 2-3 (TRIVIAL)**: the stale multi-chunk fixture comment corrected to the actual shape (160 bytes, 3 chunks); the unused `import json as _json` removed. - **No product change**: gateway/Caddy behavior untouched; the checker learned the two-target truth, the servers did not move. - **Offline verification**: 18 files / 502 cases all green (stage1 176 = 166 + 10; stage2 125 = 119 + 6; stage3 201 = 192 + 9); stage-1 runner 39/39 passed; stage-2 inert 35 notrun exit 0; stage-3 inert 26 notrun exit 0. The 64c979e configured stage-2 receipts (32/0/3 incl. all `ssh/*` and `public-mcp/*` rows) ran against the DIRECT gateway URL — valid direct-gateway evidence, accurately labeled as such; the integrated public-edge lane acceptance is this round's configured edge run (disposable stack's caddy edge, `target=public-edge`), with receipts preserved in the round's execution evidence together with a direct-gateway run of the same board.