diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a1f2a4..33c9c7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,15 +53,39 @@ jobs: # The site's Content-Security-Policy says script-src 'none'. If a build # ever emits a script this fails here rather than in a browser console # on a page that has already shipped. + # + # One exception, and only one: a `type="application/ld+json"` block is a + # data block that never executes, so script-src has nothing to act on. + # It is allowed, and each one is parsed here to prove it really is data + # rather than something wearing the type attribute. run: | if find dist -name '*.js' -o -name '*.mjs' | grep -q .; then echo "A JavaScript file was emitted, which the CSP forbids:" >&2 find dist -name '*.js' -o -name '*.mjs' >&2 exit 1 fi - if grep -rl ' tag reached the HTML, which the CSP forbids:" >&2 - grep -rl '&2 - exit 1 - fi + python3 - <<'CHECK' + import json + import re + import sys + from pathlib import Path + + LD = re.compile( + r']*\btype=["\']application/ld\+json["\'][^>]*>(.*?)', + re.S | re.I, + ) + problems = [] + for page in sorted(Path("dist").rglob("*.html")): + html = page.read_text(encoding="utf-8") + for block in LD.findall(html): + try: + json.loads(block) + except json.JSONDecodeError as bad: + problems.append(f"{page}: the JSON-LD block does not parse: {bad}") + if " ({ + ...page, + // The landing page is the one worth crawling most often, and the only + // one that changes when anything about the tool does. + changefreq: 'weekly', + priority: page.url === 'https://agentchaperone.dev/' ? 1.0 : 0.8, + }), + }), + ], devToolbar: { enabled: false }, }); diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..db8b0b4 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..0c54ebc Binary files /dev/null and b/public/favicon.png differ diff --git a/public/llms.txt b/public/llms.txt new file mode 100644 index 0000000..85a73bb --- /dev/null +++ b/public/llms.txt @@ -0,0 +1,25 @@ +# agent-chaperone + +> agent-chaperone is an open-source npm package that screens an AI agent's tool calls before they run, and the tool results those calls return before the agent reads them. It covers MCP servers through a transparent proxy, and a client's own shell commands, file edits and web fetches through a hooks adapter. + +Every judgment comes back as a probability rather than a verdict. Thresholds live in a policy file rather than in a prompt, and every decision is written to a local log with the numbers that produced it. It starts in shadow mode, which blocks nothing, so the decision to enforce rests on a log the user has read. + +- Install: `npm install -g agent-chaperone` +- Licence: Apache-2.0 +- Source: https://github.com/agent-chaperone/agent-chaperone +- Registry: https://www.npmjs.com/package/agent-chaperone +- What it is not: a sandbox. It sees what a call says it will do and cannot stop a server doing something the call did not describe. +- What leaves the machine: the arguments and results being screened go to the configured model backend. Secret-shaped strings are replaced first, and screening can be turned off per server. + +## Docs + +- [README](https://raw.githubusercontent.com/agent-chaperone/agent-chaperone/main/README.md): install, the two screens, the commands, and the measured results +- [Design](https://raw.githubusercontent.com/agent-chaperone/agent-chaperone/main/docs/design.md): architecture, every screening question and its exact wording, the policy file, the audit log +- [Hooks](https://raw.githubusercontent.com/agent-chaperone/agent-chaperone/main/docs/hooks.md): screening a client's own tools, what each hook command answers, and what hooks cannot see +- [Roadmap](https://raw.githubusercontent.com/agent-chaperone/agent-chaperone/main/ROADMAP.md): what each version shipped + +## Measured + +- [Results](https://agentchaperone.dev/results): one run of jev-1.13.0 against agent-chaperone 0.1.0 on 2026-09-19, with what was caught, what was missed, and what was flagged in error + +Three questions the tool asks are outside those numbers, and the pages say so where they appear: `policy_violation` and `off_task` are only sent when a policy or a task is configured, and `description_steers` asks about a tool description, which no set in the benchmark covers. diff --git a/public/robots.txt b/public/robots.txt index 25f01b6..8036371 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,4 +1,31 @@ +# Everything here is public documentation for an open-source tool, and being +# quoted is the point. Nothing is disallowed, and the named groups below exist +# to say that deliberately rather than by omission. + User-agent: * Allow: / +# Search and answer engines. Named individually because an allow-all that +# happens to include them is not the same as a site that meant to. +User-agent: Googlebot +User-agent: Google-Extended +User-agent: Bingbot +User-agent: DuckDuckBot +User-agent: Applebot +User-agent: Applebot-Extended +User-agent: OAI-SearchBot +User-agent: ChatGPT-User +User-agent: GPTBot +User-agent: ClaudeBot +User-agent: Claude-User +User-agent: Claude-SearchBot +User-agent: anthropic-ai +User-agent: PerplexityBot +User-agent: Perplexity-User +User-agent: CCBot +User-agent: Amazonbot +User-agent: Meta-ExternalAgent +User-agent: cohere-ai +Allow: / + Sitemap: https://agentchaperone.dev/sitemap-index.xml diff --git a/scripts/make-icons.py b/scripts/make-icons.py new file mode 100644 index 0000000..e2b890e --- /dev/null +++ b/scripts/make-icons.py @@ -0,0 +1,55 @@ +"""Draw the raster icons. + +An SVG favicon alone is not enough. Google Search does not use one, and neither +does Safari before 26, so a site with only favicon.svg gets a generic globe +beside every search result. iOS needs its own file as well, because a home +screen icon is masked to a rounded square and anything with its own corner +radius baked in either gets clipped twice or shows a gap. + + python3 scripts/make-icons.py + +Writes public/favicon.png and public/apple-touch-icon.png. Both are drawn rather +than rasterised from favicon.svg: that file carries rx="96", and those corners +are exactly what the iOS mask would fight with. +""" + +from pathlib import Path + +from mark import draw_mark +from PIL import Image, ImageDraw + +BG = (12, 13, 16) +INK = (255, 255, 255) +OUT = Path(__file__).resolve().parent.parent / "public" + + +def icon(size: int, padding: int, radius: int) -> Image.Image: + """The mark on an opaque panel. + + Opaque, not transparent: a transparent icon against a dark home screen or a + dark tab strip is an invisible one, and neither surface promises a + background. + """ + img = Image.new("RGB", (size, size), BG) + d = ImageDraw.Draw(img) + if radius > 0: + # Drawn as a rounded panel over the square, for the places that show the + # file as it is rather than masking it themselves. + d.rounded_rectangle([0, 0, size - 1, size - 1], radius=radius, fill=BG) + draw_mark(d, padding, padding, size - padding * 2, INK) + return img + + +def main() -> None: + # A square icon with its own corners, for the browser tab and search results. + icon(180, 24, 40).save(OUT / "favicon.png", optimize=True) + # iOS masks this itself, so it ships square and opaque with the mark inset + # far enough that the mask cannot clip it. + icon(180, 20, 0).save(OUT / "apple-touch-icon.png", optimize=True) + for name in ("favicon.png", "apple-touch-icon.png"): + path = OUT / name + print(f"{name}: {path.stat().st_size // 1024}KB") + + +if __name__ == "__main__": + main() diff --git a/scripts/make-og.py b/scripts/make-og.py index 9098b92..afec742 100644 --- a/scripts/make-og.py +++ b/scripts/make-og.py @@ -10,6 +10,7 @@ from pathlib import Path +from mark import draw_mark from PIL import Image, ImageDraw, ImageFont W, H = 1200, 630 @@ -28,25 +29,6 @@ def font(path: str, size: int, index: int = 0) -> ImageFont.FreeTypeFont: return ImageFont.truetype(path, size, index=index) -def draw_mark(d: ImageDraw.ImageDraw, x: int, y: int, size: int, fill: tuple) -> None: - """The project mark: two chevrons facing inward with a dot between them. - - Same geometry as public/logo.svg, scaled from its 460 unit box. - """ - k = size / 460 - - def at(pts): - return [(x + px * k, y + py * k) for px, py in pts] - - d.polygon(at([(30, 57), (85, 57), (172, 229.5), (85, 402), (30, 402), (115, 229.5)]), fill=fill) - d.polygon( - at([(430, 57), (375, 57), (288, 229.5), (375, 402), (430, 402), (345, 229.5)]), fill=fill - ) - r = 30.5 * k - cx, cy = x + 229.5 * k, y + 229.5 * k - d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=fill) - - def main() -> None: img = Image.new("RGB", (W, H), BG) d = ImageDraw.Draw(img) diff --git a/scripts/mark.py b/scripts/mark.py new file mode 100644 index 0000000..b9e9c3a --- /dev/null +++ b/scripts/mark.py @@ -0,0 +1,24 @@ +"""The project mark, drawn once so both the card and the icons use it. + +Lifted out of make-og.py unchanged. It is the same geometry as public/logo.svg, +scaled from that file's 460 unit box, so the drawn images and the vector stay +the same shape when one of them is edited. +""" + +from PIL import ImageDraw + + +def draw_mark(d: ImageDraw.ImageDraw, x: int, y: int, size: int, fill: tuple) -> None: + """Two chevrons facing inward with a dot between them.""" + k = size / 460 + + def at(pts): + return [(x + px * k, y + py * k) for px, py in pts] + + d.polygon(at([(30, 57), (85, 57), (172, 229.5), (85, 402), (30, 402), (115, 229.5)]), fill=fill) + d.polygon( + at([(430, 57), (375, 57), (288, 229.5), (375, 402), (430, 402), (345, 229.5)]), fill=fill + ) + r = 30.5 * k + cx, cy = x + 229.5 * k, y + 229.5 * k + d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=fill) diff --git a/src/data/schema.ts b/src/data/schema.ts new file mode 100644 index 0000000..d7e9149 --- /dev/null +++ b/src/data/schema.ts @@ -0,0 +1,118 @@ +/** + * Structured data, as one graph per page. + * + * A search engine reads this for rich results. An answer engine reads it for + * something more useful: unambiguous facts it can quote without inferring them + * from prose, which is what the pages otherwise leave it to do. The name, the + * licence, the repository, the registry and the date of the measured run are + * all things a model would otherwise have to guess at from a sentence. + * + * One `@graph` rather than several script tags, so each node is declared once + * and referenced by `@id` from the others. Two nodes describing the same thing + * under different identifiers is the usual way this goes wrong. + * + * None of this needs the CSP loosened. A `ld+json` block is a data block that + * never executes, so `script-src 'none'` has nothing to act on, which was + * checked against the deployed header rather than assumed. + */ + +import { CURRENT_VERSION, NPM, REPO, RUN } from './benchmark'; + +const SITE = 'https://agentchaperone.dev'; +const ORG = `${SITE}/#organization`; +const SITE_ID = `${SITE}/#website`; +const APP = `${SITE}/#software`; +const SOURCE = `${SITE}/#source`; + +const ORGANIZATION = { + '@type': 'Organization', + '@id': ORG, + name: 'agent-chaperone', + url: SITE, + logo: `${SITE}/logo.svg`, + // Where the same thing exists elsewhere. This is what lets an engine treat + // the repository, the package and this site as one project rather than three. + sameAs: [REPO, NPM], +}; + +const WEBSITE = { + '@type': 'WebSite', + '@id': SITE_ID, + url: SITE, + name: 'agent-chaperone', + publisher: { '@id': ORG }, + inLanguage: 'en', +}; + +const SOURCE_CODE = { + '@type': 'SoftwareSourceCode', + '@id': SOURCE, + name: 'agent-chaperone', + codeRepository: REPO, + programmingLanguage: 'TypeScript', + license: 'https://www.apache.org/licenses/LICENSE-2.0', + targetProduct: { '@id': APP }, +}; + +const APPLICATION = { + '@type': 'SoftwareApplication', + '@id': APP, + name: 'agent-chaperone', + applicationCategory: 'DeveloperApplication', + applicationSubCategory: 'Security', + operatingSystem: 'macOS, Linux, Windows', + softwareVersion: CURRENT_VERSION, + description: + "agent-chaperone screens an AI agent's tool calls before they run, and the tool results those calls return before the agent reads them. It covers MCP servers through a proxy and a client's own shell, file edits and web fetches through a hooks adapter.", + url: SITE, + downloadUrl: NPM, + softwareHelp: REPO, + license: 'https://www.apache.org/licenses/LICENSE-2.0', + author: { '@id': ORG }, + publisher: { '@id': ORG }, + // Free and open source, said in the way a parser reads rather than in prose. + offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, + isAccessibleForFree: true, +}; + +export function homeSchema(): unknown { + return { + '@context': 'https://schema.org', + '@graph': [ORGANIZATION, WEBSITE, APPLICATION, SOURCE_CODE], + }; +} + +/** + * The results page describes a measurement, so it says so. + * + * `Dataset` rather than `Article`: the page is a table of counts from one run, + * and the properties that matter are what was measured, when, and against + * which version. The date is the run's, never the current release: the two are + * deliberately separate values and conflating them would have the page claiming + * numbers from a run nobody made. + */ +export function resultsSchema(): unknown { + return { + '@context': 'https://schema.org', + '@graph': [ + ORGANIZATION, + WEBSITE, + { + '@type': 'Dataset', + '@id': `${SITE}/results#dataset`, + name: `agent-chaperone screening results, ${RUN.model}`, + description: `One run of ${RUN.model} against agent-chaperone ${RUN.toolVersion} on ${RUN.date}: ${RUN.requests.toLocaleString('en-US')} screening requests against public prompt-injection datasets and hand-labeled tool calls, with what was caught, what was missed, and what was flagged in error.`, + url: `${SITE}/results`, + license: 'https://www.apache.org/licenses/LICENSE-2.0', + creator: { '@id': ORG }, + dateCreated: RUN.date, + datePublished: RUN.date, + isAccessibleForFree: true, + measurementTechnique: 'Calibrated probability screening of tool calls and tool results', + variableMeasured: ['precision', 'recall', 'false positives', 'false negatives'], + about: { '@id': APP }, + }, + APPLICATION, + ], + }; +} diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index b4fd5e3..d9b292f 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -5,10 +5,19 @@ interface Props { readonly title: string; readonly description: string; readonly canonical: string; + /** When the tab title has to carry the category and the card should not repeat it. */ + readonly ogTitle?: string; + /** JSON-LD for this page, as one graph. Never a second script tag. */ + readonly schema?: unknown; + /** Serialised, because `noindex` is right for exactly one page here. */ + readonly robots?: string; } -const { title, description, canonical } = Astro.props; +const { title, description, canonical, ogTitle, schema, robots } = Astro.props; const year = new Date().getFullYear(); +// Every page is prose that changes when the tool does, so the date a reader +// should weigh is the date of the release it describes, not the build. +const updated = RUN.date; --- @@ -20,13 +29,34 @@ const year = new Date().getFullYear(); - + + + + + + + + + {/* A raster icon as well as the vector one: Google Search ignores an SVG-only + favicon, and so does Safari before 26. */} + + + + {schema === undefined ? null : ( + + )}