laboratory/docs/ACCESS-VALIDATION-IMPLEMENTATION.md
Claudiu Schuster 7a629ff660
All checks were successful
access-validation-contract / contract (push) Successful in 4m3s
ci / ci (push) Successful in 5m30s
Make access-validation evidence and transport facts reliable
Add explicit acceptance outcomes and operation facts. Reject contradictory receipts and stale or failed stage evidence. Preserve opt-in native and client prerequisites.

Validated by independent source review, targeted regressions, full isolated PVE E2E and current Forge CI.
2026-10-28 21:45:03 +00:00

50 KiB
Raw Permalink Blame History

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 <cfg> --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 <cfg> --target <env> [--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):

# 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 '<vm-throwaway>' \
  --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:

$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)

{
  "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):

{"mcpServers": {"oss-oo": {"url": "https://oss-oo.io/mcp",
  "headers": {"Authorization": "Bearer <forge token>"}}}}
  • 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/<tag>, 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 — 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 <len>\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.