diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6393cf4..a9215d3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -20,6 +20,24 @@ /specification/ @rocklambros @fewdisc @GangGreenTemperTatum @mamicidal @sclintonowasp @afogel @stefanoamorelli @almogbhl @bar-capsule @evabenn @RbBuiltWrong @aruneeshsalhotra /docs/spec/ @rocklambros @fewdisc @GangGreenTemperTatum @mamicidal @sclintonowasp @afogel @stefanoamorelli @almogbhl @bar-capsule @evabenn @RbBuiltWrong @aruneeshsalhotra +# These generate and publish the public site, or feed the build that does. A change +# here alters what the front door says, which is the same class of privilege the +# /.github/ rule below protects. mkdocs.yml in particular accepts a hooks: key that +# executes Python inside the build job. The overrides directory holds theme templates +# the build renders into every page, which carries the same reach. +# The docs stylesheet and asset directories render into every documentation page and +# can reach a third party through url(), @font-face, or @import with no script at all. +/tools/ @rocklambros @fewdisc @GangGreenTemperTatum @mamicidal @sclintonowasp +/landing/ @rocklambros @fewdisc @GangGreenTemperTatum @mamicidal @sclintonowasp +/GOVERNANCE.md @rocklambros @fewdisc @GangGreenTemperTatum @mamicidal @sclintonowasp +/mkdocs.yml @rocklambros @fewdisc @GangGreenTemperTatum @mamicidal @sclintonowasp +/pyproject.toml @rocklambros @fewdisc @GangGreenTemperTatum @mamicidal @sclintonowasp +/uv.lock @rocklambros @fewdisc @GangGreenTemperTatum @mamicidal @sclintonowasp +/tests/ @rocklambros @fewdisc @GangGreenTemperTatum @mamicidal @sclintonowasp +/overrides/ @rocklambros @fewdisc @GangGreenTemperTatum @mamicidal @sclintonowasp +/docs/stylesheets/ @rocklambros @fewdisc @GangGreenTemperTatum @mamicidal @sclintonowasp +/docs/assets/ @rocklambros @fewdisc @GangGreenTemperTatum @mamicidal @sclintonowasp + # CI runs with write access to the repository. Changes here are a # privilege-escalation surface and warrant admin review. /.github/ @rocklambros @fewdisc @GangGreenTemperTatum @mamicidal @sclintonowasp diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000..84857de --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,121 @@ +# Builds and publishes the landing page, the documentation site, and the JSON schemas. +# Pull requests test and build without deploying. Merge to main publishes with no human +# in the loop, so every guard runs here rather than on a contributor's laptop. +name: Deploy Pages + +on: + push: + branches: ["main"] + pull_request: + workflow_dispatch: + +# Deny by default. Each job grants itself only what it needs. +permissions: {} + +# One group for every deploy so they serialize on the single Pages site they share. +# Pull request builds group per ref and cancel stale runs. Keying everything on ref +# would let a dispatch on a branch deploy alongside a push to main. +concurrency: + group: pages-${{ github.event_name == 'pull_request' && github.ref || 'deploy' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.9.9" + + - name: Install dependencies from the lockfile + run: uv sync --locked + + - name: Run the guards + run: uv run pytest -v + + build: + needs: test + runs-on: ubuntu-latest + permissions: + contents: read + # configure-pages calls GET /repos/{owner}/{repo}/pages, which needs this. + pages: read + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.9.9" + + - name: Install dependencies from the lockfile + run: uv sync --locked --no-dev + + - name: Configure Pages + id: pages + # Skipped on pull requests. The action fails when Pages is not yet enabled, and + # a fork's token cannot read the Pages API at all. Pull requests only need a + # site_url, and the constant below is correct for them. + if: github.event_name != 'pull_request' + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Build the documentation site + env: + # mkdocs.yml reads site_url from this, and MkDocs builds into _site/docs/, so + # the value must name the docs subpath. Passing the site root makes every + # canonical URL and every sitemap entry omit /docs/ and resolve to a 404. + GITHUB_PAGES_URL: ${{ steps.pages.outputs.base_url && format('{0}/docs/', steps.pages.outputs.base_url) || 'https://genai-security-project.github.io/agent-control-standard/docs/' }} + run: uv run --no-dev mkdocs build --strict -d _site/docs + + - name: Render the landing page + run: uv run --no-dev python tools/render_landing.py landing _site + + - name: Publish the schemas + # Fails when any $id is unsafe or duplicated, or any $ref does not resolve. + run: uv run --no-dev python tools/publish_schemas.py specification _site/schema + + - name: Upload the artifact + if: github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: _site + # The default is one day, which would expire the fastest rollback path: + # re-running the deploy job of the last good run. + retention-days: 30 + + deploy: + # event_name alone is not enough. workflow_dispatch can target any ref, so without + # the branch check a feature branch could publish to the production site. + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Deploy to Pages + id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 + + - name: Verify the published schemas + # Through env, never interpolated into the shell. sync_version.yml documents why. + env: + PAGE_URL: ${{ steps.deployment.outputs.page_url }} + run: | + python3 tools/verify_published.py "$PAGE_URL" \ + schema/v0.1.0/acs_schema.json \ + schema/v0.1.0/hooks/session-start.json diff --git a/.github/workflows/monitor-pages.yml b/.github/workflows/monitor-pages.yml new file mode 100644 index 0000000..4320f2c --- /dev/null +++ b/.github/workflows/monitor-pages.yml @@ -0,0 +1,27 @@ +# The published $id URIs are a machine-consumed contract. Between merges nothing else +# checks that they still resolve, so this does. +name: Monitor Pages + +on: + schedule: + - cron: "17 */6 * * *" + workflow_dispatch: + +permissions: {} + +jobs: + check: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Verify the published schemas still resolve + env: + PAGE_URL: https://genai-security-project.github.io/agent-control-standard/ + run: | + python3 tools/verify_published.py "$PAGE_URL" \ + schema/v0.1.0/acs_schema.json \ + schema/v0.1.0/hooks/session-start.json diff --git a/.gitignore b/.gitignore index c2284a1..a06d66e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ references/ # Python build artifacts from `uv pip install -e .` *.egg-info/ __pycache__/ + +# Subagent-driven development scratch. Never committed. +.superpowers/ +_site/ diff --git a/CLAUDE.md b/CLAUDE.md index 97e858d..0427ab2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,17 +93,51 @@ This is a documentation-focused project built with: - **MkDocs Material** for local documentation preview ### Hosting (decoupled from this repo) -This repository is the **source of truth for the ACS spec** (schema, hooks, events, AgBOM definitions, written specification). The marketing and docs site at **agentcontrolstandard.org** is built and deployed independently from a separate repository — changes here do not propagate automatically. The `.ai` and `.com` domains redirect to `.org`. +This repository is the source of truth for the ACS spec, and it now carries the workflow +that publishes the site. Once Pages is enabled, `.github/workflows/deploy-pages.yml` +builds three things on every merge to `main`: the landing page from `landing/`, the +MkDocs documentation under `/docs/`, and the JSON schemas under `/schema//`. + +Schema publish paths derive from each schema's own `$id`, which is validated and +contained because `$id` is a pull-request-writable string used to build a filesystem +path. The build fails on an unsafe or duplicated `$id` and on any `$ref` that does not +resolve, fragment included. + +`GOVERNANCE.md` is a build input. Its workstream table renders into the published page, +so a change to its shape can fail the deploy, and its contents are escaped as untrusted +text. + +The marketing site at **agentcontrolstandard.org** is still built and deployed from a +separate repository. It will redirect here later. Adding the custom domain makes GitHub +301 the `github.io` URIs to it, which schema tooling follows. Do not rebase `$id` onto +the marketing domain during that cutover. A `CNAME` must be written into `_site` by the +build. Placing one in `landing/` does not reach the artifact. ### Contact channels -The repository carries no email addresses, by policy. Community contact is GitHub Discussions, 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 a contact address to documentation, `project.owasp.yaml`, or the site config. Example addresses in specification documents must use the RFC 2606 reserved domains (`example.com`, `example.net`, `example.org`). +The repository carries one contact address and no others. `rock.lambros@owasp.org` +appears on the landing page for general questions about the project. Do not add any +other contact address to documentation, `project.owasp.yaml`, or the site config. + +Routing is unchanged. Community contact is GitHub Discussions and the +`#team-genai-asi-acs-general` channel on `owasp.slack.com`. Security reporting is GitHub +private vulnerability reporting, which is the channel `SECURITY.md` covers. Code of +Conduct enforcement routes to the OWASP CoC process so that a report about a maintainer +does not land with the maintainers. The landing page links both, so publishing an +address does not pull reports out of the processes that handle them independently. + +Example addresses in specification documents must use the RFC 2606 reserved domains +(`example.com`, `example.net`, `example.org`). Eleven of these exist in `docs/` today +and are correct. ### Schema namespace Schema `$id` values are based at `https://genai-security-project.github.io/agent-control-standard/schema//`, not at any of the project domains. The namespace follows the org and repo so that schema identity survives a domain or hosting change. Do not rebase `$id` onto a marketing domain. `$id` is identity, not a fetch target. Every `$ref` in the package is relative and resolves against the enclosing `$id` base, so the whole set must share one base. Two bases means the relative refs resolve to URIs no `$id` declares, which is the defect fixed in `4fb84c1`. If you add a subschema, give it an `$id` under the same base and keep its refs relative. -The base is not yet served: GitHub Pages is not enabled on this repo, so remote retrieval 404s. Local and file-path validation is unaffected. +The base is served once GitHub Pages is enabled on this repository, which +`.github/workflows/deploy-pages.yml` then publishes to on every merge to `main` and +`.github/workflows/monitor-pages.yml` rechecks every six hours. Until that setting is +turned on, remote retrieval 404s and only local and file-path validation works. `$id` is versioned by **spec** version, not release version. `.github/workflows/sync_version.py` deliberately leaves `$id` alone. See `1af1f92`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 903fece..b3a2e6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,13 +37,13 @@ All submissions go through GitHub pull request review. See [GitHub's PR guide](h ## Development Process 1. **Fork the repository** and clone your fork -2. **Create a feature branch** — use `feature/` or `fix/` +2. **Create a feature branch.** Use `feature/` or `fix/` 3. **Make your changes** following the style guide 4. **Sign your commits** with `git commit -s` (required by the DCO below) 5. **Open a pull request** against `main` 6. **Address review feedback** to land your change -For changes to the spec itself (`acs_schema.json`, hooks, events), open a [Discussion](https://github.com/GenAI-Security-Project/agent-control-standard/discussions) before submitting a PR — these affect downstream implementers and warrant a longer conversation. +For changes to the spec itself (`acs_schema.json`, hooks, events), open a [Discussion](https://github.com/GenAI-Security-Project/agent-control-standard/discussions) before submitting a PR. These affect downstream implementers and warrant a longer conversation. ## What We Need @@ -89,6 +89,16 @@ By contributing, you agree that your contributions will be licensed under the li This guide is based on [github-contributing](https://raw.githubusercontent.com/standard/.github/refs/heads/master/CONTRIBUTING.md). +## Before the first Pages deploy + +`.github/workflows/deploy-pages.yml` and `.github/workflows/monitor-pages.yml` both +assume GitHub Pages is already enabled for this repository. Until it is, the deploy fails +at the Configure Pages step and the monitor fails on its schedule. + +Enabling it is a one-time repository setting, done by an administrator: Settings, then +Pages, then set Build and deployment Source to GitHub Actions. Do this before merging any +change that turns those workflows on, not after. + ## Community - **[GitHub Discussions](https://github.com/GenAI-Security-Project/agent-control-standard/discussions)**: Ask questions, share ideas diff --git a/LICENSING.md b/LICENSING.md index 5759b00..ea05e1d 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -14,8 +14,12 @@ Copyright 2025-2026 The OWASP GenAI Security Project and the ACS contributors. | Code samples embedded in any Markdown file | Apache License 2.0 | `Apache-2.0` | | `docs/**` | CC BY-SA 4.0 | `CC-BY-SA-4.0` | | `README.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `CONTRIBUTORS.md`, `STYLE.md`, `SPEC_REVIEW_PRINCIPLES.md`, `LICENSING.md` | CC BY-SA 4.0 | `CC-BY-SA-4.0` | +| `landing/**`, `tools/**`, `tests/**` | Apache License 2.0 | `Apache-2.0` | +| `overrides/**` | CC BY-SA 4.0 | `CC-BY-SA-4.0` | +| `design/**` | CC BY-SA 4.0 | `CC-BY-SA-4.0` | +| `landing/assets/fonts/**` | SIL Open Font License 1.1 | `OFL-1.1` | -Full texts live in [`LICENSE`](./LICENSE) for Apache 2.0 and [`LICENSE-DOCS`](./LICENSE-DOCS) for CC BY-SA 4.0. Attribution details live in [`NOTICE`](./NOTICE). +Full texts live in [`LICENSE`](./LICENSE) for Apache 2.0, [`LICENSE-DOCS`](./LICENSE-DOCS) for CC BY-SA 4.0, and [`landing/assets/fonts/OFL.txt`](./landing/assets/fonts/OFL.txt) for the SIL Open Font License 1.1. Attribution details live in [`NOTICE`](./NOTICE). Code samples inside the documentation carry the Apache 2.0 grant, not the ShareAlike obligation. Copy a JSON payload or a hook definition out of `docs/` into a proprietary agent and nothing forces you to open-source the result. @@ -52,3 +56,24 @@ These licenses cover copyright. They grant no rights to the OWASP name, the OWAS ## License history Releases up to and including v0.1.0 were published under the MIT License. That grant stands. Anyone who obtained ACS under the MIT License keeps their rights under it. Contributions merged after the relicense are governed by the terms on this page. + +## Provenance of the landing page design + +The design tokens in `landing/assets/acs.css`, the diagram geometry in +`landing/assets/starburst.svg`, and the mark in `landing/assets/icon.svg`, which is +duplicated at `docs/assets/icon.svg` because MkDocs requires a theme logo inside its own +documentation directory, derive from agentcontrolstandard.org, which the OWASP GenAI +Security Project operates and which is built from a separate repository. They are used +here as the project's own work. Both copies of the mark are covered by the +`landing/**` row above. + +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`. + +The documentation header inlines the GitHub mark from the Simple Icons set bundled with +Material for MkDocs, dedicated to the public domain under CC0 1.0 Universal. It is +included at build time rather than vendored, so no copy lives in this repository. diff --git a/NOTICE b/NOTICE index 8ab0503..f8fc2e2 100644 --- a/NOTICE +++ b/NOTICE @@ -10,6 +10,10 @@ Apache License, Version 2.0. See LICENSE. Prose documentation in this repository is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License. See LICENSE-DOCS. +The landing page bundles the Inter typeface, Copyright (c) 2016 The Inter Project +Authors (https://github.com/rsms/inter), licensed under the SIL Open Font License, +Version 1.1. See landing/assets/fonts/OFL.txt. + See LICENSING.md for the full scope map. Prior history: releases up to and including v0.1.0 were published under the MIT diff --git a/README.md b/README.md index a30d701..98c1fef 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ The standard covers the following aspects ## Getting Started -- 📚 **Explore the Documentation:** Visit the [Documentation Site](https://agentcontrolstandard.ai) for a complete overview, the full specification, tutorials, and guides. +- 📚 **Explore the Documentation:** Once GitHub Pages is enabled, visit the [documentation site](https://genai-security-project.github.io/agent-control-standard/docs/) for a complete overview, the full specification, tutorials, and guides. The [project landing page](https://genai-security-project.github.io/agent-control-standard/) covers what ACS is and why it exists. - 📝 **View the Specification:** [Specification](https://github.com/GenAI-Security-Project/agent-control-standard/tree/main/specification) ## Contributing diff --git a/SECURITY.md b/SECURITY.md index c4b03c3..89539c1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,11 +21,14 @@ Partial reports are welcome. We would rather triage something incomplete than ne | In scope | Out of scope | | --- | --- | -| Flaws in the ACS specification that lead implementers into insecure designs | The documentation site at agentcontrolstandard.ai, which is built from a separate repository | +| Flaws in the ACS specification that lead implementers into insecure designs | The marketing site at agentcontrolstandard.org, which is built and deployed from a separate repository | | Errors in the JSON Schemas under `specification/` | Findings against third-party agent frameworks that happen to implement ACS | | The GitHub Actions workflows in `.github/workflows/` | Automated scanner output with no demonstrated impact | | Hook or event definitions that leak sensitive data by design | Missing security headers on sites we do not operate | | Supply-chain issues in this repository's dependencies | Social engineering of maintainers or contributors | +| The published site at genai-security-project.github.io/agent-control-standard once Pages is enabled, including the landing page, the documentation, and the schema endpoints | | +| The build and publish tooling in `tools/` and `.github/workflows/` | | +| | Missing security response headers on the Pages site, which GitHub Pages does not allow us to set | A specification flaw counts. If a hook definition forces implementers to log secrets, or an event schema makes an authorization bypass easy to write, that is a finding even though no code here executes. 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. 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..c52be8a --- /dev/null +++ b/design/plans/2026-09-05-github-pages-site.md @@ -0,0 +1,2491 @@ +# 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 workflow runs tests, assembles three parts into a single Pages artifact, deploys, then verifies the published bytes. Schema publish paths derive from each schema's own `$id`, validated and contained. Landing page content that varies with repository state is injected at build time and machine-checked. + +**Tech Stack:** Python 3.11+, uv, MkDocs Material, pytest, GitHub Actions, hand-authored HTML and CSS with no JavaScript framework and no third-party runtime assets. + +**Spec:** `design/2026-09-05-github-pages-landing.md` + +**Revision:** 2.0. Version 1.0 went through a six-perspective adversarial premortem that returned 36 findings at Plausible or above, four Critical. Every fix below is verified by execution, not by reasoning. The premortem map at the end of this document ties each finding to the task that closes it. + +## 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. The five pins in this plan were each resolved from the action's tagged release and verified to point at that tag. +- Workflow-level `permissions: {}`. Jobs grant only what they need. +- Never interpolate `${{ }}` inside a `run:` block. Pass values through `env:` and reference the shell variable. `sync_version.yml` documents this rule and this plan follows it. +- Schema `$id` values must not change. `$id` is versioned by spec version (`v0.1.0`), not release version (`version.txt`, currently `0.1.1`). +- `$id` and `GOVERNANCE.md` are untrusted input. Both are writable by pull request and both reach a filesystem path or an HTML attribute. Validate accordingly. +- The landing page carries exactly one email address, `rock.lambros@owasp.org`. Example addresses in `docs/` keep using the RFC 2606 reserved domains, and the eleven that exist today stay as they are. +- The published site loads no third-party asset. No external font, script, stylesheet, or image. +- 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). This includes the HTML ``. +- Every guard must run in CI. A test that only runs on a developer's laptop is not a control. +- 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 validated path its `$id` declares. Verify every `$ref` resolves, fragment included. | +| `tools/render_landing.py` | Replace named placeholders in the landing page from repository state. Escape untrusted content. | +| `tools/verify_published.py` | Poll the deployed site and assert each schema serves its own `$id`. | +| `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, injected at build time. | +| `landing/assets/icon.svg` | Favicon. | +| `landing/assets/fonts/` | Self-hosted Inter, so the page contacts no third party. | +| `tests/test_publish_schemas.py` | Publishing, containment, and ref-closure tests. | +| `tests/test_render_landing.py` | Injection, escaping, and parser-robustness tests. | +| `tests/test_landing_page.py` | Content guards, run against the **rendered** page. | +| `tests/test_site_config.py` | Regression test that no third-party request ships. | +| `.github/workflows/deploy-pages.yml` | Test, build, deploy, verify. | +| `.github/workflows/monitor-pages.yml` | Scheduled check that the published schemas still resolve. | +| `mkdocs.yml` | Modified: remove `extra.analytics`, set `font: false`. | +| `.gitignore` | Modified: add `_site/`. | +| `pyproject.toml` | Modified: add a `dev` group with pytest and jsonschema. | +| `CLAUDE.md`, `SECURITY.md`, `LICENSING.md`, `.github/CODEOWNERS` | Modified: policy and ownership catch up with the new hosting posture. | + +--- + +## Task 1: Schema publisher + +Publishes schemas to the validated paths their `$id` values declare and fails the build if the package does not resolve completely. `$id` is attacker-influenced input: a fork pull request reaches this code on the runner before any human review. + +**Files:** +- Create: `tools/publish_schemas.py`, `tests/test_publish_schemas.py` +- Modify: `pyproject.toml`, `uv.lock` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `BASE: str`; `class SchemaError(Exception)`; `load_schemas(source: Path) -> dict[Path, dict]`; `target_for(doc: dict, path: Path) -> str`; `iter_refs(node: object) -> Iterator[str]`; `resolve_pointer(doc: dict, pointer: str) -> bool`; `publish(source: Path, out: Path) -> list[str]`. CLI: `python tools/publish_schemas.py <source> <out>`. + +- [ ] **Step 1: Add the dev dependency group** + +Append to `pyproject.toml`: + +```toml +[dependency-groups] +dev = [ "pytest>=8.0", "jsonschema>=4.25.0",] +``` + +- [ ] **Step 2: Regenerate the lockfile** + +Run: `uv lock` +Expected: `uv.lock` updates. Note the resolved pytest version; `>=8.0` resolves to a 9.x release, which is correct because the lockfile is the pin. + +- [ ] **Step 3: Write the failing tests** + +Create `tests/test_publish_schemas.py`: + +```python +"""Tests for the schema publisher. + +The negative cases are the point. $id reaches this code from any pull request, including +a fork's, and it is used to build a filesystem path. +""" +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, resolve_pointer, target_for + +REPO = Path(__file__).resolve().parents[1] + + +def write_schema(root: Path, rel: str, sid: str, body: dict | None = None) -> Path: + 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 + + +# --- $id validation ------------------------------------------------------- + +def test_target_for_strips_the_namespace_base(): + assert target_for({"$id": BASE + "v0.1.0/acs_schema.json"}, Path("a")) == "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(): + with pytest.raises(SchemaError, match="outside namespace"): + target_for({"$id": "https://example.com/schema/v0.1.0/x.json"}, Path("broken.json")) + + +@pytest.mark.parametrize( + "tail", + [ + "../index.html", + "../../../../pwned.txt", + "/etc/passwd", + "v0.1.0/../../x.json", + "v0.1.0/%2e%2e/x.json", + "index.html", + "v0.1.0/x.txt", + "", + ], +) +def test_target_for_rejects_unsafe_publish_paths(tail): + """Each of these escapes the artifact or lands outside the versioned namespace.""" + with pytest.raises(SchemaError): + target_for({"$id": BASE + tail}, Path("evil.json")) + + +def test_publish_refuses_an_id_that_escapes_the_output_root(tmp_path): + src, out = tmp_path / "spec", tmp_path / "out" + write_schema(src, "v0.1.0/a.json", BASE + "../../../../pwned.txt") + with pytest.raises(SchemaError): + publish(src, out) + assert not (tmp_path.parent / "pwned.txt").exists() + + +def test_publish_rejects_a_draft_claiming_the_normative_namespace(tmp_path): + src, out = tmp_path / "spec", tmp_path / "out" + write_schema(src, "v0.1.0/a.json", BASE + "v0.1.0/a.json") + write_schema(src, "proposals/draft.json", BASE + "v0.1.0/draft.json") + with pytest.raises(SchemaError, match="normative namespace"): + publish(src, out) + + +def test_publish_rejects_a_duplicate_id(tmp_path): + src, out = tmp_path / "spec", tmp_path / "out" + write_schema(src, "v0.1.0/a.json", BASE + "v0.1.0/dup.json", {"x": 1}) + write_schema(src, "v0.1.0/b.json", BASE + "v0.1.0/dup.json", {"x": 2}) + with pytest.raises(SchemaError, match="duplicate \\$id"): + publish(src, out) + + +# --- placement ------------------------------------------------------------ + +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") + assert publish(src, out) == ["v0.1.0/acs_schema.json"] + assert (out / "v0.1.0" / "acs_schema.json").is_file() + + +def test_publish_skips_json_without_an_id(tmp_path): + """An example payload beside a proposal must not stop the deploy.""" + src, out = tmp_path / "spec", tmp_path / "out" + write_schema(src, "v0.1.0/a.json", BASE + "v0.1.0/a.json") + (src / "proposals").mkdir(parents=True, exist_ok=True) + (src / "proposals" / "example.json").write_text('{"session_id": "abc"}', encoding="utf-8") + assert publish(src, out) == ["v0.1.0/a.json"] + + +def test_publish_fails_on_invalid_json(tmp_path): + src, out = tmp_path / "spec", tmp_path / "out" + (src / "v0.1.0").mkdir(parents=True) + (src / "v0.1.0" / "bad.json").write_text("{not json", encoding="utf-8") + with pytest.raises(SchemaError, match="invalid JSON"): + publish(src, out) + + +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_more_than_one_spec_version(tmp_path): + """Old versions stay published so their $id URIs keep resolving.""" + src, out = tmp_path / "spec", tmp_path / "out" + write_schema(src, "v0.1.0/a.json", BASE + "v0.1.0/a.json") + write_schema(src, "v0.2.0/a.json", BASE + "v0.2.0/a.json") + assert publish(src, out) == ["v0.1.0/a.json", "v0.2.0/a.json"] + + +# --- reference closure ---------------------------------------------------- + +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_resolve_pointer_walks_objects_and_arrays(): + doc = {"$defs": {"S": {"type": "string"}}, "list": [{"a": 1}]} + assert resolve_pointer(doc, "/$defs/S") + assert resolve_pointer(doc, "/list/0/a") + assert not resolve_pointer(doc, "/$defs/Missing") + assert not resolve_pointer(doc, "/list/9") + + +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_fails_on_a_cross_file_fragment_that_does_not_exist(tmp_path): + """Renaming a $defs entry another schema points at is the likeliest real breakage.""" + src, out = tmp_path / "spec", tmp_path / "out" + write_schema(src, "v0.1.0/t.json", BASE + "v0.1.0/t.json", {"$defs": {"Renamed": {}}}) + write_schema(src, "v0.1.0/s.json", BASE + "v0.1.0/s.json", + {"properties": {"p": {"$ref": "t.json#/$defs/Sig"}}}) + with pytest.raises(SchemaError, match="does not exist in"): + publish(src, out) + + +def test_publish_accepts_a_cross_file_fragment_that_exists(tmp_path): + src, out = tmp_path / "spec", tmp_path / "out" + write_schema(src, "v0.1.0/t.json", BASE + "v0.1.0/t.json", {"$defs": {"Sig": {"type": "string"}}}) + write_schema(src, "v0.1.0/s.json", BASE + "v0.1.0/s.json", + {"properties": {"p": {"$ref": "t.json#/$defs/Sig"}}}) + assert len(publish(src, out)) == 2 + + +def test_publish_fails_on_a_broken_self_fragment(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/Missing"}}}) + with pytest.raises(SchemaError, match="does not exist in"): + publish(src, out) + + +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"] + + +# --- the real tree -------------------------------------------------------- + +def test_publish_handles_the_real_specification_tree(tmp_path): + published = publish(REPO / "specification", tmp_path / "out") + assert "v0.1.0/acs_schema.json" in published + assert len(published) == len(set(published)) + # No magic count. A count assertion breaks on every legitimate schema addition, + # and the first hand-bump after a collision would hide the collision. + assert len(published) >= 44 + + +def test_every_real_schema_is_a_valid_json_schema(): + """Ref closure is not validity. A closed package can still be unusable.""" + from jsonschema import Draft202012Validator + + for path in sorted((REPO / "specification").rglob("*.json")): + doc = json.loads(path.read_text(encoding="utf-8")) + if isinstance(doc, dict) and "$id" in doc: + Draft202012Validator.check_schema(doc) +``` + +- [ ] **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 new spec version publish untouched. + +$id is attacker-influenced input, not trusted identity. Anyone who can land a file under +specification/ controls the string, and a fork pull request reaches this code on the +runner before review. Every path derived from it is validated and contained. +""" +from __future__ import annotations + +import json +import re +import shutil +import sys +from collections.abc import Iterator +from pathlib import Path +from urllib.parse import unquote, urldefrag, urljoin + +BASE = "https://genai-security-project.github.io/agent-control-standard/schema/" + +# A publishable tail: version directory, then nested names, ending in .json. +SAFE_TAIL = re.compile(r"^v[0-9]+(?:\.[0-9]+)*/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_.-]+\.json$") + +# Draft schemas live here. They must never publish to a normative URI. +NON_NORMATIVE = ("proposals",) + + +class SchemaError(Exception): + """A schema has an unusable $id, an unsafe publish path, or an unresolvable $ref.""" + + +def load_schemas(source: Path) -> dict[Path, dict]: + """Parse every JSON file under source that declares an $id. + + Files without an $id are skipped rather than fatal. Example payloads and fixtures + live under specification/ too, and a contributor adding one must not stop the deploy. + """ + schemas: dict[Path, dict] = {} + for path in sorted(source.rglob("*.json")): + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + raise SchemaError(f"{path}: invalid JSON: {error}") from error + if not isinstance(doc, dict) or "$id" not in doc: + continue + schemas[path] = doc + return schemas + + +def target_for(doc: dict, path: Path) -> str: + """Return the validated publish path a document's $id declares. + + Rejects anything that would escape the output root or land outside the versioned + namespace. The check runs on the decoded string so percent-encoded traversal + cannot slip past it. + """ + 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}") + + tail = sid[len(BASE) :] + if unquote(tail) != tail: + raise SchemaError(f"{path}: $id must not be percent-encoded: {sid}") + if not SAFE_TAIL.match(tail): + raise SchemaError( + f"{path}: $id tail {tail!r} is not a safe publish path. " + "Expected v<version>/<name>.json with no traversal and no absolute prefix." + ) + if any(part in NON_NORMATIVE for part in path.parts): + raise SchemaError( + f"{path}: a file under {'/'.join(NON_NORMATIVE)}/ must not claim the " + f"normative namespace ($id: {sid})" + ) + return tail + + +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 resolve_pointer(doc: dict, pointer: str) -> bool: + """Return whether a JSON Pointer resolves inside doc. An empty pointer means the root.""" + if pointer in ("", "/"): + return True + node: object = doc + for raw in pointer.lstrip("/").split("/"): + token = raw.replace("~1", "/").replace("~0", "~") + if isinstance(node, dict): + if token not in node: + return False + node = node[token] + elif isinstance(node, list): + if not token.isdigit() or int(token) >= len(node): + return False + node = node[int(token)] + else: + return False + return True + + +def verify_refs(docs: dict[Path, dict], by_id: dict[str, dict]) -> None: + """Fail if a $ref inside our namespace does not resolve, fragment included. + + Checking only the file leaves the likeliest real breakage undetected: renaming a + $defs entry another schema points at. The package holds one cross-file fragment + reference and it targets the signature definition. + """ + for path, doc in docs.items(): + sid = doc["$id"] + for ref in iter_refs(doc): + target, fragment = urldefrag(urljoin(sid, ref)) + if not target.startswith(BASE): + continue # external reference, not ours to publish + if target not in by_id: + raise SchemaError( + f"{path}: $ref {ref!r} resolves to {target}, which no $id publishes" + ) + if fragment == "" or fragment.startswith("/"): + if not resolve_pointer(by_id[target], fragment): + raise SchemaError( + f"{path}: $ref {ref!r} points at {fragment!r}, " + f"which does not exist in {target}" + ) + + +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}") + + out.mkdir(parents=True, exist_ok=True) + out_root = out.resolve() + by_id: dict[str, dict] = {} + seen: dict[str, Path] = {} + published: list[str] = [] + + for path, doc in docs.items(): + rel = target_for(doc, path) + sid = doc["$id"] + if sid in seen: + raise SchemaError(f"{path}: duplicate $id {sid}, already declared by {seen[sid]}") + seen[sid] = path + + destination = (out / rel).resolve() + # Belt and braces. SAFE_TAIL should make this unreachable. If it ever is + # reachable, the build stops rather than writing outside the artifact. + if not destination.is_relative_to(out_root): + raise SchemaError(f"{path}: $id escapes the output root: {sid}") + + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(path, destination) + by_id[sid] = doc + published.append(rel) + + verify_refs(docs, by_id) + return sorted(published) + + +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, 29 tests (the parametrized case contributes 8) + +- [ ] **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 validated paths derived from their \$id + +Derives each destination from the schema's own \$id rather than from +hardcoded directory names, then asserts that every \$ref inside the +namespace resolves, fragment included. + +Treats \$id as untrusted input. It is a pull-request-writable string used +to build a filesystem path, and a fork's copy reaches this code on the +runner before review. An \$id tail of ../index.html overwrote the +rendered landing page in the artifact, and an absolute tail wrote +outside the output tree entirely, because pathlib discards the left +operand when the right is absolute. Both are now rejected by pattern and +by a resolved-path containment check. + +Also rejects a duplicate \$id, which previously collapsed two schemas +into one published file with no error, and a draft under proposals/ +claiming the normative namespace. Verifying the fragment as well as the +file catches a renamed \$defs target, which the package depends on for +its signature definition." +``` + +--- + +## Task 2: Landing page, styles, and assets + +Builds the static page. Every value that varies with repository state is a placeholder that Task 3 fills and machine-checks, including the hero diagram. + +**Files:** +- Create: `landing/index.html`, `landing/assets/acs.css`, `landing/assets/starburst.svg`, `landing/assets/icon.svg`, `landing/assets/fonts/` +- 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. +- Produces: five placeholders, spelled exactly `<!--ACS:SPEC_VERSION-->`, `<!--ACS:SCHEMA_COUNT-->`, `<!--ACS:SCHEMA_HREF-->`, `<!--ACS:WORKSTREAMS-->`, `<!--ACS:STARBURST-->`. + +- [ ] **Step 1: Vendor the Inter font** + +The page must contact no third party, so the font ships with the site. Inter is licensed under the SIL Open Font License 1.1, which permits redistribution. + +```bash +mkdir -p landing/assets/fonts +cd landing/assets/fonts +curl -sSLO https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip +unzip -j Inter-4.1.zip 'web/InterVariable.woff2' -d . +rm Inter-4.1.zip +shasum -a 256 InterVariable.woff2 | tee CHECKSUMS.txt +``` + +Record the printed checksum in `CHECKSUMS.txt` and commit it alongside the font. If the download fails or the release layout has changed, stop and use the system font stack instead by deleting the `@font-face` rule in Step 2; the stack in `--acs-font` already renders the page correctly without Inter. + +- [ ] **Step 2: Write the design tokens and layout** + +Create `landing/assets/acs.css`. Token values come from the live site, except the five that failed a measured contrast check. Each replacement is annotated with its computed ratio. + +```css +/* ACS landing page. Tokens mirror agentcontrolstandard.org, except where the source + value failed a WCAG contrast measurement. Those five carry their ratio inline. */ + +@font-face { + font-family: "Inter"; + src: url("fonts/InterVariable.woff2") format("woff2"); + font-weight: 100 900; + font-display: swap; +} + +: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; + /* Was hsla(0,0%,7%,.18), which composited to #d4d4d4 for 1.48:1 against the page. + SC 1.4.11 needs 3:1. Solid navy measures 8.72:1. */ + --acs-focus-ring: #1b4f72; + --acs-grid-line: hsla(0, 0%, 7%, 0.04); + --acs-node-fill: #ffffff; + --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 center of the diagram was + close to invisible. #7d8899 measures 3.29:1. */ + --acs-hex-stroke: #7d8899; + --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: ui-monospace, SFMono-Regular, "JetBrains Mono", "Fira Code", 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; + /* Was #6b7280 for 3.74:1 on the surface. SC 1.4.3 needs 4.5:1. This is 5.48:1. */ + --acs-text-muted: #868e9c; + --acs-brand: #1b4f72; + --acs-accent-navy: #2e86c1; + --acs-accent-teal: #1abc9c; + --acs-border: #2a2a2a; + --acs-border-strong: #373737; + --acs-footer: #0a0a0a; + /* Was hsla(0,0%,100%,.2) for 1.77:1. Solid measures 8.67:1. */ + --acs-focus-ring: #63b3ed; + --acs-grid-line: hsla(0, 0%, 100%, 0.04); + --acs-node-fill: #111111; + --acs-node-stroke: #a0aec0; + /* Was #3d4f65 for 2.36:1 against the page. This is 8.78:1. */ + --acs-spoke: #a0aec0; + --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: #868e9c; + --acs-brand: #1b4f72; + --acs-accent-navy: #2e86c1; + --acs-accent-teal: #1abc9c; + --acs-border: #2a2a2a; + --acs-border-strong: #373737; + --acs-footer: #0a0a0a; + --acs-focus-ring: #63b3ed; + --acs-grid-line: hsla(0, 0%, 100%, 0.04); + --acs-node-fill: #111111; + --acs-node-stroke: #a0aec0; + --acs-spoke: #a0aec0; + --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; text-decoration: none; } +.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); } +footer nav { display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 1rem; } + +@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; } +} + +/* 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 + `animation: none` does not reach them. */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; } +} +``` + +- [ ] **Step 3: Write the starburst** + +Create `landing/assets/starburst.svg`. Geometry comes from the live site: a `0 0 680 680` viewBox, center `(340, 340)`, six nodes of radius 48. Node centers 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. + +SMIL animation ignores `animation: none`, so each animating element carries `systemLanguage`-independent guards through a CSS rule that sets `visibility` on a wrapper is not reliable either. The dependable approach is to gate the animation elements themselves with a media query in an internal stylesheet, which SVG honours. + +```svg +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 680 680" fill="none" + class="starburst" role="img" + aria-label="Six agent decision points routed through a central ACS control panel"> + <style> + /* SMIL does not respond to `animation: none`. Disabling the elements is what works. */ + @media (prefers-reduced-motion: reduce) { + animate, animateMotion, animateTransform { display: none; } + } + </style> + <defs> + <radialGradient id="acs-glow" cx="50%" cy="50%" r="50%"> + <stop offset="0%" stop-color="currentColor" stop-opacity="0.15"/> + <stop offset="100%" stop-color="currentColor" stop-opacity="0"/> + </radialGradient> + </defs> + + <circle cx="340" cy="340" r="150" fill="url(#acs-glow)" color="var(--acs-hex-stroke)"> + <animate attributeName="r" values="140;170;140" dur="8s" repeatCount="indefinite"/> + </circle> + + <!-- Orbit rings expand outward on an eight second cycle, offset by half. --> + <circle cx="340" cy="340" r="60" fill="none" stroke="var(--acs-hex-stroke)" stroke-width="0.5" opacity="0"> + <animate attributeName="r" values="60;240" dur="8s" begin="0s" repeatCount="indefinite"/> + <animate attributeName="opacity" values="0.2;0" dur="8s" begin="0s" repeatCount="indefinite"/> + </circle> + <circle cx="340" cy="340" r="60" fill="none" stroke="var(--acs-hex-stroke)" stroke-width="0.5" opacity="0"> + <animate attributeName="r" values="60;240" dur="8s" begin="4s" repeatCount="indefinite"/> + <animate attributeName="opacity" values="0.2;0" dur="8s" begin="4s" repeatCount="indefinite"/> + </circle> + + <!-- One group per node: a dashed quadratic spoke, a particle traveling it, the node, + and a two-line label. Particle begin times stagger by 1.5s so traffic reads as + continuous rather than synchronized. --> + <g> + <path d="M340,340 Q356,197 340,80" stroke="var(--acs-spoke)" stroke-width="1.2" stroke-dasharray="4 3" fill="none"/> + <circle r="3.5" fill="var(--acs-node-stroke)" opacity="0.7"> + <animateMotion dur="5s" begin="0s" repeatCount="indefinite" path="M340,340 Q356,197 340,80"/> + </circle> + <circle cx="340" cy="80" r="48" fill="var(--acs-node-fill)" stroke="var(--acs-node-stroke)" stroke-width="2"/> + <text x="340" y="75" text-anchor="middle" fill="var(--acs-text-soft)" font-size="17" font-family="Inter, sans-serif">LLM</text> + <text x="340" y="96" text-anchor="middle" fill="var(--acs-text-soft)" font-size="17" font-family="Inter, sans-serif">agent</text> + </g> + <g> + <path d="M340,340 Q471.84,282.36 565.17,210" stroke="var(--acs-spoke)" stroke-width="1.2" stroke-dasharray="4 3" fill="none"/> + <circle r="3.5" fill="var(--acs-node-stroke)" opacity="0.7"> + <animateMotion dur="5.3s" begin="1.5s" repeatCount="indefinite" path="M340,340 Q471.84,282.36 565.17,210"/> + </circle> + <circle cx="565.17" cy="210" r="48" fill="var(--acs-node-fill)" stroke="var(--acs-node-stroke)" stroke-width="2"/> + <text x="565.17" y="205" text-anchor="middle" fill="var(--acs-text-soft)" font-size="17" font-family="Inter, sans-serif">Tool</text> + <text x="565.17" y="226" text-anchor="middle" fill="var(--acs-text-soft)" font-size="17" font-family="Inter, sans-serif">call</text> + </g> + <g> + <path d="M340,340 Q455.84,425.36 565.17,470" stroke="var(--acs-spoke)" stroke-width="1.2" stroke-dasharray="4 3" fill="none"/> + <circle r="3.5" fill="var(--acs-node-stroke)" opacity="0.7"> + <animateMotion dur="5.6s" begin="3s" repeatCount="indefinite" path="M340,340 Q455.84,425.36 565.17,470"/> + </circle> + <circle cx="565.17" cy="470" r="48" fill="var(--acs-node-fill)" stroke="var(--acs-node-stroke)" stroke-width="2"/> + <text x="565.17" y="465" text-anchor="middle" fill="var(--acs-text-soft)" font-size="17" font-family="Inter, sans-serif">Output</text> + <text x="565.17" y="486" text-anchor="middle" fill="var(--acs-text-soft)" font-size="17" font-family="Inter, sans-serif">guard</text> + </g> + <g> + <path d="M340,340 Q324,483 340,600" stroke="var(--acs-spoke)" stroke-width="1.2" stroke-dasharray="4 3" fill="none"/> + <circle r="3.5" fill="var(--acs-node-stroke)" opacity="0.7"> + <animateMotion dur="5.9s" begin="4.5s" repeatCount="indefinite" path="M340,340 Q324,483 340,600"/> + </circle> + <circle cx="340" cy="600" r="48" fill="var(--acs-node-fill)" stroke="var(--acs-node-stroke)" stroke-width="2"/> + <text x="340" y="595" text-anchor="middle" fill="var(--acs-text-soft)" font-size="17" font-family="Inter, sans-serif">Sub</text> + <text x="340" y="616" text-anchor="middle" fill="var(--acs-text-soft)" font-size="17" font-family="Inter, sans-serif">agent</text> + </g> + <g> + <path d="M340,340 Q208.16,397.64 114.83,470" stroke="var(--acs-spoke)" stroke-width="1.2" stroke-dasharray="4 3" fill="none"/> + <circle r="3.5" fill="var(--acs-node-stroke)" opacity="0.7"> + <animateMotion dur="6.2s" begin="6s" repeatCount="indefinite" path="M340,340 Q208.16,397.64 114.83,470"/> + </circle> + <circle cx="114.83" cy="470" r="48" fill="var(--acs-node-fill)" stroke="var(--acs-node-stroke)" stroke-width="2"/> + <text x="114.83" y="465" text-anchor="middle" fill="var(--acs-text-soft)" font-size="17" font-family="Inter, sans-serif">Memory</text> + <text x="114.83" y="486" text-anchor="middle" fill="var(--acs-text-soft)" font-size="17" font-family="Inter, sans-serif">store</text> + </g> + <g> + <path d="M340,340 Q224.16,254.64 114.83,210" stroke="var(--acs-spoke)" stroke-width="1.2" stroke-dasharray="4 3" fill="none"/> + <circle r="3.5" fill="var(--acs-node-stroke)" opacity="0.7"> + <animateMotion dur="6.5s" begin="7.5s" repeatCount="indefinite" path="M340,340 Q224.16,254.64 114.83,210"/> + </circle> + <circle cx="114.83" cy="210" r="48" fill="var(--acs-node-fill)" stroke="var(--acs-node-stroke)" stroke-width="2"/> + <text x="114.83" y="205" text-anchor="middle" fill="var(--acs-text-soft)" font-size="17" font-family="Inter, sans-serif">Code</text> + <text x="114.83" y="226" text-anchor="middle" fill="var(--acs-text-soft)" font-size="17" font-family="Inter, sans-serif">exec</text> + </g> + + <g> + <polygon points="340,272 398.89,306 398.89,374 340,408 281.11,374 281.11,306" + fill="var(--acs-hex-fill)" stroke="var(--acs-hex-stroke)" stroke-width="2"/> + <polygon points="340,291.04 382.4,315.52 382.4,364.48 340,388.96 297.6,364.48 297.6,315.52" + fill="none" stroke="var(--acs-hex-stroke)" stroke-width="0.5" opacity="0.4"/> + <text x="340" y="326" text-anchor="middle" fill="var(--acs-text)" font-size="18" font-family="Inter, sans-serif">ACS</text> + <text x="340" y="346" text-anchor="middle" fill="var(--acs-text)" font-size="18" font-family="Inter, sans-serif">control</text> + <text x="340" y="366" text-anchor="middle" fill="var(--acs-text)" font-size="18" font-family="Inter, sans-serif">panel</text> + </g> +</svg> +``` + +- [ ] **Step 4: Write the favicon** + +Create `landing/assets/icon.svg`, matching the live site's mark: + +```svg +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"> + <rect width="32" height="32" rx="6" fill="#1B4F72"/> + <text x="16" y="21.5" text-anchor="middle" font-family="Inter, system-ui, sans-serif" + font-size="12" font-weight="700" letter-spacing="0.5" fill="#FFFFFF">ACS</text> +</svg> +``` + +- [ ] **Step 5: Write the page** + +Create `landing/index.html`. The title carries no em dash. The contact list separates general contact from vulnerability reporting so the routing in `SECURITY.md` and `CODE_OF_CONDUCT.md` survives, and the footer links every governing document. + +```html +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>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 high-risk AI systems to be designed for effective human + oversight, including the ability for a person to intervene in or interrupt the + system (Regulation (EU) + 2024/1689, Article 14). The NIST AI Risk Management Framework, which is + voluntary guidance rather than regulation, describes continuous monitoring and the + ability to deactivate systems operating outside intended limits + (NIST AI 100-1, MANAGE 2.4).

