From 9867c7769b955923e8b6c6d51a2a9316d3fb438e Mon Sep 17 00:00:00 2001 From: rocklambros Date: Sat, 5 Sep 2026 13:15:39 -0600 Subject: [PATCH 01/34] Add design for GitHub Pages landing page and schema hosting Records the design for publishing three artifacts from this repository to GitHub Pages on every merge to main: a landing page matching the visual design of agentcontrolstandard.org, the existing MkDocs specification site, and the JSON schemas. The schema piece closes a known gap. All 44 schemas declare an $id under the project-controlled GitHub Pages base, but Pages has never been enabled, so every one of those URIs returns 404. Publish paths derive from each schema's own $id rather than from hardcoded directory names, and the build asserts that every $id and $ref in the package resolves to a published file. A future spec version directory publishes with no workflow edit. Drops the Google Analytics block from the site config. Verified against the real config that Material emits a googletagmanager script tag with an empty ID when no key is set, including when the variable is unset entirely, which leaks referrer and client IP for no benefit. --- design/2026-09-05-github-pages-landing.md | 241 ++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 design/2026-09-05-github-pages-landing.md diff --git a/design/2026-09-05-github-pages-landing.md b/design/2026-09-05-github-pages-landing.md new file mode 100644 index 0000000..ede89dc --- /dev/null +++ b/design/2026-09-05-github-pages-landing.md @@ -0,0 +1,241 @@ +# GitHub Pages landing page and schema hosting + +Version: 1.0 +Owner: ACS project lead +Date: 2026-09-05 +Status: approved design, not yet implemented + +## Goal + +Publish three things from this repository to GitHub Pages, rebuilt on every merge to `main`: + +1. A landing page that matches the visual design of agentcontrolstandard.org. +2. The existing MkDocs specification site. +3. The JSON schemas, served at the URIs their `$id` values already declare. + +Item 3 closes a known gap. Every schema in `specification/` declares an `$id` under +`https://genai-security-project.github.io/agent-control-standard/schema//`. +Pages has never been enabled, so all 44 of those URIs return 404. Enabling Pages is the +precondition for fixing it. + +## Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Site scope | Landing page, docs, and schemas | The eventual domain redirect makes this the public front door. A front door needs somewhere to lead. | +| Build approach | Hand-authored HTML plus MkDocs, assembled by one workflow | Exact design fidelity with no new dependency tree. See "Approaches rejected". | +| Landing page content | Mirror the live site, plus repo-native sections | Continuity for visitors after the redirect, and the page can show live spec data the marketing site cannot. | +| Analytics | None | Material emits a Google tag with an empty ID when no key is set, leaking referrer and client IP for no benefit. Removed rather than configured. | +| Contact address | `rock.lambros@owasp.org` on the page | Amends the no-email policy in `CLAUDE.md` with a named exception. | +| Custom domain | Not yet | `agentcontrolstandard.org` redirects here in a later, separate change. | + +## Architecture + +One workflow assembles three independent parts into a single Pages artifact. + +``` +_site/ + index.html landing page, hand-authored, generated content injected + assets/ stylesheet, fonts, starburst SVG, favicon + docs/ mkdocs build output + schema/v0.1.0/ 44 schemas, placed at the paths their $id values declare +``` + +Each part builds independently. A failure in any part fails the whole deploy, which is +deliberate: a half-published site is worse than a stale one. + +### Approaches rejected + +**MkDocs theme override for the landing page.** One build system, but Material's chrome, +CSS reset, and typography fight a full-bleed custom hero. Design fidelity is the reason +this work exists, so the approach trades away the thing being bought. + +**Port the Next.js source.** Pixel-identical by construction, but it adds a Node toolchain +and a second dependency tree to a Python repository, widens supply-chain surface against +QC.1, and the source is not available. Reconstructing it from minified chunks costs more +than writing the page. + +## Schema publishing + +The publish path for each schema derives from that schema's own `$id`. Nothing hardcodes +directory names. + +``` +tools/publish_schemas.py + +BASE = "https://genai-security-project.github.io/agent-control-standard/schema/" + +1. Read every *.json under specification/. +2. Require an $id inside BASE. Fail the build on a missing or out-of-namespace $id. +3. Write the file to the path its $id declares. +4. Resolve every relative $ref against its enclosing $id. + Fail the build if the target was not published. +``` + +Step 4 asserts closure. The package either resolves completely or the build stops. + +This design fixes the root cause of a failure that hardcoded paths would reintroduce. The +on-disk layout does not match the URI layout: `specification/ACS/acs_schema.json` declares +`$id` of `.../schema/v0.1.0/acs_schema.json`. Deriving the target from `$id` handles that +without a special case, and a future `specification/v0.2.0/` publishes with no workflow +edit. + +Current tree verified: 44 JSON files, 44 in-namespace `$id` values, and no `$ref` resolves +outside the `/schema/` base. + +`$id` is versioned by spec version, not release version. `version.txt` reads `0.1.1` while +the spec version is `v0.1.0`. The two are separate concepts. `sync_version.py` leaves `$id` +alone by design, and this workflow does the same. + +## Landing page + +### Design tokens + +Values taken from the live site's stylesheet, not approximated. + +| Token | Light | Dark | +|---|---|---| +| page / surface | `#ffffff` / `#f4f5f7` | `#0a0a0a` / `#161616` | +| text / soft / muted | `#121212` / `#5f636d` / `#6b7079` | `#ffffff` / `#9ca3af` / `#6b7280` | +| brand | `#111111` | `#1b4f72` | +| accent navy / teal | `#1b4f72` / `#17a2b8` | `#2e86c1` / `#1abc9c` | +| border / border strong | `#e5e7eb` / `#d0d5dd` | `#2a2a2a` / `#373737` | +| footer | `#111111` | `#0a0a0a` | + +Tier accents carry to the three-tier section: `#0f7b3f`, `#1b4f72`, `#6b46c1`. + +Typography is Inter for text and JetBrains Mono for code, each with a full system fallback +stack. Both themes ship, with a toggle that persists the reader's choice and falls back to +`prefers-color-scheme`. + +### The starburst + +The hero diagram reuses the live site's SVG: a hexagonal ACS control panel at center, six +dashed spokes radiating to circular nodes labeled LLM agent, Tool call, Output guard, Sub +agent, Memory store, and Code exec. Particles travel the spokes. Orbit rings expand on an +eight second cycle. + +Two changes. Node fills and strokes bind to theme tokens so the diagram works in dark mode. +All motion sits behind a `prefers-reduced-motion` guard, with a static fallback that keeps +every node, spoke, and label legible. + +### Structure + +``` +Sidebar nav wordmark, section links, external resources, theme toggle +Hero "The runtime control plane for AI agents." plus starburst +The problem agents ship fast, controls do not +The solution Instrument, Trace, Inspect +How it works three-tier control model +Why now EU AI Act, NIST AI RMF +Built with OWASP ASI, AIVSS, OpenTelemetry, CycloneDX, SPDX, MCP, A2A +Spec status current spec version and schema index, generated at build time +Workstreams generated from GOVERNANCE.md +Contribute Slack, GitHub Discussions, contact address +Footer Apache 2.0, vendor neutral, OWASP GenAI Security Project +``` + +Spec status and Workstreams are new sections that the marketing site cannot serve. Both +generate from repository state so they cannot drift. + +Every specification link points at `docs/` on this site. No link references `aos.owasp.org`. + +### Contact + +- Slack: `owasp.slack.com`, channel `#team-genai-asi-acs-general` +- GitHub Discussions +- General contact: `rock.lambros@owasp.org` + +Security reports continue to route through GitHub private vulnerability reporting. Code of +Conduct enforcement continues to route to the OWASP process, so a report about a maintainer +never lands with the maintainers. The `CLAUDE.md` Contact channels section gets amended in +the same commit to record the exception. + +### Accessibility and layout + +Links use relative paths. Root-relative paths break because a project Pages site serves from +`/agent-control-standard/`, not `/`. + +The sidebar collapses to a top bar below 1024px. The starburst scales and moves below the +hero copy on narrow screens. Semantic landmarks throughout, visible focus rings using the +source `--acs-focus-ring` value, and the page works with JavaScript disabled apart from the +theme toggle. + +## Pipeline + +``` +build (push to main, pull_request, workflow_dispatch) + 1. uv sync --locked + 2. mkdocs build --strict -> _site/docs/ + 3. render landing page, injecting spec version and workstreams -> _site/ + 4. python tools/publish_schemas.py -> _site/schema/ + 5. upload-pages-artifact (push only) + +deploy (push to main only) + needs: build + 6. deploy-pages + 7. smoke test: /schema/v0.1.0/acs_schema.json returns 200 +``` + +Permissions are `contents: read`, `pages: write`, `id-token: write`. Concurrency group +`pages` with `cancel-in-progress: false`, so a running deploy never gets cancelled into a +partial state. Actions are SHA-pinned, matching the two existing workflows. + +Pull requests build without deploying. A broken build surfaces before merge rather than +after, which matters because merge to `main` publishes with no human in the loop. + +The trigger is `pull_request`, never `pull_request_target`. Fork pull requests get a +read-only token and no access to repository secrets. + +`--strict` turns a broken navigation reference into a failed build. Step 7 asserts that the +gap this project set out to close did close. + +### Supporting changes + +| File | Change | +|---|---| +| `mkdocs.yml` | Remove the `extra.analytics` block. No env value suppresses the Google tag, so the block itself has to go. | +| `.gitignore` | Add `_site/`, so a local build leaves no untracked output. | +| `CLAUDE.md` | Amend Contact channels to record the address exception. Add a Hosting section describing what this repository now publishes. | + +The workflow sets `GITHUB_PAGES_URL` for the MkDocs build, because `mkdocs.yml` reads +`site_url` from that variable. An unset value produces a site with no canonical URL. + +Generated page content injects at build time, not in the browser. A small script fills +named placeholders in the HTML template from `specification/` and `GOVERNANCE.md`. The +published page is static, so it needs no client-side fetch and renders with JavaScript +disabled. + +## Risks accepted + +**A docs failure blocks schema publishing.** One artifact means one deploy. A broken prose +link fails the build that also republishes schemas. Accepted because Pages keeps serving the +last successful deployment, so published schema URIs continue to resolve. Only new schema +changes wait, behind a visible red build on `main`. Splitting into two deploy targets adds +real complexity for a low-severity risk. + +**The landing page is hand-maintained markup.** Volatile content generates from repository +state, but prose does not. A page that changes a few times a year is the cheaper side of +this trade. + +## Domain cutover + +Pointing `agentcontrolstandard.org` at this site later adds a `CNAME` file. GitHub then +issues a 301 from `genai-security-project.github.io/agent-control-standard/*` to the custom +domain. + +Schema resolution survives, because JSON Schema tooling follows redirects. The `$id` URIs +stop being the address that answers directly and become the address that redirects. The +recorded reason for choosing a project-controlled base was that schema identity survives a +domain or hosting change, and a redirect honors that. + +Do not rebase `$id` onto the marketing domain during the cutover. + +## Out of scope + +- Enabling the custom domain. +- Changes to the separate repository that builds the current agentcontrolstandard.org. + Stale links there, including the `aos.owasp.org` specification link, live in that + repository and resolve when the redirect lands. +- Seven A2A hook pages under `docs/spec/instrument/a2a/hooks/` are absent from the MkDocs + navigation. They publish as orphans reachable only by direct URL. Tracked separately. From c4e86ee1b88d2d0511afdfeb8341dc1384b51f20 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Sat, 5 Sep 2026 13:31:28 -0600 Subject: [PATCH 02/34] Add implementation plan for the GitHub Pages site Six tasks, each ending in an independently testable deliverable: the schema publisher, the landing page, build-time content injection, the analytics removal, the deploy workflow, and Pages enablement. Both pieces of custom logic in the plan were run against real repository data before the plan was written down. The publisher places all 44 schemas at their declared paths, including relocating acs_schema.json out of ACS/ into v0.1.0/, and passes ref closure. The governance parser reads five workstreams and skips the project lead table above it. --- design/plans/2026-09-05-github-pages-site.md | 1623 ++++++++++++++++++ 1 file changed, 1623 insertions(+) create mode 100644 design/plans/2026-09-05-github-pages-site.md diff --git a/design/plans/2026-09-05-github-pages-site.md b/design/plans/2026-09-05-github-pages-site.md new file mode 100644 index 0000000..7934616 --- /dev/null +++ b/design/plans/2026-09-05-github-pages-site.md @@ -0,0 +1,1623 @@ +# GitHub Pages Site Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish a landing page, the MkDocs specification site, and all 44 JSON schemas to GitHub Pages on every merge to `main`, so that schema `$id` URIs resolve for the first time. + +**Architecture:** One GitHub Actions workflow assembles three independent parts into a single Pages artifact. The landing page is hand-authored HTML and CSS with build-time content injection. Schema publish paths derive from each schema's own `$id`, and the build fails if any `$id` or `$ref` in the package does not resolve. + +**Tech Stack:** Python 3.11+, uv, MkDocs Material, pytest, GitHub Actions, hand-authored HTML and CSS with no JavaScript framework. + +**Spec:** `design/2026-09-05-github-pages-landing.md` + +## Global Constraints + +- Python `>=3.11`, matching `pyproject.toml` `requires-python`. +- uv pinned to `0.9.9` in CI, matching `.github/workflows/sync_version.yml`. +- Every GitHub Action SHA-pinned with a trailing version comment. Reuse the pins already in this repository where they exist. +- Workflow-level `permissions: {}`. Jobs grant only what they need. +- Schema `$id` values must not change. `$id` is versioned by spec version (`v0.1.0`), not release version (`version.txt`, currently `0.1.1`). +- No email address anywhere in the repository except `rock.lambros@owasp.org` on the landing page. +- No link may reference `aos.owasp.org`. +- Landing page links must be relative (`docs/`), never root-relative (`/docs/`). A project Pages site serves from `/agent-control-standard/`. +- Prose follows `STYLE.md`. Avoid em dashes, semicolons, sentences starting with conjunctions, and filler words (just, very, really, actually, certainly, basically, literally, utilize, facilitate, leverage, robust, seamless, transformative, holistic, unlock, unleash, empower). +- Never credit an AI in commit messages, code comments, file headers, or documentation. +- All work lands on branch `feat/github-pages-site`. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `tools/publish_schemas.py` | Place each schema at the path its `$id` declares. Verify every `$ref` resolves. | +| `tools/render_landing.py` | Replace named placeholders in the landing page from repository state. | +| `landing/index.html` | Landing page markup and copy. | +| `landing/assets/acs.css` | Design tokens, layout, light and dark themes. | +| `landing/assets/starburst.svg` | Hero diagram. | +| `landing/assets/icon.svg` | Favicon. | +| `tests/test_publish_schemas.py` | Schema publishing and ref-closure tests. | +| `tests/test_render_landing.py` | Content injection tests. | +| `tests/test_site_config.py` | Regression test that no analytics tag ships. | +| `.github/workflows/deploy-pages.yml` | Build and deploy. | +| `mkdocs.yml` | Modified: remove `extra.analytics`. | +| `.gitignore` | Modified: add `_site/`. | +| `pyproject.toml` | Modified: add a `dev` dependency group with pytest. | +| `CLAUDE.md` | Modified: contact exception, hosting section. | + +--- + +## Task 1: Schema publisher + +Publishes schemas to the paths their own `$id` values declare, and fails the build if the package does not resolve completely. This is the task that closes the 404 gap and keeps it closed. + +**Files:** +- Create: `tools/publish_schemas.py` +- Create: `tests/test_publish_schemas.py` +- Modify: `pyproject.toml` +- Modify: `uv.lock` (regenerated) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `BASE: str` module constant, the schema namespace prefix. + - `class SchemaError(Exception)` + - `load_schemas(source: Path) -> dict[Path, dict]` + - `target_for(doc: dict, path: Path) -> str` returning the `$id`-relative path, for example `v0.1.0/acs_schema.json`. + - `iter_refs(node: object) -> Iterator[str]` + - `publish(source: Path, out: Path) -> list[str]` returning sorted relative paths. + - CLI: `python tools/publish_schemas.py `. + +- [ ] **Step 1: Add pytest as a dev dependency** + +Append to `pyproject.toml`: + +```toml +[dependency-groups] +dev = [ "pytest>=8.0",] +``` + +- [ ] **Step 2: Regenerate the lockfile** + +Run: `uv lock` +Expected: `uv.lock` updates to include pytest and its dependencies. + +- [ ] **Step 3: Write the failing tests** + +Create `tests/test_publish_schemas.py`: + +```python +"""Tests for the schema publisher.""" +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) + +from publish_schemas import BASE, SchemaError, iter_refs, publish, target_for + + +def write_schema(root: Path, rel: str, sid: str, body: dict | None = None) -> Path: + """Write a schema file at rel with the given $id.""" + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + doc = {"$id": sid} + doc.update(body or {}) + path.write_text(json.dumps(doc), encoding="utf-8") + return path + + +def test_target_for_strips_the_namespace_base(): + doc = {"$id": BASE + "v0.1.0/acs_schema.json"} + assert target_for(doc, Path("any.json")) == "v0.1.0/acs_schema.json" + + +def test_target_for_rejects_a_missing_id(): + with pytest.raises(SchemaError, match="no \\$id"): + target_for({}, Path("broken.json")) + + +def test_target_for_rejects_an_out_of_namespace_id(): + doc = {"$id": "https://example.com/schema/v0.1.0/x.json"} + with pytest.raises(SchemaError, match="outside namespace"): + target_for(doc, Path("broken.json")) + + +def test_iter_refs_finds_nested_and_listed_refs(): + doc = { + "$ref": "a.json", + "properties": {"x": {"$ref": "b.json"}}, + "anyOf": [{"$ref": "c.json"}], + } + assert sorted(iter_refs(doc)) == ["a.json", "b.json", "c.json"] + + +def test_publish_places_files_at_their_declared_id_path(tmp_path): + src, out = tmp_path / "spec", tmp_path / "out" + # On-disk layout deliberately differs from the URI layout. + write_schema(src, "ACS/acs_schema.json", BASE + "v0.1.0/acs_schema.json") + published = publish(src, out) + assert published == ["v0.1.0/acs_schema.json"] + assert (out / "v0.1.0" / "acs_schema.json").is_file() + + +def test_publish_resolves_a_parent_relative_ref(tmp_path): + src, out = tmp_path / "spec", tmp_path / "out" + write_schema(src, "v0.1.0/provenance.json", BASE + "v0.1.0/provenance.json") + write_schema( + src, + "v0.1.0/hooks/session-start.json", + BASE + "v0.1.0/hooks/session-start.json", + {"properties": {"p": {"$ref": "../provenance.json"}}}, + ) + assert len(publish(src, out)) == 2 + + +def test_publish_fails_on_a_dangling_ref(tmp_path): + src, out = tmp_path / "spec", tmp_path / "out" + write_schema( + src, + "v0.1.0/a.json", + BASE + "v0.1.0/a.json", + {"properties": {"p": {"$ref": "./missing.json"}}}, + ) + with pytest.raises(SchemaError, match="which no \\$id publishes"): + publish(src, out) + + +def test_publish_ignores_a_self_fragment_ref(tmp_path): + src, out = tmp_path / "spec", tmp_path / "out" + write_schema( + src, + "v0.1.0/a.json", + BASE + "v0.1.0/a.json", + {"properties": {"p": {"$ref": "#/$defs/x"}}, "$defs": {"x": {"type": "string"}}}, + ) + assert publish(src, out) == ["v0.1.0/a.json"] + + +def test_publish_ignores_an_external_ref(tmp_path): + src, out = tmp_path / "spec", tmp_path / "out" + write_schema( + src, + "v0.1.0/a.json", + BASE + "v0.1.0/a.json", + {"properties": {"p": {"$ref": "https://json-schema.org/draft/2020-12/schema"}}}, + ) + assert publish(src, out) == ["v0.1.0/a.json"] + + +def test_publish_fails_when_no_schemas_are_found(tmp_path): + with pytest.raises(SchemaError, match="no schemas found"): + publish(tmp_path / "empty", tmp_path / "out") + + +def test_publish_handles_the_real_specification_tree(tmp_path): + """The repository's own schemas must publish and resolve completely.""" + repo = Path(__file__).resolve().parents[1] + published = publish(repo / "specification", tmp_path / "out") + assert len(published) == 44 + assert "v0.1.0/acs_schema.json" in published +``` + +- [ ] **Step 4: Run the tests to verify they fail** + +Run: `uv run pytest tests/test_publish_schemas.py -v` +Expected: FAIL, collection error `ModuleNotFoundError: No module named 'publish_schemas'` + +- [ ] **Step 5: Write the implementation** + +Create `tools/publish_schemas.py`: + +```python +#!/usr/bin/env python3 +"""Publish JSON schemas to the paths their own $id values declare. + +The on-disk layout does not match the URI layout. specification/ACS/acs_schema.json +declares an $id of .../schema/v0.1.0/acs_schema.json, so deriving the destination from +$id avoids a hardcoded special case and lets a future spec version publish untouched. +""" +from __future__ import annotations + +import json +import shutil +import sys +from collections.abc import Iterator +from pathlib import Path +from urllib.parse import urldefrag, urljoin + +BASE = "https://genai-security-project.github.io/agent-control-standard/schema/" + + +class SchemaError(Exception): + """A schema is missing an $id, sits outside the namespace, or has a dangling $ref.""" + + +def load_schemas(source: Path) -> dict[Path, dict]: + """Parse every JSON file under source, keyed by path.""" + return { + path: json.loads(path.read_text(encoding="utf-8")) + for path in sorted(source.rglob("*.json")) + } + + +def target_for(doc: dict, path: Path) -> str: + """Return the publish path a document's $id declares, relative to the schema root.""" + sid = doc.get("$id") + if not sid: + raise SchemaError(f"{path}: no $id") + if not sid.startswith(BASE): + raise SchemaError(f"{path}: $id outside namespace: {sid}") + return sid[len(BASE) :] + + +def iter_refs(node: object) -> Iterator[str]: + """Yield every $ref string anywhere in a parsed document.""" + if isinstance(node, dict): + for key, value in node.items(): + if key == "$ref" and isinstance(value, str): + yield value + else: + yield from iter_refs(value) + elif isinstance(node, list): + for item in node: + yield from iter_refs(item) + + +def verify_refs(docs: dict[Path, dict], published: dict[str, str]) -> None: + """Fail if a $ref inside our namespace points at something we did not publish.""" + for path, doc in docs.items(): + sid = doc["$id"] + for ref in iter_refs(doc): + target, _ = urldefrag(urljoin(sid, ref)) + # A same-document fragment and an external reference are both fine. + if target == sid or not target.startswith(BASE): + continue + if target not in published: + raise SchemaError( + f"{path}: $ref {ref!r} resolves to {target}, which no $id publishes" + ) + + +def publish(source: Path, out: Path) -> list[str]: + """Copy every schema to its $id-declared path. Return sorted relative paths.""" + docs = load_schemas(source) + if not docs: + raise SchemaError(f"no schemas found under {source}") + published: dict[str, str] = {} + for path, doc in docs.items(): + rel = target_for(doc, path) + destination = out / rel + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(path, destination) + published[doc["$id"]] = rel + verify_refs(docs, published) + return sorted(published.values()) + + +def main(argv: list[str]) -> int: + source = Path(argv[1]) if len(argv) > 1 else Path("specification") + out = Path(argv[2]) if len(argv) > 2 else Path("_site/schema") + try: + files = publish(source, out) + except SchemaError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + print(f"published {len(files)} schemas to {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `uv run pytest tests/test_publish_schemas.py -v` +Expected: PASS, 11 tests + +- [ ] **Step 7: Run the publisher against the real tree** + +Run: `uv run python tools/publish_schemas.py specification /tmp/schema-check` +Expected: `published 44 schemas to /tmp/schema-check` + +- [ ] **Step 8: Commit** + +```bash +git add pyproject.toml uv.lock tools/publish_schemas.py tests/test_publish_schemas.py +git commit -m "Publish schemas to the paths their \$id values declare + +Derives each destination from the schema's own \$id rather than from +hardcoded directory names, then asserts that every \$ref inside the +namespace resolves to a published file. + +The on-disk layout does not match the URI layout, so a hardcoded copy +needs a special case for specification/ACS/acs_schema.json and breaks +again on the first new spec version directory." +``` + +--- + +## Task 2: Landing page markup, styles, and assets + +Builds the static page. Content that varies with repository state uses named placeholders that Task 3 fills. + +**Files:** +- Create: `landing/index.html` +- Create: `landing/assets/acs.css` +- Create: `landing/assets/starburst.svg` +- Create: `landing/assets/icon.svg` +- Create: `tests/test_landing_page.py` +- Modify: `CLAUDE.md` + +**Interfaces:** +- Consumes: nothing. +- Produces: three placeholder tokens that Task 3 replaces, spelled exactly ``, ``, ``. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_landing_page.py`: + +```python +"""Structural guards on the landing page. These encode premortem findings.""" +import re +from pathlib import Path + +import pytest + +LANDING = Path(__file__).resolve().parents[1] / "landing" +HTML = LANDING / "index.html" +CSS = LANDING / "assets" / "acs.css" +SVG = LANDING / "assets" / "starburst.svg" + +PLACEHOLDERS = ["", "", ""] + + +@pytest.fixture(scope="module") +def html() -> str: + return HTML.read_text(encoding="utf-8") + + +def test_every_placeholder_is_present(html): + for token in PLACEHOLDERS: + assert token in html + + +def test_no_root_relative_links(html): + """A project Pages site serves from /agent-control-standard/, so /docs/ would 404.""" + assert not re.search(r'(href|src)="/(?!/)', html) + + +def test_no_retired_project_links(html): + assert "aos.owasp.org" not in html + + +def test_the_only_email_is_the_approved_one(html): + found = set(re.findall(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", html)) + assert found == {"rock.lambros@owasp.org"} + + +def test_slack_points_at_the_owasp_workspace(html): + assert "owasp.slack.com" in html + assert "#team-genai-asi-acs-general" in html + + +def test_dark_theme_tokens_are_defined(): + css = CSS.read_text(encoding="utf-8") + assert "--acs-page" in css + assert 'data-theme="dark"' in css + assert "prefers-color-scheme: dark" in css + + +def test_animation_respects_reduced_motion(): + css = CSS.read_text(encoding="utf-8") + assert "prefers-reduced-motion: reduce" in css + + +def test_starburst_has_six_nodes_and_a_centre(): + svg = SVG.read_text(encoding="utf-8") + for label in ["LLM", "Tool", "Output", "Sub", "Memory", "Code", "ACS"]: + assert label in svg +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/test_landing_page.py -v` +Expected: FAIL, `FileNotFoundError` for `landing/index.html` + +- [ ] **Step 3: Write the design tokens and layout** + +Create `landing/assets/acs.css`. Token values are taken from the live site's stylesheet, not approximated. + +```css +/* ACS landing page. Tokens mirror agentcontrolstandard.org. */ + +:root { + --acs-page: #ffffff; + --acs-surface: #f4f5f7; + --acs-surface-2: #eef0f4; + --acs-text: #121212; + --acs-text-soft: #5f636d; + --acs-text-muted: #6b7079; + --acs-text-inverse: #f7f7f7; + --acs-brand: #111111; + --acs-accent-navy: #1b4f72; + --acs-accent-teal: #17a2b8; + --acs-border: #e5e7eb; + --acs-border-strong: #d0d5dd; + --acs-footer: #111111; + --acs-focus-ring: hsla(0, 0%, 7%, 0.18); + --acs-grid-line: hsla(0, 0%, 7%, 0.04); + --acs-node-fill: #ffffff; + --acs-node-stroke: #6b7079; + --acs-spoke: #6b7079; + --acs-hex-fill: #f4f5f7; + --acs-hex-stroke: #c4cdd8; + --acs-tier-1: #0f7b3f; + --acs-tier-2: #1b4f72; + --acs-tier-3: #6b46c1; + --acs-font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --acs-mono: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, monospace; +} + +/* Dark tokens are redefined in two places so the toggle wins in both directions. */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --acs-page: #0a0a0a; + --acs-surface: #161616; + --acs-surface-2: #202020; + --acs-text: #ffffff; + --acs-text-soft: #9ca3af; + --acs-text-muted: #6b7280; + --acs-brand: #1b4f72; + --acs-accent-navy: #2e86c1; + --acs-accent-teal: #1abc9c; + --acs-border: #2a2a2a; + --acs-border-strong: #373737; + --acs-footer: #0a0a0a; + --acs-focus-ring: hsla(0, 0%, 100%, 0.2); + --acs-grid-line: hsla(0, 0%, 100%, 0.04); + --acs-node-fill: #111111; + --acs-node-stroke: #a0aec0; + --acs-spoke: #3d4f65; + --acs-hex-fill: #0d1117; + --acs-hex-stroke: #63b3ed; + --acs-tier-1: #48bb78; + --acs-tier-2: #63b3ed; + --acs-tier-3: #805ad5; + } +} + +:root[data-theme="dark"] { + --acs-page: #0a0a0a; + --acs-surface: #161616; + --acs-surface-2: #202020; + --acs-text: #ffffff; + --acs-text-soft: #9ca3af; + --acs-text-muted: #6b7280; + --acs-brand: #1b4f72; + --acs-accent-navy: #2e86c1; + --acs-accent-teal: #1abc9c; + --acs-border: #2a2a2a; + --acs-border-strong: #373737; + --acs-footer: #0a0a0a; + --acs-focus-ring: hsla(0, 0%, 100%, 0.2); + --acs-grid-line: hsla(0, 0%, 100%, 0.04); + --acs-node-fill: #111111; + --acs-node-stroke: #a0aec0; + --acs-spoke: #3d4f65; + --acs-hex-fill: #0d1117; + --acs-hex-stroke: #63b3ed; + --acs-tier-1: #48bb78; + --acs-tier-2: #63b3ed; + --acs-tier-3: #805ad5; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: var(--acs-font); + color: var(--acs-text); + background-color: var(--acs-page); + background-image: linear-gradient(var(--acs-grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--acs-grid-line) 1px, transparent 1px); + background-size: 64px 64px; + line-height: 1.6; +} + +a { color: inherit; } +a:focus-visible, +button:focus-visible { outline: 3px solid var(--acs-focus-ring); outline-offset: 2px; } + +.layout { display: grid; grid-template-columns: 260px 1fr; } + +.sidebar { + position: sticky; + top: 0; + align-self: start; + height: 100vh; + padding: 2rem 1.5rem; + border-right: 1px solid var(--acs-border); + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.wordmark { font-weight: 700; letter-spacing: 0.12em; font-size: 1.1rem; } +.sidebar nav { display: flex; flex-direction: column; gap: 0.6rem; } +.sidebar nav a { text-decoration: none; color: var(--acs-text-soft); } +.sidebar nav a:hover { color: var(--acs-text); } +.sidebar h2 { font-size: 0.75rem; text-transform: uppercase; color: var(--acs-text-muted); } + +main { padding: 4rem 3rem; max-width: 1100px; } +section { padding-block: 3.5rem; border-top: 1px solid var(--acs-border); } +section:first-of-type { border-top: 0; } + +.hero { display: grid; grid-template-columns: 1fr 1fr; gap: 3rem; align-items: center; } +.hero h1 { font-size: clamp(2.5rem, 6vw, 4.5rem); line-height: 1.02; letter-spacing: -0.03em; margin: 0; } +.hero p { font-size: 1.15rem; color: var(--acs-text-soft); } + +.cta-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1.5rem; } +.cta { + display: inline-flex; + align-items: center; + min-height: 50px; + padding: 0.85rem 1.25rem; + border: 1px solid var(--acs-border-strong); + border-radius: 999px; + font-weight: 600; + text-decoration: none; + transition: transform 0.15s ease, background-color 0.15s ease; +} +.cta:hover { background-color: var(--acs-surface); transform: translateY(-1px); } +.cta-primary { background-color: var(--acs-brand); color: var(--acs-text-inverse); border-color: var(--acs-brand); } + +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1.25rem; } +.card { padding: 1.5rem; border: 1px solid var(--acs-border); border-radius: 14px; background-color: var(--acs-surface); } +.card h3 { margin-top: 0; } + +.tier { border-left: 4px solid var(--acs-border-strong); padding-left: 1rem; margin-bottom: 1.25rem; } +.tier-1 { border-left-color: var(--acs-tier-1); } +.tier-2 { border-left-color: var(--acs-tier-2); } +.tier-3 { border-left-color: var(--acs-tier-3); } + +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 0.6rem 0.5rem; border-bottom: 1px solid var(--acs-border); } +code { font-family: var(--acs-mono); background-color: var(--acs-surface-2); padding: 0.15em 0.4em; border-radius: 4px; } + +footer { background-color: var(--acs-footer); color: var(--acs-text-inverse); padding: 3rem; } +footer a { color: var(--acs-text-inverse); } + +@media (max-width: 1024px) { + .layout { grid-template-columns: 1fr; } + .sidebar { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--acs-border); } + .hero { grid-template-columns: 1fr; } + main { padding: 2rem 1.25rem; } +} + +/* Premortem FM-6: continuous motion is a vestibular trigger and a battery cost. */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; } + .starburst animate, + .starburst animateMotion { display: none; } +} +``` + +- [ ] **Step 4: Write the starburst** + +Create `landing/assets/starburst.svg`. Geometry is taken from the live site: a `0 0 680 680` viewBox, centre at `(340, 340)`, six nodes of radius 48, and a centre hexagon. Node centres, clockwise from top: `(340, 80)` LLM agent, `(565.17, 210)` Tool call, `(565.17, 470)` Output guard, `(340, 600)` Sub agent, `(114.83, 470)` Memory store, `(114.83, 210)` Code exec. + +```svg + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LLM + agent + + + + + + + + + Tool + call + + + + + + + + + Output + guard + + + + + + + + + Sub + agent + + + + + + + + + Memory + store + + + + + + + + + Code + exec + + + + + + ACS + control + panel + + +``` + +- [ ] **Step 5: Write the favicon** + +Create `landing/assets/icon.svg`, matching the live site's mark: + +```svg + + + ACS + +``` + +- [ ] **Step 6: Write the page** + +Create `landing/index.html`. Copy follows `STYLE.md` and mirrors the live site's narrative. + +```html + + + + + +ACS — Agent Control Standard + + + + + + + + +
+ + +
+
+
+

