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.
50 KiB
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.shproves 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 /mcpandDELETE /mcpanswer 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 realtools/callread (get_file). - Cookie-jar (HTTP session) e2e evidence can never mark a browser check
passed; browser checks stay
unsupporteduntil a rendered-browser proof (kind: browser-run) exists, and real-client checks (Codex/ChatGPT/ZCode) only pass on verifiedclient-runreceipts — 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
stage2config section and stage 3 without--targetwrite all-notrunreports and exit 0. CI is detected (CI,GITHUB_ACTIONS,GITEA_ACTIONS,FORGEJO_ACTIONS,ACT) and both stages refuse real-route execution entirely (allnotrun, reasons nameci). - Canary writes (stage 3) are fail-closed behind
av_common.config.canary_gate— configcanary.enabledAND CLI--authorize-canaryAND not-CI AND an exact release binding ANDcanary.environment==--targetAND 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 honestnotrun, 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-archiveverifies 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_ASKPASShelper under a neutral env name withGIT_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/surfacechecks):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-pluginverification 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:
- Wrong-token negative retargeted to a distinct PRIVATE fixture
(
git.negative_repo, falling back torest.private_repo). The old leg aimed at the publicgit.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 128Authentication failed/Credentials are incorrect); a not-found or local failure can no longer fake a pass. The newgit-https/public-anonymous-readrow 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. - Branch readback reads the native schema: the Forgejo branch API
carries
commit.id(40-hex), nevercommit.sha(nativemain-branch-shape:commit_idset,commit_shanull). The GitHub-shaped readback now FAILS the identity proof instead of being the only shape that passed. - LFS presence via the protocol batch route: POST
.../info/lfs/objects/batchwith the native empty-download body andapplication/vnd.git-lfs+jsonmedia 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. - Package registry via
/api/v1/packages/{owner}(native: 200[]; the unversioned/api/packagesroute answers 404). The report row states API-presence only — an empty package-list 200 is never labeled an OCI transfer proof. - 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.pypin the honest shape (user create + mint underav-outsider; three distinct env names;git.negative_repopresent).
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 newgit.negative_repoowner/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
shaproperty 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}) carriescommit.sha("SHA is the commit SHA hash") andid("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".
- Explicit redirect & authentication-boundary policy
(
av_common/clients/http.pyopen_with_policy, applied identically bymcp.py; git pinshttp.followRedirects=initialin its argv — official git-config(1) citation in the ROUTES doc). Replaces bareurlopendefault following: authenticated requests never follow a cross-origin redirect (deliberateunsafe-redirectrefusal 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 inmeta["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 operatorSSL_CERT_FILEchanges. - 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.OperatorCancelledderives fromBaseExceptionprecisely so noexcept Exceptionprobe boundary can swallow it into a fake "failed check"; temp hygiene survives because the clients' askpass/ known_hosts cleanups live infinallyblocks. Stage 1 gained theINVENTORYconstant (drift-pinned against a complete run byStage1InventoryTests); 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 uncaughtValueErrorat_origin_of'sparts.port— escapingrest_request/mcp_raw_postwhose 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.txtagainst the unfixed engine — exactly the reviewer'sValueError: Port out of range 0-65535trace — andgreen-rev01-port.txtafter). Operator-supplied base URLs keep exactly their base behavior, A/B-probed against a4cc434atree extraction: a nonnumeric port still raiseshttp.client.InvalidURLfrom urllib's own parsing and an out-of-range port still maps to a status-0ClientResult— the new check guards only redirect Location values, and no unvalidated.portaccess remains on the redirect path (_origin_of/_chain_entrynow 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 RETURNSHandlers.SIG_DFL— an enum, fully restorable byrestore(); only aNonereturn marks a handler installed outside Python (a C-level dispositionsignal.signalcannot read back), whichrestore()leaves as is. Theinstall()/restore()docstrings inav_common/cancellation.pynow 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 —
OperatorCancelledderives fromBaseExceptionprecisely so probe boundaries cannot swallow it, and by that same property it skipsexecute_canary'sexcept Exceptioncleanup loop (stage3canary.py). How to check: the branch API 404 probe documented in the canary plan's cleanup steps. Recorded incancellation.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_entryre-brackets IPv6 literal hosts (urlsplit's.hostnamestrips the brackets) so scrubbedmeta["redirects"]entries likehttps://[::1]:8443/astay 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.initialwas 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-scopedfollowRedirects=truefrom defeating the general command-line setting. Only anonymous reads from explicitly named HTTP targets retaininitial, with no single-hop or single-origin guarantee. A separate real-binary HTTPS→HTTP anonymous probe had also returned success at the earlier acceptance treeb7b1cf4; 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
.netrccredentials are not inherited. Actual synthetic.netrcbait 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--barepreparation dropped remote-tracking and notes refs.--mirrorpreserves every explicitly addressed local namespace. Real receiving- server tests pin heads/tags/remotes/notes, unchanged source bytes and hooks/config isolation; the mirror's ownremote.origin.mirror=truedoes 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-redirectClientResults. 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_toolmirror the authenticated helpers' shapes with NO token parameter anywhere (credential-freedom is structural).public_read_filewalkspublic_get_filecursors 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 raisesMcpErrorand can never pass.public_list_treewalks pages verifying one pinned commit, consecutive non-overlapping offsets, no duplicates, byte-wise order across the whole listing, and exacthas_more/next_cursortermination. 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.pygainedheader_facts(Allow, Retry-After values + the lowercased header-NAME set, which proves ABSENCE of Access-Control-Allow-* — names only, never values), surfaced intorest_request/mcp_raw_postresult meta. - Stage 1:
FakePublicMcpinstage1_contract/fakes.pymirrorsmcp_public.py+ the/mcp/publicserving (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 newpublic-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_mcpconfig 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_mcpsection (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) andtest_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
notrununtil 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_mcpconfig section and the stage-3 environmentpublic_mcpsection now carry a REQUIREDtargetkey —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 withAllow: POST+ OPTIONS 204; public-edge = GET/DELETE/OPTIONS all 405 with NOAllowheader (caddy'srespondstructurally sends none); NOAccess-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:
FakePublicMcpgained anedge_modeknob mirroring the caddy edge (every non-POST — OPTIONS included — 405 text/plain, structurally noAllow, no CORS; POST and the/mcp/healthzliveness route still pass through to the catalog logic). New runner rowspublic-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):
FakePublicMcpnow mirrors the service's snapshot-expiry cursor semantics — a continuation whose pinned snapshot vanished answers the DISTINCT bounded refusals (snapshot expired; restart the listingfor list_tree;snapshot expired; restart the file transferfor get_file — the exactmcp_public.pytexts), never the uniform not-found. Hostileexpire_snapshotknob + new runner rowpublic-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 _jsonremoved. - 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
64c979econfigured stage-2 receipts (32/0/3 incl. allssh/*andpublic-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.