+

Both describe controls that 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.

+ +

Reporting a problem

+

Report a security vulnerability through + GitHub + private vulnerability reporting, which is the channel our + security + policy covers. Report a Code of Conduct concern through the + OWASP Code of + Conduct process, which handles reports independently of this project's + maintainers.

+
+
+
+ + + + + + +``` + +- [ ] **Step 6: Commit** + +```bash +git add landing/ +git commit -m "Add the landing page, its design tokens, and the starburst diagram + +Tokens mirror agentcontrolstandard.org, except five that failed a +measured contrast check and are annotated with their computed ratios. +The focus ring was a translucent overlay compositing to 1.48:1 against +the page where WCAG SC 1.4.11 requires 3:1, and the hexagon stroke at +the center of the diagram measured 1.47:1 against its own fill. + +The hero is a machine-filled placeholder rather than a hand-applied +paste, so the renderer's placeholder check covers it. Inter ships with +the site so the page contacts no third party, which is the same +reasoning that removed analytics. + +The contact section separates general questions from vulnerability +reporting and Code of Conduct reports, so publishing an address does not +route those away from the channels that handle them independently." +``` + +--- + +## Task 3: Build-time content injection and page guards + +Fills placeholders from repository state, escapes untrusted content, and runs the page guards against the **rendered** output rather than the template. + +**Files:** +- Create: `tools/render_landing.py`, `tests/test_render_landing.py`, `tests/test_landing_page.py` + +**Interfaces:** +- Consumes: `publish_schemas.load_schemas`, `publish_schemas.target_for`, `publish_schemas.SchemaError`, and the five placeholders from Task 2. +- Produces: `class RenderError(Exception)`; `REQUIRED_PLACEHOLDERS: tuple[str, ...]`; `spec_version(source)`; `schema_count(source)`; `parse_workstreams(text)`; `render_workstreams(rows)`; `render(template, source, governance, starburst)`. 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. + +GOVERNANCE.md reaches an HTML attribute position, so the escaping cases are the point. +A roster pull request is reviewed for names and handles, not for quoting. +""" +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, +) + +REPO = Path(__file__).resolve().parents[1] + +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. +""" + +FULL_TEMPLATE = ( + "" + "
" +) + + +@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_returns_the_highest_of_several(tmp_path): + """Old versions stay published, so more than one is the steady state.""" + root = tmp_path / "specification" + root.mkdir() + for version in ("v0.1.0", "v0.2.0", "v0.10.0"): + (root / f"{version}.json").write_text( + json.dumps({"$id": BASE + f"{version}/x.json"}), encoding="utf-8") + assert spec_version(root) == "v0.10.0" + + +# --- governance parsing --------------------------------------------------- + +def test_parse_workstreams_reads_only_the_workstream_table(): + assert [name for name, _ in parse_workstreams(GOVERNANCE)] == ["Identity", "Spec"] + + +def test_parse_workstreams_accepts_heading_case_and_spacing(): + """A formatter or a title-case edit must not take the site down.""" + for heading in ("## Workstream Leads", "## Workstream leads ", "## Workstream leads"): + text = GOVERNANCE.replace("## Workstream leads", heading) + assert len(parse_workstreams(text)) == 2 + + +def test_parse_workstreams_stops_at_any_heading_level(): + text = GOVERNANCE.replace("## Origins", "### Emeritus\n\n| Old | Thing |\n| --- | --- |\n| A | B |\n\n## Origins") + assert [name for name, _ in parse_workstreams(text)] == ["Identity", "Spec"] + + +def test_parse_workstreams_keeps_an_escaped_pipe(): + text = GOVERNANCE.replace("| Identity |", r"| Identity \| IAM |") + assert [name for name, _ in parse_workstreams(text)] == ["Identity | IAM", "Spec"] + + +def test_parse_workstreams_raises_on_a_wrong_width_row(): + """A silently dropped workstream is worse than a loud failure.""" + text = GOVERNANCE.replace("| Spec |", "| Spec | extra |") + with pytest.raises(RenderError, match="cells"): + parse_workstreams(text) + + +def test_parse_workstreams_fails_without_the_section(): + with pytest.raises(RenderError, match="Workstream leads"): + parse_workstreams("# Governance\n\nNothing here.\n") + + +def test_parse_workstreams_handles_the_real_file(): + assert len(parse_workstreams((REPO / "GOVERNANCE.md").read_text(encoding="utf-8"))) == 5 + + +# --- escaping ------------------------------------------------------------- + +def test_render_workstreams_converts_markdown_links_to_html(): + html = render_workstreams([("Identity", "Eva Benn ([@evabenn](https://example.com))")]) + assert '@evabenn' in html + + +def parse_attributes(markup: str) -> list[tuple[str, list[tuple[str, str | None]]]]: + """Return (tag, attributes) for every start tag. + + String matching is the wrong tool here. A safely escaped payload still contains the + literal text `onfocus=` inside an attribute value, so grepping for it reports a + breakout that does not exist. Only a parser answers whether an attribute is real. + """ + from html.parser import HTMLParser + + class Collector(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.tags: list[tuple[str, list[tuple[str, str | None]]]] = [] + + def handle_starttag(self, tag, attrs): + self.tags.append((tag, attrs)) + + collector = Collector() + collector.feed(markup) + return collector.tags + + +def test_render_workstreams_blocks_attribute_breakout(): + html = render_workstreams([("X", '[@ok](https://x" autofocus onfocus="alert(1))')]) + attributes = [name for _, attrs in parse_attributes(html) for name, _ in attrs] + assert not [name for name in attributes if name.startswith("on")] + assert "autofocus" not in attributes + # The quote survives as an entity inside the value rather than as a delimiter. + assert """ in html + + +def test_render_workstreams_drops_a_javascript_scheme(): + html = render_workstreams([("X", "[@x](javascript:alert(1))")]) + assert "javascript:" not in html + assert "@x" in html # the label survives, the link does not + + +def test_render_workstreams_escapes_raw_html(): + html = render_workstreams([("", "safe")]) + assert " + + diff --git a/mkdocs.yml b/mkdocs.yml index d9aacb7..69bb764 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,7 +16,8 @@ docs_dir: docs theme: name: material - logo: assets/logo.png + custom_dir: overrides + logo: assets/icon.svg features: - search.suggest # edit this page for each doc page @@ -24,37 +25,26 @@ 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 - font: - text: Roboto - code: Roboto Mono - favicon: assets/logo.png + icon: material/weather-sunny + name: Switch to light mode + font: false + favicon: assets/icon.svg plugins: - search # Additional configuration extra: - analytics: - provider: google - property: !ENV GOOGLE_ANALYTICS_KEY social: - icon: /fontawesome/brands/github name: Join the discussion on GitHub diff --git a/overrides/partials/source.html b/overrides/partials/source.html new file mode 100644 index 0000000..5cdcfcf --- /dev/null +++ b/overrides/partials/source.html @@ -0,0 +1,19 @@ +{#- + Renders the repository link in the documentation header. + + The theme's own partial sets data-md-component="source", which makes it fetch release + and star counts from api.github.com on every page load and sends each reader's IP + address to a third party. This override exists to render the link without that hook. + + The class names come from the theme's stylesheet, which is what styles this element. + The mark comes from the Simple Icons set the theme bundles, which is CC0 and so adds + no attribution obligation to the pages it is inlined into. +-#} + +
+ {% include ".icons/simple/github.svg" %} +
+
+ {{ config.repo_name }} +
+
diff --git a/pyproject.toml b/pyproject.toml index 50c99b1..d88ce98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,3 +5,6 @@ description = "ACS Documentation" license = "Apache-2.0" requires-python = ">=3.11" dependencies = [ "mike>=1.2.0", "mkdocs-material>=9.6.14", "pymdown-extensions>=11.0.1",] + +[dependency-groups] +dev = [ "pytest>=8.0", "jsonschema>=4.25.0",] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8376416 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +"""Shared patterns for the guards that keep the site from contacting a third party. + +The two guards were duplicated regexes in separate files. One definition means they +cannot drift, and the drift is what would let one of them quietly stop matching. +""" +import re + +# Anchor links are prose and fetch nothing. Only these constructs reach a third party. +RESOURCE_TAG = re.compile( + r"""<(?:script|link|img|iframe|source|audio|video|embed|object)\b[^>]*?""" + r"""(?:src|href|data)\s*=\s*['"](?:https?:)?//([^/'"]+)""", + re.I, +) +IMPORT_RULE = re.compile(r"""@import\s+(?:url\()?['"]?(?:https?:)?//([^/'"]+)""", re.I) +# A stylesheet reaches a third party with no script, through url() in background-image, +# @font-face src, or a cursor. HTML-only scanning cannot see any of it. +URL_FUNC = re.compile(r"""url\(\s*['"]?(?:https?:)?//([^/'"]+)""", re.I) + + +def third_party_hosts(text: str, self_hosts: set[str]) -> set[str]: + """Return every third-party host the text would load from.""" + hosts = {m.group(1) for m in RESOURCE_TAG.finditer(text)} + hosts |= {m.group(1) for m in IMPORT_RULE.finditer(text)} + hosts |= {m.group(1) for m in URL_FUNC.finditer(text)} + return hosts - self_hosts diff --git a/tests/test_docs_theme.py b/tests/test_docs_theme.py new file mode 100644 index 0000000..f2233a0 --- /dev/null +++ b/tests/test_docs_theme.py @@ -0,0 +1,146 @@ +"""Guards on the documentation site's theme and its published URLs. + +The canonical bug these cover was invisible to every earlier test, because none of them +compared the URL a page declares against the path it is published at. +""" +import os +import re +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] +DOCS_BASE = "https://example.org/agent-control-standard/docs/" + + +@pytest.fixture(scope="module") +def built_docs(tmp_path_factory) -> Path: + out = tmp_path_factory.mktemp("docs") + env = dict(os.environ) + env.pop("GOOGLE_ANALYTICS_KEY", None) + env["GITHUB_PAGES_URL"] = DOCS_BASE + result = subprocess.run( + ["uv", "run", "mkdocs", "build", "--strict", "-d", str(out)], + cwd=REPO, env=env, capture_output=True, text=True, + ) + if result.returncode != 0: + pytest.fail(f"mkdocs build failed:\n{result.stdout}\n{result.stderr}") + return out + + +def test_every_canonical_matches_the_page_path(built_docs): + """A canonical that omits /docs/ names a URL that does not exist.""" + wrong = [] + for page in built_docs.rglob("index.html"): + match = re.search(r'rel="canonical" href="([^"]+)"', page.read_text(encoding="utf-8")) + if not match: + continue + relative = page.parent.relative_to(built_docs).as_posix() + expected = DOCS_BASE if relative == "." else f"{DOCS_BASE}{relative}/" + if match.group(1) != expected: + wrong.append((relative, match.group(1))) + assert not wrong, f"canonical does not match publish path: {wrong[:5]}" + + +def test_sitemap_urls_sit_under_the_docs_path(built_docs): + sitemap = built_docs / "sitemap.xml" + if not sitemap.exists(): + import gzip + text = gzip.open(built_docs / "sitemap.xml.gz", "rt").read() + else: + text = sitemap.read_text(encoding="utf-8") + urls = re.findall(r"([^<]+)", text) + assert urls, "sitemap has no entries" + assert all(u.startswith(DOCS_BASE) for u in urls), [u for u in urls if not u.startswith(DOCS_BASE)][:5] + + +def test_docs_stylesheet_declares_the_acs_tokens(): + css = (REPO / "docs" / "stylesheets" / "extra.css").read_text(encoding="utf-8") + for token in ['[data-md-color-scheme="default"]', '[data-md-color-scheme="slate"]', + "--md-text-font", "--md-typeset-a-color"]: + assert token in css + + +def test_docs_use_the_same_mark_as_the_landing_page(): + config = (REPO / "mkdocs.yml").read_text(encoding="utf-8") + assert "logo: assets/icon.svg" in config + assert "favicon: assets/icon.svg" in config + assert (REPO / "docs" / "assets" / "icon.svg").is_file() + + +def test_the_two_copies_of_the_mark_stay_identical(): + """MkDocs needs the logo inside docs_dir, so the file exists twice. Nothing else + keeps the copies in step.""" + assert (REPO / "docs" / "assets" / "icon.svg").read_bytes() == \ + (REPO / "landing" / "assets" / "icon.svg").read_bytes() + + +def test_docs_do_not_call_the_github_api_at_runtime(built_docs): + """Material fetches star counts from api.github.com when this hook is present. + + A runtime fetch is invisible to the markup-scanning guards, so this asserts the + trigger is absent rather than trying to find the request. + """ + for page in built_docs.rglob("*.html"): + assert 'data-md-component="source"' not in page.read_text(encoding="utf-8") + + +def test_repository_link_survives_the_override(built_docs): + """Dropping the fetch must not drop the link it decorated.""" + index = (built_docs / "index.html").read_text(encoding="utf-8") + assert 'class="md-source"' in index + assert "github.com/GenAI-Security-Project/agent-control-standard" in index + + +def test_the_header_keeps_its_repository_icon(built_docs): + """The icon vanished once when this partial was rewritten and a person had to catch it. + + Asserting the mark is inlined turns a silent regression into a failing build. The + fetch hook staying absent is covered separately, and both have to hold together. + """ + index = (built_docs / "index.html").read_text(encoding="utf-8") + match = re.search(r'class="md-source__icon md-icon">\s*()', index, re.S) + assert match, "the repository icon is not inlined in the header" + assert "viewBox" in match.group(1) + assert 'data-md-component="source"' not in index + + +def test_the_theme_still_styles_the_override(): + """The override uses the theme's class names, which the theme's stylesheet supplies. + + Writing the partial for this project means nothing tracks upstream markup. What still + has to hold is that these classes exist, because a rename upstream would leave the + header rendering unstyled with every other test passing. + """ + import material + + stylesheets = Path(material.__file__).parent / "templates" / "assets" / "stylesheets" + css = "".join(p.read_text(encoding="utf-8") for p in stylesheets.glob("main.*.css")) + if not css: + 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 + + +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 diff --git a/tests/test_landing_page.py b/tests/test_landing_page.py new file mode 100644 index 0000000..b181cd2 --- /dev/null +++ b/tests/test_landing_page.py @@ -0,0 +1,213 @@ +"""Content guards on the page that ships. + +These run against render() output, not the template. The template is hand-reviewed, +but the injected sections are not. +""" +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) +from render_landing import render + +REPO = Path(__file__).resolve().parents[1] +LANDING = REPO / "landing" + + +@pytest.fixture(scope="module") +def page() -> str: + return render( + (LANDING / "index.html").read_text(encoding="utf-8"), + REPO / "specification", + (REPO / "GOVERNANCE.md").read_text(encoding="utf-8"), + (LANDING / "assets" / "starburst.svg").read_text(encoding="utf-8"), + ) + + +def test_the_hero_diagram_is_present(page): + assert " None: + super().__init__() + self.handlers: list[str] = [] + self.schemes: list[str] = [] + + def handle_starttag(self, tag, attrs): + for name, value in attrs: + if name.startswith("on"): + self.handlers.append(f"<{tag} {name}>") + if name in ("href", "src") and value and value.lower().startswith("javascript:"): + self.schemes.append(f"<{tag} {name}={value[:40]}>") + + collector = Collector() + collector.feed(page) + assert not collector.handlers, collector.handlers + assert not collector.schemes, collector.schemes + + +def test_no_em_dash(page): + assert "—" not in page + + +def test_the_title_has_no_em_dash_and_is_present(page): + match = re.search(r"(.*?)", page) + assert match and "—" not in match.group(1) + + +def test_footer_links_the_governing_documents(page): + for doc in ["GOVERNANCE.md", "SECURITY.md", "CODE_OF_CONDUCT.md", "LICENSING.md"]: + assert doc in page + + +def test_license_names_are_linked(page): + assert "apache.org/licenses/LICENSE-2.0" in page + assert "creativecommons.org/licenses/by-sa/4.0" in page + + +def test_dark_theme_and_reduced_motion_are_defined(): + css = (LANDING / "assets" / "acs.css").read_text(encoding="utf-8") + assert 'data-theme="dark"' in css + assert "prefers-color-scheme: dark" in css + assert "prefers-reduced-motion: reduce" in css + svg = (LANDING / "assets" / "starburst.svg").read_text(encoding="utf-8") + # `animation: none` does not reach SMIL, so the SVG carries its own guard. + assert "prefers-reduced-motion: reduce" in svg + + +def test_focus_ring_and_muted_text_meet_contrast(): + """Encodes the measurements that four inherited token values failed.""" + css = (LANDING / "assets" / "acs.css").read_text(encoding="utf-8") + + def luminance(hex_color: str) -> float: + h = hex_color.lstrip("#") + channels = [] + for i in (0, 2, 4): + c = int(h[i : i + 2], 16) / 255 + channels.append(c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4) + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2] + + def ratio(a: str, b: str) -> float: + la, lb = luminance(a), luminance(b) + hi, lo = max(la, lb), min(la, lb) + return (hi + 0.05) / (lo + 0.05) + + def token(name: str, block: str) -> str: + section = css.split(block, 1)[1] + return re.search(rf"{name}:\s*(#[0-9a-fA-F]{{6}})", section).group(1) + + light_ring = token("--acs-focus-ring", ":root {") + assert ratio(light_ring, "#ffffff") >= 3.0 + dark_ring = token("--acs-focus-ring", ':root[data-theme="dark"]') + assert ratio(dark_ring, "#0a0a0a") >= 3.0 + dark_muted = token("--acs-text-muted", ':root[data-theme="dark"]') + assert ratio(dark_muted, "#161616") >= 4.5 + light_hex = token("--acs-hex-stroke", ":root {") + assert ratio(light_hex, "#f4f5f7") >= 3.0 + + +def test_built_with_section_names_the_standards(page): + """The section carries named standards with a stated relationship, not a logo wall.""" + for standard in ["OWASP ASI", "AIVSS", "OpenTelemetry", "OCSF", "CycloneDX", "SPDX", "MCP", "A2A"]: + assert standard in page + + +def test_no_dead_a2a_link(page): + """The documentation still links a google-a2a.github.io path that now returns 404.""" + assert "google-a2a.github.io" not in page + + +def test_vendored_font_matches_its_recorded_checksum(): + """The checksum documents provenance. Verifying it makes the record load-bearing.""" + import hashlib + + fonts = LANDING / "assets" / "fonts" + recorded = {} + for line in (fonts / "CHECKSUMS.txt").read_text(encoding="utf-8").splitlines(): + if line.strip(): + digest, name = line.split() + recorded[name.lstrip("*")] = digest + assert recorded, "CHECKSUMS.txt is empty" + for name, digest in recorded.items(): + actual = hashlib.sha256((fonts / name).read_bytes()).hexdigest() + assert actual == digest, f"{name}: recorded {digest}, actual {actual}" + + +def test_font_license_ships_with_the_font(): + """OFL-1.1 conditions redistribution on shipping the notice and the license text.""" + 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 + + +def test_the_sidebar_shows_the_same_mark_as_the_documentation(page): + """The two surfaces drew the same file differently, text here and an image there.""" + assert 'src="assets/icon.svg"' in page + assert ' and the nav's aria-label, so the nav + # content follows the last occurrence, not the first. + external = page.split("External resources")[-1].split("")[0] + assert "docs/" not in external + assert "github.com" in external and "owasp.slack.com" in external + sections = page.split('aria-label="Sections"')[1].split("")[0] + assert 'href="docs/"' in sections diff --git a/tests/test_publish_schemas.py b/tests/test_publish_schemas.py new file mode 100644 index 0000000..ad9ca5c --- /dev/null +++ b/tests/test_publish_schemas.py @@ -0,0 +1,211 @@ +"""Tests for the schema publisher. + +The negative cases are the point. $id reaches this code from any pull request, including +a fork's, and it is used to build a filesystem path. +""" +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, resolve_pointer, target_for + +REPO = Path(__file__).resolve().parents[1] + + +def write_schema(root: Path, rel: str, sid: str, body: dict | None = None) -> Path: + 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 + + +# --- $id validation ------------------------------------------------------- + +def test_target_for_strips_the_namespace_base(): + assert target_for({"$id": BASE + "v0.1.0/acs_schema.json"}, Path("a")) == "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(): + with pytest.raises(SchemaError, match="outside namespace"): + target_for({"$id": "https://example.com/schema/v0.1.0/x.json"}, Path("broken.json")) + + +@pytest.mark.parametrize( + "tail", + [ + "../index.html", + "../../../../pwned.txt", + "/etc/passwd", + "v0.1.0/../../x.json", + "v0.1.0/%2e%2e/x.json", + "index.html", + "v0.1.0/x.txt", + "", + ], +) +def test_target_for_rejects_unsafe_publish_paths(tail): + """Each of these escapes the artifact or lands outside the versioned namespace.""" + with pytest.raises(SchemaError): + target_for({"$id": BASE + tail}, Path("evil.json")) + + +def test_publish_refuses_an_id_that_escapes_the_output_root(tmp_path): + src, out = tmp_path / "spec", tmp_path / "out" + write_schema(src, "v0.1.0/a.json", BASE + "../../../../pwned.txt") + with pytest.raises(SchemaError): + publish(src, out) + assert not (tmp_path.parent / "pwned.txt").exists() + + +def test_publish_rejects_a_draft_claiming_the_normative_namespace(tmp_path): + src, out = tmp_path / "spec", tmp_path / "out" + write_schema(src, "v0.1.0/a.json", BASE + "v0.1.0/a.json") + write_schema(src, "proposals/draft.json", BASE + "v0.1.0/draft.json") + with pytest.raises(SchemaError, match="normative namespace"): + publish(src, out) + + +def test_publish_rejects_a_duplicate_id(tmp_path): + src, out = tmp_path / "spec", tmp_path / "out" + write_schema(src, "v0.1.0/a.json", BASE + "v0.1.0/dup.json", {"x": 1}) + write_schema(src, "v0.1.0/b.json", BASE + "v0.1.0/dup.json", {"x": 2}) + with pytest.raises(SchemaError, match="duplicate \\$id"): + publish(src, out) + + +# --- placement ------------------------------------------------------------ + +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") + assert publish(src, out) == ["v0.1.0/acs_schema.json"] + assert (out / "v0.1.0" / "acs_schema.json").is_file() + + +def test_publish_skips_json_without_an_id(tmp_path): + """An example payload beside a proposal must not stop the deploy.""" + src, out = tmp_path / "spec", tmp_path / "out" + write_schema(src, "v0.1.0/a.json", BASE + "v0.1.0/a.json") + (src / "proposals").mkdir(parents=True, exist_ok=True) + (src / "proposals" / "example.json").write_text('{"session_id": "abc"}', encoding="utf-8") + assert publish(src, out) == ["v0.1.0/a.json"] + + +def test_publish_fails_on_invalid_json(tmp_path): + src, out = tmp_path / "spec", tmp_path / "out" + (src / "v0.1.0").mkdir(parents=True) + (src / "v0.1.0" / "bad.json").write_text("{not json", encoding="utf-8") + with pytest.raises(SchemaError, match="invalid JSON"): + publish(src, out) + + +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_more_than_one_spec_version(tmp_path): + """Old versions stay published so their $id URIs keep resolving.""" + src, out = tmp_path / "spec", tmp_path / "out" + write_schema(src, "v0.1.0/a.json", BASE + "v0.1.0/a.json") + write_schema(src, "v0.2.0/a.json", BASE + "v0.2.0/a.json") + assert publish(src, out) == ["v0.1.0/a.json", "v0.2.0/a.json"] + + +# --- reference closure ---------------------------------------------------- + +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_resolve_pointer_walks_objects_and_arrays(): + doc = {"$defs": {"S": {"type": "string"}}, "list": [{"a": 1}]} + assert resolve_pointer(doc, "/$defs/S") + assert resolve_pointer(doc, "/list/0/a") + assert not resolve_pointer(doc, "/$defs/Missing") + assert not resolve_pointer(doc, "/list/9") + + +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_fails_on_a_cross_file_fragment_that_does_not_exist(tmp_path): + """Renaming a $defs entry another schema points at is the likeliest real breakage.""" + src, out = tmp_path / "spec", tmp_path / "out" + write_schema(src, "v0.1.0/t.json", BASE + "v0.1.0/t.json", {"$defs": {"Renamed": {}}}) + write_schema(src, "v0.1.0/s.json", BASE + "v0.1.0/s.json", + {"properties": {"p": {"$ref": "t.json#/$defs/Sig"}}}) + with pytest.raises(SchemaError, match="does not exist in"): + publish(src, out) + + +def test_publish_accepts_a_cross_file_fragment_that_exists(tmp_path): + src, out = tmp_path / "spec", tmp_path / "out" + write_schema(src, "v0.1.0/t.json", BASE + "v0.1.0/t.json", {"$defs": {"Sig": {"type": "string"}}}) + write_schema(src, "v0.1.0/s.json", BASE + "v0.1.0/s.json", + {"properties": {"p": {"$ref": "t.json#/$defs/Sig"}}}) + assert len(publish(src, out)) == 2 + + +def test_publish_fails_on_a_broken_self_fragment(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/Missing"}}}) + with pytest.raises(SchemaError, match="does not exist in"): + publish(src, out) + + +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"] + + +# --- the real tree -------------------------------------------------------- + +def test_publish_handles_the_real_specification_tree(tmp_path): + published = publish(REPO / "specification", tmp_path / "out") + assert "v0.1.0/acs_schema.json" in published + assert len(published) == len(set(published)) + # No magic count. A count assertion breaks on every legitimate schema addition, + # and the first hand-bump after a collision would hide the collision. + assert len(published) >= 44 + + +def test_every_real_schema_is_a_valid_json_schema(): + """Ref closure is not validity. A closed package can still be unusable.""" + from jsonschema import Draft202012Validator + + for path in sorted((REPO / "specification").rglob("*.json")): + doc = json.loads(path.read_text(encoding="utf-8")) + if isinstance(doc, dict) and "$id" in doc: + Draft202012Validator.check_schema(doc) diff --git a/tests/test_render_landing.py b/tests/test_render_landing.py new file mode 100644 index 0000000..47fe1e8 --- /dev/null +++ b/tests/test_render_landing.py @@ -0,0 +1,197 @@ +"""Tests for build-time content injection. + +GOVERNANCE.md reaches an HTML attribute position, so the escaping cases are the point. +A roster pull request is reviewed for names and handles, not for quoting. +""" +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, +) + +REPO = Path(__file__).resolve().parents[1] + +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. +""" + +FULL_TEMPLATE = ( + "" + "
" +) + + +@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_returns_the_highest_of_several(tmp_path): + """Old versions stay published, so more than one is the steady state.""" + root = tmp_path / "specification" + root.mkdir() + for version in ("v0.1.0", "v0.2.0", "v0.10.0"): + (root / f"{version}.json").write_text( + json.dumps({"$id": BASE + f"{version}/x.json"}), encoding="utf-8") + assert spec_version(root) == "v0.10.0" + + +# --- governance parsing --------------------------------------------------- + +def test_parse_workstreams_reads_only_the_workstream_table(): + assert [name for name, _ in parse_workstreams(GOVERNANCE)] == ["Identity", "Spec"] + + +def test_parse_workstreams_accepts_heading_case_and_spacing(): + """A formatter or a title-case edit must not take the site down.""" + for heading in ("## Workstream Leads", "## Workstream leads ", "## Workstream leads"): + text = GOVERNANCE.replace("## Workstream leads", heading) + assert len(parse_workstreams(text)) == 2 + + +def test_parse_workstreams_stops_at_any_heading_level(): + text = GOVERNANCE.replace("## Origins", "### Emeritus\n\n| Old | Thing |\n| --- | --- |\n| A | B |\n\n## Origins") + assert [name for name, _ in parse_workstreams(text)] == ["Identity", "Spec"] + + +def test_parse_workstreams_keeps_an_escaped_pipe(): + text = GOVERNANCE.replace("| Identity |", r"| Identity \| IAM |") + assert [name for name, _ in parse_workstreams(text)] == ["Identity | IAM", "Spec"] + + +def test_parse_workstreams_raises_on_a_wrong_width_row(): + """A silently dropped workstream is worse than a loud failure.""" + text = GOVERNANCE.replace("| Spec |", "| Spec | extra |") + with pytest.raises(RenderError, match="cells"): + parse_workstreams(text) + + +def test_parse_workstreams_fails_without_the_section(): + with pytest.raises(RenderError, match="Workstream leads"): + parse_workstreams("# Governance\n\nNothing here.\n") + + +def test_parse_workstreams_handles_the_real_file(): + assert len(parse_workstreams((REPO / "GOVERNANCE.md").read_text(encoding="utf-8"))) == 5 + + +# --- escaping ------------------------------------------------------------- + +def test_render_workstreams_converts_markdown_links_to_html(): + html = render_workstreams([("Identity", "Eva Benn ([@evabenn](https://example.com))")]) + assert '@evabenn' in html + + +def parse_attributes(markup: str) -> list[tuple[str, list[tuple[str, str | None]]]]: + """Return (tag, attributes) for every start tag. + + String matching is the wrong tool here. A safely escaped payload still contains the + literal text `onfocus=` inside an attribute value, so grepping for it reports a + breakout that does not exist. Only a parser answers whether an attribute is real. + """ + from html.parser import HTMLParser + + class Collector(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.tags: list[tuple[str, list[tuple[str, str | None]]]] = [] + + def handle_starttag(self, tag, attrs): + self.tags.append((tag, attrs)) + + collector = Collector() + collector.feed(markup) + return collector.tags + + +def test_render_workstreams_blocks_attribute_breakout(): + html = render_workstreams([("X", '[@ok](https://x" autofocus onfocus="alert(1))')]) + attributes = [name for _, attrs in parse_attributes(html) for name, _ in attrs] + assert not [name for name in attributes if name.startswith("on")] + assert "autofocus" not in attributes + # The quote survives as an entity inside the value rather than as a delimiter. + assert """ in html + + +def test_render_workstreams_drops_a_javascript_scheme(): + html = render_workstreams([("X", "[@x](javascript:alert(1))")]) + assert "javascript:" not in html + assert "@x" in html # the label survives, the link does not + + +def test_render_workstreams_escapes_raw_html(): + html = render_workstreams([("", "safe")]) + assert "