The runtime control plane for AI agents.

+

Agent Control Standard (ACS) is the open standard that defines how agent + platforms expose middleware hooks and how open-source tooling enforces safety + policy through those hooks. Declarative controls. Portable across frameworks. + Enforced at runtime.

+ +
+
+
+ +
+

Agents are shipping fast. Controls are not

+

AI agents act across organizational boundaries. The industry standardized how + agents communicate through MCP and A2A, and documented the risks through the OWASP + Agentic Top 10. Runtime control never got the same treatment.

+
    +
  • System prompts are not controls.
  • +
  • Model improvements do not cover edge cases or adversarial inputs.
  • +
  • Proprietary guardrails create vendor lock-in.
  • +
+
+ +
+

Three layers, one standard

+
+
+

Instrument

+

ACS defines standardized middleware hooks at every agent decision point. A + Guardian Agent intercepts the action and returns a verdict: allow, deny, or + modify.

+
+
+

Trace

+

Agents emit structured trace data through OpenTelemetry, the pipeline your + teams already run. ACS maps those traces to OCSF so security events land in the + SIEM without a custom parser.

+
+
+

Inspect

+

Enterprises cannot secure what they cannot inventory. AgBOM captures tools, + models, and dependencies as the agent acquires them, which a static SBOM cannot + do.

+
+
+
+ +
+

How it works

+
+

Tier 1: Platform layer

+

Agent frameworks expose standardized middleware hooks.

+
+
+

Tier 2: Enforcement layer

+

An open-source SDK reads declarative policy and returns verdicts through those hooks.

+
+
+

Tier 3: Enterprise layer

+

Custom classifiers and domain-specific logic plug in behind the same interface.

+
+
+ +
+

Why now

+

The EU AI Act requires demonstrable human oversight of high-risk AI systems, + including the ability to intervene in real time. The NIST AI Risk Management + Framework calls for continuous monitoring and the capacity to disengage autonomous + systems operating outside acceptable parameters.

+

Both describe controls that have to exist at runtime. Neither is satisfied by a + system prompt.

+
+ +
+

Built with the community

+

OWASP ASI, AIVSS, OpenTelemetry, CycloneDX, SPDX, MCP, and A2A.

+
+ +
+

Spec status

+ + + +
Specification version
Published schemas
+

Every schema resolves at the URI its $id declares. Start at + the root schema.

+
+ +
+

Workstreams

+

Each workstream owns a slice of the standard and runs its own review.

+ + + +
WorkstreamLeads
+
+ +
+

Contribute

+

ACS is an open specification. The fastest way to shape it is to use it and tell + us what breaks.

+
    +
  • Join owasp.slack.com and the + #team-genai-asi-acs-general channel.
  • +
  • Open an issue or a discussion on + GitHub.
  • +
  • Report a vulnerability through GitHub private vulnerability reporting.
  • +
  • General contact: rock.lambros@owasp.org
  • +
+
+
+
+ +
+

ACS is vendor neutral and community governed. Licensed under Apache 2.0. + Documentation under CC BY-SA 4.0.

+

A project of the OWASP GenAI Security Project.

+
+ + + + +``` + +- [ ] **Step 7: Inline the starburst** + +Replace the `` comment in `landing/index.html` with the full contents of +`landing/assets/starburst.svg`, minus its XML declaration. Inlining is required because an +SVG loaded through `` cannot read the page's CSS custom properties, so the diagram +would not follow the theme. + +- [ ] **Step 8: Run the tests to verify they pass** + +Run: `uv run pytest tests/test_landing_page.py -v` +Expected: PASS, 8 tests + +- [ ] **Step 9: Amend the contact policy** + +In `CLAUDE.md`, replace the first sentence of the "Contact channels" section with: + +```markdown +The repository carries no email addresses except one. `rock.lambros@owasp.org` appears on +the landing page as general project contact. Every other channel stays as it was: +community contact is GitHub Discussions and the `#team-genai-asi-acs-general` channel on +`owasp.slack.com`, security reporting is GitHub private vulnerability reporting, and Code +of Conduct enforcement routes to the OWASP CoC process so that a report about a maintainer +does not land with the maintainers. Do not add any other contact address to documentation, +`project.owasp.yaml`, or the site config. +``` + +- [ ] **Step 10: Commit** + +```bash +git add landing/ tests/test_landing_page.py CLAUDE.md +git commit -m "Add the landing page, its design tokens, and the starburst diagram + +Tokens mirror agentcontrolstandard.org so the page matches the site it +will eventually replace. The starburst binds its fills and strokes to +theme tokens, which the live site's copy does not, so the diagram works +in dark mode. + +Tests guard three things that are easy to regress: no root-relative +links, which 404 on a project Pages path; no aos.owasp.org links; and +one approved email address rather than any address. + +Records the contact-policy exception in CLAUDE.md so the address does +not read as drift to a later reader." +``` + +--- + +## Task 3: Build-time content injection + +Fills the placeholders from repository state so the published page cannot drift from the specification and the governance roster. + +**Files:** +- Create: `tools/render_landing.py` +- Create: `tests/test_render_landing.py` + +**Interfaces:** +- Consumes: `publish_schemas.load_schemas`, `publish_schemas.target_for`, and the three placeholder tokens from Task 2. +- Produces: + - `class RenderError(Exception)` + - `spec_version(source: Path) -> str` + - `schema_count(source: Path) -> int` + - `parse_workstreams(text: str) -> list[tuple[str, str]]` + - `render_workstreams(rows: list[tuple[str, str]]) -> str` + - `render(template: str, source: Path, governance: str) -> str` + - CLI: `python tools/render_landing.py ` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_render_landing.py`: + +```python +"""Tests for build-time content injection.""" +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) + +from publish_schemas import BASE +from render_landing import ( + RenderError, + parse_workstreams, + render, + render_workstreams, + schema_count, + spec_version, +) + +GOVERNANCE = """# Governance + +## Project lead + +| Role | Name | +| --- | --- | +| Project Lead | Rock Lambros ([@rocklambros](https://github.com/rocklambros)) | + +## Workstream leads + +Prose that must not be parsed as a row. + +| Workstream | Leads | +| --- | --- | +| Identity | Eva Benn ([@evabenn](https://github.com/evabenn)) | +| Spec | Bar Kaduri ([@bar-capsule](https://github.com/bar-capsule)) | + +## Origins + +Not a workstream. +""" + + +@pytest.fixture +def spec_tree(tmp_path: Path) -> Path: + root = tmp_path / "specification" + (root / "v0.1.0").mkdir(parents=True) + for name in ("a.json", "b.json"): + (root / "v0.1.0" / name).write_text( + json.dumps({"$id": BASE + f"v0.1.0/{name}"}), encoding="utf-8" + ) + return root + + +def test_spec_version_reads_the_id_namespace(spec_tree): + assert spec_version(spec_tree) == "v0.1.0" + + +def test_schema_count_counts_every_schema(spec_tree): + assert schema_count(spec_tree) == 2 + + +def test_spec_version_rejects_a_mixed_namespace(tmp_path): + root = tmp_path / "specification" + root.mkdir() + for version in ("v0.1.0", "v0.2.0"): + (root / f"{version}.json").write_text( + json.dumps({"$id": BASE + f"{version}/x.json"}), encoding="utf-8" + ) + with pytest.raises(RenderError, match="more than one spec version"): + spec_version(root) + + +def test_parse_workstreams_reads_only_the_workstream_table(): + rows = parse_workstreams(GOVERNANCE) + assert [name for name, _ in rows] == ["Identity", "Spec"] + + +def test_parse_workstreams_fails_without_the_section(): + with pytest.raises(RenderError, match="Workstream leads"): + parse_workstreams("# Governance\n\nNothing here.\n") + + +def test_render_workstreams_converts_markdown_links_to_html(): + html = render_workstreams([("Identity", "Eva Benn ([@evabenn](https://example.com))")]) + assert '@evabenn' in html + assert "Identity" in html + + +def test_render_fills_every_placeholder(spec_tree): + template = ( + "

" + "

" + "" + ) + out = render(template, spec_tree, GOVERNANCE) + assert "v0.1.0" in out + assert ">2<" in out + assert "Identity" in out + + +def test_render_fails_when_a_placeholder_survives(spec_tree): + with pytest.raises(RenderError, match="unfilled placeholder"): + render("

", spec_tree, GOVERNANCE) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/test_render_landing.py -v` +Expected: FAIL, collection error `ModuleNotFoundError: No module named 'render_landing'` + +- [ ] **Step 3: Write the implementation** + +Create `tools/render_landing.py`: + +```python +#!/usr/bin/env python3 +"""Fill the landing page's placeholders from repository state. + +Injection happens at build time rather than in the browser, so the published page stays +static and renders with JavaScript disabled. +""" +from __future__ import annotations + +import html +import re +import shutil +import sys +from pathlib import Path + +from publish_schemas import SchemaError, load_schemas, target_for + +WORKSTREAM_HEADING = "## Workstream leads" +MD_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") +PLACEHOLDER = re.compile(r"") + + +class RenderError(Exception): + """Repository state does not supply what the template asks for.""" + + +def _versions(source: Path) -> set[str]: + try: + docs = load_schemas(source) + except SchemaError as error: + raise RenderError(str(error)) from error + return {target_for(doc, path).split("/")[0] for path, doc in docs.items()} + + +def spec_version(source: Path) -> str: + """Return the single spec version the schema namespace declares.""" + versions = _versions(source) + if not versions: + raise RenderError(f"no schemas found under {source}") + if len(versions) > 1: + raise RenderError( + f"more than one spec version published: {sorted(versions)}. " + "The page shows one, so decide which is current." + ) + return versions.pop() + + +def schema_count(source: Path) -> int: + return len(load_schemas(source)) + + +def parse_workstreams(text: str) -> list[tuple[str, str]]: + """Read the two-column table under the Workstream leads heading.""" + lines = text.splitlines() + try: + start = lines.index(WORKSTREAM_HEADING) + except ValueError as error: + raise RenderError(f"GOVERNANCE.md: no '{WORKSTREAM_HEADING}' section") from error + + rows: list[tuple[str, str]] = [] + for line in lines[start + 1 :]: + if line.startswith("## "): + break + stripped = line.strip() + if not stripped.startswith("|"): + continue + cells = [cell.strip() for cell in stripped.strip("|").split("|")] + if len(cells) != 2: + continue + # Skip the header row and the dashed separator. + if cells[0].lower() == "workstream" or set(cells[0]) <= set("-: "): + continue + rows.append((cells[0], cells[1])) + + if not rows: + raise RenderError("GOVERNANCE.md: the workstream table is empty") + return rows + + +def _to_html(markdown: str) -> str: + # Escape first, then substitute, so the anchors we insert keep real quotes. + return MD_LINK.sub(r'\1', html.escape(markdown, quote=False)) + + +def render_workstreams(rows: list[tuple[str, str]]) -> str: + return "".join( + f"{_to_html(name)}{_to_html(leads)}" for name, leads in rows + ) + + +def render(template: str, source: Path, governance: str) -> str: + """Replace every placeholder. Fail if one survives.""" + out = template.replace("", spec_version(source)) + out = out.replace("", str(schema_count(source))) + out = out.replace("", render_workstreams(parse_workstreams(governance))) + survivors = PLACEHOLDER.findall(out) + if survivors: + raise RenderError(f"unfilled placeholder: {', '.join(sorted(set(survivors)))}") + return out + + +def main(argv: list[str]) -> int: + landing = Path(argv[1]) if len(argv) > 1 else Path("landing") + out = Path(argv[2]) if len(argv) > 2 else Path("_site") + repo = Path(__file__).resolve().parents[1] + try: + page = render( + (landing / "index.html").read_text(encoding="utf-8"), + repo / "specification", + (repo / "GOVERNANCE.md").read_text(encoding="utf-8"), + ) + except RenderError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + out.mkdir(parents=True, exist_ok=True) + (out / "index.html").write_text(page, encoding="utf-8") + shutil.copytree(landing / "assets", out / "assets", dirs_exist_ok=True) + print(f"rendered landing page to {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `uv run pytest tests/test_render_landing.py -v` +Expected: PASS, 8 tests + +- [ ] **Step 5: Render the real page and check it by eye** + +Run: `uv run python tools/render_landing.py landing /tmp/landing-check && open /tmp/landing-check/index.html` +Expected: the page renders with `v0.1.0`, `44`, and five workstream rows. Toggle the theme and confirm the starburst follows it. + +- [ ] **Step 6: Commit** + +```bash +git add tools/render_landing.py tests/test_render_landing.py +git commit -m "Inject spec version and workstreams into the landing page at build time + +Reads the version from the schema \$id namespace and the roster from +GOVERNANCE.md, so neither can drift from the repository. Injection runs +at build time, so the published page needs no client-side fetch. + +Fails the build when a placeholder survives, when the governance table +is missing, and when the namespace carries more than one spec version, +because the page shows a single version and that ambiguity needs a human +decision." +``` + +--- + +## Task 4: Site configuration cleanup + +Removes the analytics tag and keeps local builds out of the working tree. + +**Files:** +- Modify: `mkdocs.yml` +- Modify: `.gitignore` +- Create: `tests/test_site_config.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: a MkDocs build that emits no third-party tracker. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_site_config.py`: + +```python +"""Regression guard on the built documentation site. + +Verified before this change: with GOOGLE_ANALYTICS_KEY set to an empty string, and also +with the variable unset entirely, Material emitted ", "safe")]) + assert " + + From 2c77e19db622357aeadac38ccd2cf6e7e6fa4862 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Sat, 5 Sep 2026 14:16:58 -0600 Subject: [PATCH 07/34] Assign the landing page guards to the task that creates them tests/test_landing_page.py appeared in both Task 2's and Task 3's file lists. It belongs to Task 3: the guards assert properties of the rendered page, which does not exist until the renderer does. --- design/plans/2026-09-05-github-pages-site.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/plans/2026-09-05-github-pages-site.md b/design/plans/2026-09-05-github-pages-site.md index 1d37dee..730ff63 100644 --- a/design/plans/2026-09-05-github-pages-site.md +++ b/design/plans/2026-09-05-github-pages-site.md @@ -542,7 +542,7 @@ Builds the static page. Every value that varies with repository state is a place **Files:** - Create: `landing/index.html`, `landing/assets/acs.css`, `landing/assets/starburst.svg`, `landing/assets/icon.svg`, `landing/assets/fonts/` -- Create: `tests/test_landing_page.py` (guards run in Task 3, against rendered output) +- This task ships no tests. Its guards live in `tests/test_landing_page.py`, which Task 3 creates, because they assert properties of the **rendered** page rather than this template. **Interfaces:** - Consumes: nothing. From 4dfc618c8a9b28ff66ec93903500f47c1dc13aa3 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Sat, 5 Sep 2026 14:23:34 -0600 Subject: [PATCH 08/34] Correct American English and prose semicolons in the landing page The shipped comments carried three semicolons in prose and three British spellings, both of which the project's writing constraints forbid. The wording came from the plan, so the plan is corrected in the same change. --- landing/assets/acs.css | 4 ++-- landing/assets/starburst.svg | 4 ++-- landing/index.html | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/landing/assets/acs.css b/landing/assets/acs.css index b26d09a..99aaa07 100644 --- a/landing/assets/acs.css +++ b/landing/assets/acs.css @@ -30,7 +30,7 @@ --acs-node-stroke: #6b7079; /* 4.98:1 on the page */ --acs-spoke: #6b7079; --acs-hex-fill: #f4f5f7; - /* Was #c4cdd8 for 1.47:1 against the hexagon fill. The centre of the diagram was + /* Was #c4cdd8 for 1.47:1 against the hexagon fill. The center of the diagram was close to invisible. #7d8899 measures 3.29:1. */ --acs-hex-stroke: #7d8899; --acs-tier-1: #0f7b3f; @@ -170,7 +170,7 @@ footer nav { display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 1rem; } } /* Continuous motion is a vestibular trigger and a battery cost. CSS animation is - stopped here; the SVG's SMIL elements carry their own guard, because + stopped here. The SVG's SMIL elements carry their own guard, because `animation: none` does not reach them. */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } diff --git a/landing/assets/starburst.svg b/landing/assets/starburst.svg index 6c2faaf..9671573 100644 --- a/landing/assets/starburst.svg +++ b/landing/assets/starburst.svg @@ -28,9 +28,9 @@ - + continuous rather than synchronized. --> diff --git a/landing/index.html b/landing/index.html index 7eddde5..e7e6e0e 100644 --- a/landing/index.html +++ b/landing/index.html @@ -182,14 +182,14 @@

Reporting a problem

", "safe")]) + assert " diff --git a/mkdocs.yml b/mkdocs.yml index fcb2769..69bb764 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -25,24 +25,18 @@ theme: # enable synchronized tabs - content.tabs.link palette: - - media: "(prefers-color-scheme)" - toggle: - icon: material/link - name: Switch to light mode + # The media queries pick the reader's system preference on first load. The toggle + # then overrides it, which is the same two-state behavior the landing page has. - media: "(prefers-color-scheme: light)" scheme: default - primary: indigo - accent: indigo toggle: - icon: material/toggle-switch + icon: material/weather-night name: Switch to dark mode - media: "(prefers-color-scheme: dark)" scheme: slate - primary: black - accent: indigo toggle: - icon: material/toggle-switch-off - name: Switch to system preference + icon: material/weather-sunny + name: Switch to light mode font: false favicon: assets/icon.svg diff --git a/tests/test_docs_theme.py b/tests/test_docs_theme.py index 27bc836..86ce0b3 100644 --- a/tests/test_docs_theme.py +++ b/tests/test_docs_theme.py @@ -121,3 +121,12 @@ def test_the_theme_still_styles_the_override(): pytest.skip("installed Material layout differs, nothing to compare") for klass in [".md-source", ".md-source__repository"]: assert klass in css, f"{klass} is gone from the installed theme" + + +def test_docs_palette_is_a_two_state_sun_and_moon_toggle(): + """Three states and a switch glyph did not match the landing page's control.""" + config = (REPO / "mkdocs.yml").read_text(encoding="utf-8") + assert "material/weather-night" in config + assert "material/weather-sunny" in config + assert "toggle-switch" not in config + assert config.count("media: \"(prefers-color-scheme") == 2 diff --git a/tests/test_landing_page.py b/tests/test_landing_page.py index f39587e..a7d6b52 100644 --- a/tests/test_landing_page.py +++ b/tests/test_landing_page.py @@ -171,3 +171,25 @@ def test_font_license_ships_with_the_font(): text = (LANDING / "assets" / "fonts" / "OFL.txt").read_text(encoding="utf-8") assert "SIL Open Font License" in text assert "The Inter Project Authors" in text + + +def test_the_theme_control_is_an_icon_with_an_accessible_name(page): + """A text button was replaced by the sun and moon readers already recognize. + + The icons are decorative, so the control carries its own accessible name and a + visually hidden label rather than relying on the glyph. + """ + assert 'id="theme-toggle"' in page + assert "Switch theme" not in page.split('id="theme-toggle"')[1].split("")[0].replace( + 'Switch theme', "" + ) + assert 'aria-label="Switch to dark theme"' in page + assert 'class="icon-sun"' in page and 'class="icon-moon"' in page + assert page.count('aria-hidden="true"') >= 2 + + +def test_only_one_theme_mark_shows_at_a_time(): + css = (LANDING / "assets" / "acs.css").read_text(encoding="utf-8") + assert "#theme-toggle .icon-sun { display: none; }" in css + assert ':root[data-theme="dark"] #theme-toggle .icon-moon { display: none; }' in css + assert ".visually-hidden" in css From b67b6bac5bfda5fb8927b849be5d50676ad25115 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Sat, 5 Sep 2026 16:55:13 -0600 Subject: [PATCH 33/34] Keep the ACS link colour against the theme's default palette The theme sets --md-typeset-a-color from its primary colour, and the built pages carry a primary attribute whether or not the palette names one. That tied with the scheme rules at equal specificity, so documentation links rendered the theme's indigo in dark mode instead of the project's blue. Matching on both attributes settles it without touching the other tokens. --- docs/stylesheets/extra.css | 12 ++++++++++++ tests/test_docs_theme.py | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 3c693ff..ca7863a 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -53,6 +53,18 @@ --md-footer-bg-color--dark: #000000; } +/* The theme's own palette sets --md-typeset-a-color from the primary colour, and the + built pages always carry a data-md-color-primary attribute. Matching on both wins + deterministically rather than relying on file order at equal specificity. */ +[data-md-color-scheme="default"][data-md-color-primary] { + --md-typeset-a-color: #1b4f72; + --md-accent-fg-color: #1b4f72; +} +[data-md-color-scheme="slate"][data-md-color-primary] { + --md-typeset-a-color: #63b3ed; + --md-accent-fg-color: #63b3ed; +} + /* Square off Material's rounded chrome to match the landing page's flatter shapes. */ .md-typeset code { border-radius: 4px; } .md-typeset pre > code { border-radius: 8px; } diff --git a/tests/test_docs_theme.py b/tests/test_docs_theme.py index 86ce0b3..f2233a0 100644 --- a/tests/test_docs_theme.py +++ b/tests/test_docs_theme.py @@ -130,3 +130,17 @@ def test_docs_palette_is_a_two_state_sun_and_moon_toggle(): assert "material/weather-sunny" in config assert "toggle-switch" not in config assert config.count("media: \"(prefers-color-scheme") == 2 + + +def test_link_colour_survives_the_theme_default_palette(built_docs): + """Material's palette sets --md-typeset-a-color from its default indigo primary. + + The scheme rules alone tie with it at equal specificity, so the ACS value only wins + when the selector also matches the primary attribute the built pages always carry. + """ + css = (REPO / "docs" / "stylesheets" / "extra.css").read_text(encoding="utf-8") + assert '[data-md-color-scheme="slate"][data-md-color-primary]' in css + assert '[data-md-color-scheme="default"][data-md-color-primary]' in css + # The pages must still carry the attribute the fix depends on. + index = (built_docs / "index.html").read_text(encoding="utf-8") + assert "data-md-color-primary=" in index From b50082c7304e323c5c414645f201b96b2617b79c Mon Sep 17 00:00:00 2001 From: rocklambros Date: Sat, 5 Sep 2026 17:13:35 -0600 Subject: [PATCH 34/34] Show one mark, and stop calling the specification external The landing sidebar drew the project mark as styled text while the documentation header drew the same file as an image. Both now show the image. The specification moved out of the external resources block. It is served from this site, so it sits with the sections and leaves that heading to GitHub and Slack, which are genuinely elsewhere. Records that OWASP holds the rights to the reused design, which the licensing notes had carried as an open question. --- LICENSING.md | 5 +++-- landing/assets/acs.css | 19 ++++++++++++++++++- landing/index.html | 7 +++++-- tests/test_landing_page.py | 18 ++++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/LICENSING.md b/LICENSING.md index 014c587..ea05e1d 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -67,8 +67,9 @@ Security Project operates and which is built from a separate repository. They ar here as the project's own work. Both copies of the mark are covered by the `landing/**` row above. -If any part of that site was produced by a party outside the project, confirm in writing -who holds the rights before the next release. +The OWASP GenAI Security Project holds the rights to this design, confirmed by the +project lead. No outside party has a claim on the tokens, the diagram geometry, or the +mark, so they are covered by the rows above with no further condition. The vendored font is Inter, redistributed under the SIL Open Font License 1.1. Its recorded checksum is in `landing/assets/fonts/CHECKSUMS.txt`. diff --git a/landing/assets/acs.css b/landing/assets/acs.css index 4d2be57..74d5fc6 100644 --- a/landing/assets/acs.css +++ b/landing/assets/acs.css @@ -121,10 +121,27 @@ button:focus-visible { outline: 3px solid var(--acs-focus-ring); outline-offset: padding: 2rem 1.5rem; border-right: 1px solid var(--acs-border); display: flex; flex-direction: column; gap: 1.5rem; } -.wordmark { font-weight: 700; letter-spacing: 0.12em; font-size: 1.1rem; text-decoration: none; } +.wordmark { + display: inline-flex; + align-items: center; + gap: 0.55rem; + font-weight: 700; + letter-spacing: 0.12em; + font-size: 1.1rem; + text-decoration: none; +} +.wordmark img { width: 28px; height: 28px; border-radius: 6px; } .sidebar nav { display: flex; flex-direction: column; gap: 0.6rem; } .sidebar nav a { text-decoration: none; color: var(--acs-text-soft); } .sidebar nav a:hover { color: var(--acs-text); } +/* This one navigates to the documentation while its neighbours are in-page anchors. */ +.sidebar nav a.nav-primary { + font-weight: 600; + color: var(--acs-text); + padding-bottom: 0.6rem; + margin-bottom: 0.2rem; + border-bottom: 1px solid var(--acs-border); +} .sidebar h2 { font-size: 0.75rem; text-transform: uppercase; color: var(--acs-text-muted); } main { padding: 4rem 3rem; max-width: 1100px; } diff --git a/landing/index.html b/landing/index.html index f55f891..7719674 100644 --- a/landing/index.html +++ b/landing/index.html @@ -11,8 +11,12 @@