From bb84e02ecd4cb128d58c05bbb83e779e7431ea6a Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Mon, 28 Sep 2026 19:19:56 -0700 Subject: [PATCH 01/55] Add design spec for Next.js site migration Co-Authored-By: Claude Opus 5.5 (1M context) --- ...2026-09-28-nextjs-site-migration-design.md | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-28-nextjs-site-migration-design.md diff --git a/docs/superpowers/specs/2026-09-28-nextjs-site-migration-design.md b/docs/superpowers/specs/2026-09-28-nextjs-site-migration-design.md new file mode 100644 index 0000000..f49ff3c --- /dev/null +++ b/docs/superpowers/specs/2026-09-28-nextjs-site-migration-design.md @@ -0,0 +1,237 @@ +# Migrate the landing page and docs site to Next.js + +Date: 2026-09-28 +Status: approved design, pending implementation plan + +## Goal + +Serve the marketing landing page and the MDX docs as one Next.js app from a +single top-level `site/` directory, deployed on Vercel. Docs move from hash +routes (`/docs/#/`) to real paths (`/docs/`). Both pages share one +root layout, which is where analytics and other site-wide concerns live. + +## Context + +Today the site is a Vite build in `showcase/docs/app` with two HTML entries: +`/` (landing, `landing/main.tsx`) and `/docs/` (the MDX app, a `#/` hash +router over `import.meta.glob("../guides/*.mdx")`). Nothing builds, checks, or +deploys it in CI, and it has no hosting. + +Facts the design depends on: + +- 129 guides in one flat directory. Metadata is an MDX ESM export, + `export const meta = { title, section, group, order, description }`, not YAML + frontmatter. `pkg/gen/mdxutil.Frontmatter()` emits the same form for the + generated CLI and CRD reference pages. +- About 292 `#/slug` links across 58 guides, 18 `/docs/#/slug` links on the + landing page, and two Go generators that emit `#/` links + (`pkg/gen/clidocs/clidocs.go:84`, `pkg/gen/crddocs/crddocs.go:138`) with test + assertions on that format. +- Guides import nothing; components (`Clip`, `Screenshot`, `ScreenshotSeries`, + `Callout`, `Coverage`, an `a` override) arrive through the MDX provider. +- `showcase/` also holds the demo-video system (slacksim, consolesim, engine), + which shares no code with the docs app. + +## Decisions + +| Question | Decision | Why | +| --- | --- | --- | +| Host | Vercel | Existing resources; easy deploys. | +| Location | New top-level `site/`, its own `package.json` and pnpm lockfile | Keeps Next.js dependencies apart from the Vite sims in `showcase/`. | +| Framework | Plain Next.js 16 App Router, no docs framework | Fumadocs, Nextra, Docusaurus, Starlight, and hosted options each cost a frontmatter conversion or a theme fight. omnigent.ai runs this same shape on Vercel. | +| MDX pipeline | `@next/mdx` (bundler-compiled) | Reads `export const meta` natively, hot-reloads, and is the Next 16 recommendation for local content. `authzed/web` compiles at runtime with `next-mdx-remote`, which HashiCorp archived and which cannot read ESM exports. | +| Search | Pagefind over the prerendered HTML | Static, no service; indexes rendered text, not MDX source. | +| Old `#/` links | Rewrite everywhere; no redirect | Nothing is deployed, so no external link depends on them. | +| Theme | `next-themes` | The Next.js-native toggle. Needs `suppressHydrationWarning` on `` only; every alternative that keeps a toggle and static pages needs the same. | +| Analytics | `posthog-js`, cookieless for every visitor, via `https://i.authzed.com` | Public repo: collect usage counts only, no identity or lead data, and say so. | + +A throwaway spike (Next 16.3.6, Turbopack, three sample guides) confirmed: +dynamic `import(\`@/content/docs/${slug}.mdx\`)` returns `{ default, meta }`; +the docs layout can build its nav from every guide's `meta`; +`generateStaticParams` plus `dynamicParams = false` prerenders every slug; +`remark-gfm` works as a string plugin; server and `'use client'` components +both render through `mdx-components.tsx`; and +`pagefind --site .next/server/app` indexes only `data-pagefind-body` pages. +Pagefind reports URLs with a `.html` suffix, and takes the first heading as the +result title unless told otherwise. + +## Architecture + +``` +site/ + package.json build: pnpm check && next build && pagefind --site .next/server/app --output-path public/_pagefind + next.config.mjs withMDX({ options: { remarkPlugins: ['remark-gfm'] } }); turbopack.root = repo root + mdx-components.tsx Clip, Screenshot, ScreenshotSeries, Callout, Coverage, `a` override + app/ + layout.tsx ; ThemeProvider; metadata.icons; + (landing)/page.tsx the landing page, plus landing.css + docs/layout.tsx sidebar from lib/guides index();
; docs.css + docs/page.tsx redirects to the lowest-order guide + docs/[slug]/page.tsx loads the guide; generateStaticParams; dynamicParams = false; generateMetadata from meta + lib/guides.ts server-only: slugs(), load(slug), index() returning sorted sections and groups + components/ ported components; 'use client' on media, ThemeToggle, Search, NavLink, Analytics + content/docs/*.mdx the 129 guides and 3 .refs.yaml sidecars + content/_manifest.json + public/media/ + scripts/check.ts + AGENTS.md authoring rules (moved from showcase/AGENTS.md) + README.md dev and build commands; the analytics note +``` + +### Routing + +- Each guide is a static route at `/docs/`. An unknown slug returns 404; + today it silently renders the first guide. +- `/docs` redirects to the lowest-order guide, matching today's default. +- Anchors (`/docs/owasp-top10#asi03`) use native fragment scrolling. +- Links within the docs go through `next/link`. Links between the landing page + and the docs stay plain ``. + +### Styles + +Both global sheets stay. Their classes are already disjoint (`lp-*` and +`doc-*`); only their `html` and `body` rules overlap, so those move onto each +section's wrapper element. + +### Theme + +- `` in + the root layout. `suppressHydrationWarning` sits on `` and nowhere else. +- The existing `data-theme` CSS tokens stay unchanged. +- Delete `theme.ts`, both inline pre-paint scripts, and the manual `storage` + sync. +- `ThemeToggle` wraps `useTheme()` and renders its active state only after + mount. +- The wordmark swap stays pure CSS on `data-theme`. +- Favicons move to `metadata.icons` with `media: '(prefers-color-scheme: …)'`, + replacing `installFavicons()`. +- Brand SVGs stay in `docs/assets/brand` and are imported across the site root + by setting `turbopack.root`. If that misbehaves on Vercel (lockfile detection, + file tracing), fall back to a prebuild copy into `site/public/brand/`. The + first implementation task settles this. + +### Analytics + +`components/Analytics.tsx` (`'use client'`), rendered once in the root layout. + +- Initializes only when `NEXT_PUBLIC_POSTHOG_KEY` is set and + `NEXT_PUBLIC_VERCEL_ENV === 'production'`. Forks, previews, and local dev send + nothing. +- `api_host: 'https://i.authzed.com'`. No Next.js rewrites. +- `cookieless_mode: 'always'` for every visitor: no cookies, no storage, no + identity. No EU detection is needed. +- Autocapture, session replay, heatmaps, surveys, and feature flags off. + Captures `$pageview` (including client-side navigation) and `$pageleave`. + `respect_dnt: true`. +- `site/README.md` states what is collected and where the config lives. + +### Search + +`components/Search.tsx` (`'use client'`) in the docs sidebar. + +- Loads `/_pagefind/pagefind.js` on first focus through a dynamic import the + bundler ignores. Turbopack would otherwise try to resolve it; verify the + ignore comment works. +- Strips `.html` from result URLs. +- The guide title element carries `data-pagefind-meta="title"`. +- Only `
` is indexed, so the landing page and the + nav stay out. +- With no index (under `next dev`), shows "Search is available after + `pnpm build`" instead of failing silently. + +### Components + +- `mdx-components.tsx` ports the existing `mdxComponents` map. +- `media.tsx` (the lightbox) and `ThemeToggle` are client components; `Callout` + and `Coverage` stay server components. +- The `a` override sends `/docs/…` through `next/link` and keeps + `target="_blank"` for `http…`. +- The sidebar renders on the server in `docs/layout.tsx` and persists across + guide navigation. A small `NavLink` client component reads `usePathname()` to + mark the current guide. + +## Migration + +### Moves (`git mv`, preserving history) + +| From | To | +| --- | --- | +| `showcase/docs/guides/*` | `site/content/docs/` | +| `showcase/docs/_manifest.json` | `site/content/_manifest.json` | +| `showcase/docs/public/media/` | `site/public/media/` | +| `showcase/docs/check.ts` | `site/scripts/check.ts`, path math fixed for the new depth | + +Then port the landing and docs components into `site/`, and delete +`showcase/docs/`, `showcase/vite.docs.config.ts`, the `docs:*` scripts, and any +dependencies only the docs used. Trim `showcase/tsconfig.json`'s `include`. + +### Links + +- A one-off codemod (run, not committed) rewrites `](#/slug…)` and + `href="#/slug…"` in the guides, and `/docs/#/slug` on the landing page + (including the templated OWASP link), to `/docs/slug…`. +- Change the generator format strings to `/docs/oap-%s` and `/docs/crd-%s`, + and update their test assertions. +- Point `cliDocsDir` (`magefiles/clidocs.go:18`) at `site/content/docs`. +- Cross-check: after the codemod, `mage docs:cli docs:crd` must leave the + generated files unchanged in `git diff`. + +### Checks (`site/scripts/check.ts`) + +Keeps the three existing checks: manifest files exist; media names resolve with +the right kind; `.refs.yaml` claims are valid. Adds a link check: + +- every `/docs/` link in the guides and the landing page names a guide; +- every `#anchor` on such a link matches an `id=` in the target guide; +- no `#/` link remains. + +The build script runs the checks, so every Vercel build, including PR previews, +enforces them. No separate CI job. + +## Testing + +- **Unit (vitest):** `lib/guides.ts` index — section order by first + appearance, groups anchored at their lowest-order child, defaults for missing + `meta` fields, and sort ties. +- **Go:** the updated `clidocs` and `crddocs` tests, via `mage test:unit`. +- **Build:** all 129 routes prerender as static; an unknown slug returns 404; + `/docs` redirects. +- **Browser (Chrome DevTools on `next build && next start`):** + - landing and docs render in both themes; + - the toggle persists across pages with no hydration warnings in the console; + - search returns results that link to paths without `.html`; + - the lightbox opens and closes; + - with no key, PostHog makes no requests; + - with a test key, requests go to `i.authzed.com` and no cookies or storage + entries appear. +- **Ship gate:** `mage test:unit`, `mage test:integration`, and `mage test:e2e` + before merge, as the repo requires, since the Go generators change. + +## Documentation updates + +- `README.md:344-349` (dev server and port) +- Root `CLAUDE.md` and `AGENTS.md` sections on `mage docs:*` +- `pkg/gen/README.md`, `docs/assets/brand/README.md` +- `showcase/README.md` and `showcase/AGENTS.md`: remove the docs sections; + authoring rules move to `site/AGENTS.md` + +## Sequencing + +The landing-page work on the `landing-page` branch is uncommitted. Commit it +as-is first, so the migration diff reads as a move rather than mixing with +unfinished work. + +## Outside the repo (owner action) + +- Create the Vercel project: root directory `site`, "Include files outside the + root directory" on (the brand SVG imports need it). +- Set `NEXT_PUBLIC_POSTHOG_KEY` for production. +- Enable "Cookieless server hash mode" in the PostHog project. + +## Out of scope + +- Redirects from `/docs/#/…` URLs. +- `rehype-slug` auto heading ids (the hand-written `id=` anchors keep working). +- A per-page table of contents. +- Consent UI, identified users, or lead capture. +- Any change to the demo-video system in `showcase/`. From dbf478a7dfd1d4d6bd262aa6f3e51627db36d96d Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Mon, 28 Sep 2026 19:57:44 -0700 Subject: [PATCH 02/55] Add implementation plan for Next.js site migration Also update the spec's sequencing: the uncommitted Vite landing work is the port source and is deleted once site/ serves both pages. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- .../plans/2026-09-28-nextjs-site-migration.md | 1903 +++++++++++++++++ ...2026-09-28-nextjs-site-migration-design.md | 7 +- 2 files changed, 1907 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-28-nextjs-site-migration.md diff --git a/docs/superpowers/plans/2026-09-28-nextjs-site-migration.md b/docs/superpowers/plans/2026-09-28-nextjs-site-migration.md new file mode 100644 index 0000000..2d20060 --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-nextjs-site-migration.md @@ -0,0 +1,1903 @@ +# Next.js Site Migration 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:** Serve the landing page (`/`) and the MDX docs (`/docs/`) as one +Next.js 16 app in a new top-level `site/` directory, deployable on Vercel. + +**Architecture:** Plain Next.js App Router with `@next/mdx`. Guides stay MDX +files with an `export const meta = {...}` ESM export; a server-only loader reads +the directory and dynamically imports each guide. A shared root layout carries +`next-themes` and a cookieless PostHog client. Pagefind indexes the prerendered +HTML after `next build`. + +**Tech Stack:** Next.js 16.3.6 (Turbopack), React 19, `@next/mdx` 16.3.6, +`remark-gfm`, `next-themes` 0.4.x, `posthog-js` 1.434.x, Pagefind 1.5.x, +vitest, tsx, pnpm. + +**Spec:** `docs/superpowers/specs/2026-09-28-nextjs-site-migration-design.md` + +## Global Constraints + +- All site code lives in `site/`, with its own `package.json` and + `pnpm-lock.yaml`. It joins no workspace. +- Docs URLs are `/docs/` and `/docs/#`. When this plan is + finished, no `#/` link remains anywhere. +- Guide metadata stays `export const meta = { title, section, group, order, description }`. + Do not introduce YAML frontmatter. +- MDX plugins are given to `@next/mdx` as strings (`'remark-gfm'`); Turbopack + cannot take functions. +- `suppressHydrationWarning` appears on `` and nowhere else. +- PostHog: `api_host: 'https://i.authzed.com'`, `cookieless_mode: 'always'` for + every visitor, `person_profiles: 'never'`; initialize only when + `NEXT_PUBLIC_POSTHOG_KEY` is set and `NEXT_PUBLIC_VERCEL_ENV === 'production'`. + No Next.js rewrites. +- Build script: `pnpm check && next build && pagefind --site .next/server/app --output-path public/_pagefind`. +- Brand files stay single-sourced in `docs/assets/brand/`. +- Tests: vitest with `describe`/`it` and `expect`; table-driven (`it.each`) + when four or more cases share shape. +- Never push; commit on the current branch (`landing-page`) after each task. +- The uncommitted landing-page work in `showcase/docs/app` is the port source. + Do not commit it on its own; Task 9 deletes it. + +## Review Focus + +1. **A `#/` or `/docs/` string inside a code fence or inline code** must be + left alone by the link checker (the guides document CLI output and URLs). + Test: Task 5, `extractDocLinks` ignores fenced and inline code. +2. **Same-page anchors, `mailto:`, and relative hrefs in MDX** must render as + plain `` without `target="_blank"`, and `/docs` links must go through + `next/link`. Test: Task 4, `linkKind` table. +3. **The landing page's templated OWASP links** + (`` `/docs/owasp-top10#${id.toLowerCase()}` ``) cannot be checked + statically. Every id must match an `id=` in `owasp-top10.mdx`. Test: Task 6, + `owasp.test.ts`. +4. **Search when the Pagefind bundle is missing** (every `next dev` session) + must show a message, not throw. Test: Task 7, `loadPagefind` returns + `null` when the import rejects, and `toPath` strips `.html`. +5. **Analytics gating across environments**: no key, a preview deploy, and + local dev must all send nothing. Only production with a key initializes. + Test: Task 8, `posthogOptions` table. + +--- + +## File Structure + +``` +site/ + package.json scripts: dev, build, start, check, test, typecheck + pnpm-workspace.yaml allowBuilds for native deps, if pnpm asks + next.config.mjs withMDX + turbopack.root / outputFileTracingRoot = repo root + tsconfig.json @/* -> ./* + vitest.config.ts + mdx.d.ts types `*.mdx` default + meta + mdx-components.tsx global MDX component map + .gitignore .next, next-env.d.ts, public/_pagefind, node_modules + README.md dev/build commands; analytics disclosure + AGENTS.md authoring rules (moved from showcase/AGENTS.md) + app/ + layout.tsx root: ThemeProvider, metadata.icons, viewport.themeColor, + (landing)/page.tsx renders + (landing)/Landing.tsx ported + (landing)/landing.css ported + (landing)/owasp.ts OWASP rows, extracted so a test can check their anchors + (landing)/owasp.test.ts + (landing)/OapMark.tsx ported + docs/layout.tsx sidebar + article shell + docs/page.tsx redirect to lowest-order guide + docs/[slug]/page.tsx one static route per guide + docs/docs.css ported styles.css + components/ + Analytics.tsx 'use client'; posthog.init + ThemeToggle.tsx 'use client'; next-themes + theme-toggle.css + Wordmark.tsx two , CSS picks by data-theme + NavLink.tsx 'use client'; active state from usePathname + Search.tsx 'use client'; Pagefind UI + media.tsx 'use client'; Clip/Screenshot/ScreenshotSeries + lightbox + Callout.tsx + Coverage.tsx + lib/ + nav.ts pure: normalizeGuide, sortGuides, buildNav + nav.test.ts + guides.ts server-only: guideSlugs, loadGuide, allGuides + links.ts pure: linkKind, extractDocLinks, anchorIds, checkLinks + links.test.ts + manifest.ts getAsset over content/_manifest.json + analytics.ts pure: posthogOptions(env) + analytics.test.ts + pagefind.ts loadPagefind, toPath + pagefind.test.ts + content/ + docs/*.mdx, *.refs.yaml + _manifest.json + public/media/ + scripts/check.ts media, manifest, refs, and link checks +``` + +--- + +### Task 1: Scaffold `site/` and settle the brand-asset import + +Settles the spec's one open risk first: importing SVGs from +`docs/assets/brand` across the site root under Turbopack. + +**Files:** + +- Create: `site/package.json`, `site/next.config.mjs`, `site/tsconfig.json`, + `site/vitest.config.ts`, `site/mdx.d.ts`, `site/mdx-components.tsx`, + `site/.gitignore`, `site/app/layout.tsx`, `site/app/(landing)/page.tsx`, + `site/lib/smoke.test.ts` (deleted in Task 2) + +**Interfaces:** + +- Produces: `@/*` path alias rooted at `site/`; `pnpm build`, `pnpm test`, + `pnpm typecheck` scripts; `useMDXComponents()` from `site/mdx-components.tsx`. + +- [ ] **Step 1: Create `site/package.json`** + +```json +{ + "name": "@oap/site", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --port 5179", + "build": "next build && pagefind --site .next/server/app --output-path public/_pagefind", + "start": "next start --port 5179", + "test": "vitest run", + "typecheck": "tsc --noEmit" + } +} +``` + +Task 3 adds `check` and prepends `pnpm check &&` to `build`. + +- [ ] **Step 2: Install dependencies** + +Run from `site/`: + +```bash +pnpm add next@16.3.6 @next/mdx@16.3.6 @mdx-js/loader @mdx-js/react react react-dom remark-gfm server-only next-themes posthog-js +pnpm add -D typescript @types/react @types/react-dom @types/node @types/mdx vitest pagefind tsx yaml +``` + +Expected: exit 0 and a `site/pnpm-lock.yaml`. If pnpm prints "Ignored build +scripts" for `sharp` or `esbuild`, create `site/pnpm-workspace.yaml` with +`allowBuilds: { sharp: true, esbuild: true }` (the same key +`showcase/pnpm-workspace.yaml` uses) and re-run `pnpm install`. + +- [ ] **Step 3: Create `site/next.config.mjs`** + +```js +import createMDX from "@next/mdx"; +import { fileURLToPath } from "node:url"; + +// The brand marks live once, in the repo's docs/assets/brand, outside this +// package. Widening Turbopack's root (and file tracing with it) lets the site +// import them directly instead of keeping a copy that drifts. +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); + +// Plugins are named by string: Turbopack runs the MDX compile in Rust and +// cannot receive JavaScript functions. +const withMDX = createMDX({ + options: { remarkPlugins: ["remark-gfm"] }, +}); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + pageExtensions: ["ts", "tsx", "mdx"], + turbopack: { root: repoRoot }, + outputFileTracingRoot: repoRoot, +}; + +export default withMDX(nextConfig); +``` + +- [ ] **Step 4: Create `site/tsconfig.json`, `site/mdx.d.ts`, `site/.gitignore`** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "resolveJsonModule": true, + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} +``` + +```ts +// site/mdx.d.ts +declare module "*.mdx" { + import type { ComponentType } from "react"; + export const meta: + | { title: string; section?: string; group?: string; order?: number; description?: string } + | undefined; + const MDXComponent: ComponentType; + export default MDXComponent; +} +``` + +The type is inlined because `next build` typechecks and `@/lib/nav` doesn't exist yet. Task 2 replaces it with an import of `GuideMeta`. + +``` +# site/.gitignore +node_modules/ +.next/ +next-env.d.ts +public/_pagefind/ +``` + +- [ ] **Step 5: Create `site/vitest.config.ts` and a smoke test** + +```ts +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + resolve: { alias: { "@": fileURLToPath(new URL(".", import.meta.url)) } }, + test: { environment: "node", include: ["**/*.test.ts"], exclude: ["node_modules/**", ".next/**"] }, +}); +``` + +```ts +// site/lib/smoke.test.ts +import { describe, expect, it } from "vitest"; +describe("vitest wiring", () => { + it("runs", () => expect(1 + 1).toBe(2)); +}); +``` + +Run: `pnpm test` → Expected: 1 passed. + +- [ ] **Step 6: Create a minimal layout, an MDX component map, and a page that imports a brand SVG** + +```tsx +// site/mdx-components.tsx +import type { MDXComponents } from "mdx/types"; + +const components: MDXComponents = {}; + +export function useMDXComponents(): MDXComponents { + return components; +} +``` + +```tsx +// site/app/layout.tsx +import type { ReactNode } from "react"; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} +``` + +```tsx +// site/app/(landing)/page.tsx (placeholder; Task 6 replaces it) +import wordmark from "../../../docs/assets/brand/oap-wordmark-light.svg"; + +export default function Page() { + return Open Agent Primitives; +} +``` + +- [ ] **Step 7: Build and confirm the cross-root import** + +Run: `pnpm build` (from `site/`) +Expected: `▲ Next.js 16.3.6 (Turbopack)`, route `/` listed as static, Pagefind finds no `data-pagefind-body` yet, so it indexes every HTML file, and its page count here doesn't matter. Then: + +```bash +grep -o '/_next/static/media/oap-wordmark-light[^"]*\.svg' .next/server/app/index.html +``` + +Expected: one match. + +**If the build fails on the import** ("outside of the project root", a +lockfile warning that selects the wrong root, or a tracing error), switch to +the fallback. Add `"prebrand": "node scripts/copy-brand.mjs"` and run it as +the first command in both `dev` and `build`. The script copies +`../docs/assets/brand/*.svg` into `public/brand/`. Add `public/brand/` to +`site/.gitignore`, reference `/brand/.svg` by URL, remove `turbopack.root` +and `outputFileTracingRoot`, and note the switch in `site/README.md`. Every +later task that says "import from `docs/assets/brand`" then means +"`/brand/`". + +- [ ] **Step 8: Commit** + +```bash +git add site +git commit -m "Scaffold the Next.js site package" +``` + +--- + +### Task 2: Guide index and nav model + +Ports the sort and nav-block logic from `showcase/docs/app/App.tsx:44-91` into +pure, tested functions. + +**Files:** + +- Create: `site/lib/nav.ts`, `site/lib/nav.test.ts`, `site/lib/guides.ts` +- Delete: `site/lib/smoke.test.ts` + +**Interfaces:** + +- Produces (`@/lib/nav`, safe to import anywhere): + - `interface GuideMeta { title: string; section?: string; group?: string; order?: number; description?: string }` + - `interface Guide { slug: string; title: string; section: string; group: string; order: number; description?: string }` + - `normalizeGuide(slug: string, meta: GuideMeta | undefined): Guide` + - `sortGuides(guides: Guide[]): Guide[]` (new array: `order` ascending, then `title` via `localeCompare`) + - `interface NavBlock { group: string; guides: Guide[] }` + - `interface NavSection { section: string; blocks: NavBlock[] }` + - `buildNav(sorted: Guide[]): NavSection[]` +- Produces (`@/lib/guides`, server only): + - `guideSlugs(): Promise` + - `loadGuide(slug: string): Promise<{ Component: ComponentType; guide: Guide }>` + - `allGuides(): Promise` (sorted) + +- [ ] **Step 1: Write failing tests** + +```ts +// site/lib/nav.test.ts +import { describe, expect, it } from "vitest"; +import { buildNav, normalizeGuide, sortGuides, type Guide } from "./nav"; + +const g = (slug: string, order: number, section = "Guides", group = "", title = slug): Guide => ({ + slug, title, section, group, order, +}); + +describe("normalizeGuide", () => { + it("missing meta: title=slug, section=Guides, group='', order=100", () => { + expect(normalizeGuide("x", undefined)).toEqual({ + slug: "x", title: "x", section: "Guides", group: "", order: 100, description: undefined, + }); + }); + it("partial meta: fills only the absent fields", () => { + expect(normalizeGuide("x", { title: "X", order: 5 })).toMatchObject({ + title: "X", section: "Guides", group: "", order: 5, + }); + }); +}); + +describe("sortGuides", () => { + it("orders by `order`, then title on ties, without mutating input", () => { + const input = [g("b", 2), g("zeta", 1), g("alpha", 1)]; + expect(sortGuides(input).map((x) => x.slug)).toEqual(["alpha", "zeta", "b"]); + expect(input.map((x) => x.slug)).toEqual(["b", "zeta", "alpha"]); + }); +}); + +describe("buildNav", () => { + it("sections appear in first-seen order of the sorted list", () => { + const nav = buildNav([g("a", 1, "Guides"), g("b", 2, "Reference"), g("c", 3, "Guides")]); + expect(nav.map((s) => s.section)).toEqual(["Guides", "Reference"]); + }); + it("an ungrouped guide is its own block at its own position; a group anchors at its lowest-order child", () => { + const nav = buildNav([ + g("intro", 1), + g("c1", 2, "Guides", "Concepts"), + g("mid", 3), + g("c2", 4, "Guides", "Concepts"), + ]); + expect(nav[0].blocks.map((b) => [b.group, b.guides.map((x) => x.slug)])).toEqual([ + ["", ["intro"]], + ["Concepts", ["c1", "c2"]], + ["", ["mid"]], + ]); + }); +}); +``` + +Run: `pnpm test lib/nav.test.ts` → Expected: FAIL, cannot resolve `./nav`. + +- [ ] **Step 2: Implement `site/lib/nav.ts`** + +```ts +// The docs sidebar model. Pure, so it is tested without a filesystem or a +// bundler; lib/guides.ts feeds it. +export interface GuideMeta { + title: string; + /** Top-level nav category (e.g. "Concepts"). */ + section?: string; + /** Sub-heading within the section. */ + group?: string; + order?: number; + description?: string; +} + +export interface Guide { + slug: string; + title: string; + section: string; + group: string; + order: number; + description?: string; +} + +export function normalizeGuide(slug: string, meta: GuideMeta | undefined): Guide { + return { + slug, + title: meta?.title ?? slug, + section: meta?.section ?? "Guides", + group: meta?.group ?? "", + order: meta?.order ?? 100, + description: meta?.description, + }; +} + +export function sortGuides(guides: Guide[]): Guide[] { + return [...guides].sort((a, b) => a.order - b.order || a.title.localeCompare(b.title)); +} + +// A nav block is one rendered unit: a single ungrouped guide, or a titled group. +// Blocks follow `order`, so an ungrouped guide sits at its own position instead +// of being hoisted above every group; a group is anchored at its lowest-order +// child. +export interface NavBlock { + group: string; + guides: Guide[]; +} + +export interface NavSection { + section: string; + blocks: NavBlock[]; +} + +export function buildNav(sorted: Guide[]): NavSection[] { + const sections = new Map }>(); + for (const guide of sorted) { + let s = sections.get(guide.section); + if (!s) { + s = { blocks: [], byGroup: new Map() }; + sections.set(guide.section, s); + } + if (guide.group === "") { + s.blocks.push({ group: "", guides: [guide] }); + continue; + } + let block = s.byGroup.get(guide.group); + if (!block) { + block = { group: guide.group, guides: [] }; + s.byGroup.set(guide.group, block); + s.blocks.push(block); + } + block.guides.push(guide); + } + return [...sections].map(([section, { blocks }]) => ({ section, blocks })); +} +``` + +Run: `pnpm test` → Expected: all nav tests PASS. Delete `site/lib/smoke.test.ts`. + +- [ ] **Step 3: Implement `site/lib/guides.ts`** + +```ts +import "server-only"; +import { readdir } from "node:fs/promises"; +import path from "node:path"; +import { cache, type ComponentType } from "react"; +import { normalizeGuide, sortGuides, type Guide, type GuideMeta } from "./nav"; + +// Guides are the .mdx files in content/docs; the filename is the slug. Each +// module exports a default component and `meta`, which the Go generators +// (pkg/gen/mdxutil) emit in the same shape. +const GUIDES_DIR = path.join(process.cwd(), "content/docs"); + +interface GuideModule { + default: ComponentType; + meta?: GuideMeta; +} + +export const guideSlugs = cache(async (): Promise => + (await readdir(GUIDES_DIR)).filter((f) => f.endsWith(".mdx")).map((f) => f.slice(0, -".mdx".length)), +); + +export async function loadGuide(slug: string): Promise<{ Component: ComponentType; guide: Guide }> { + const mod = (await import(`@/content/docs/${slug}.mdx`)) as GuideModule; + return { Component: mod.default, guide: normalizeGuide(slug, mod.meta) }; +} + +export const allGuides = cache(async (): Promise => { + const slugs = await guideSlugs(); + const guides = await Promise.all(slugs.map(async (slug) => (await loadGuide(slug)).guide)); + return sortGuides(guides); +}); +``` + +Replace the inline `meta` type in `site/mdx.d.ts` with `import type { GuideMeta } from "@/lib/nav";` and `export const meta: GuideMeta | undefined;`. + +Run: `pnpm typecheck` → Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add site +git commit -m "Add the docs guide index and nav model" +``` + +--- + +### Task 3: Move content, the checker, and the generator output dir + +**Files:** + +- Move: `showcase/docs/guides/*` → `site/content/docs/`; + `showcase/docs/_manifest.json` → `site/content/_manifest.json`; + `showcase/docs/public/media` → `site/public/media`; + `showcase/docs/check.ts` → `site/scripts/check.ts` +- Modify: `site/scripts/check.ts:15-20`, `site/package.json`, + `magefiles/clidocs.go:16-18` + +**Interfaces:** + +- Produces: `pnpm check` (in `site/`); `cliDocsDir = "site/content/docs"`. + +- [ ] **Step 1: Move with history** + +```bash +mkdir -p site/content/docs site/public site/scripts +git mv showcase/docs/guides/* site/content/docs/ +git mv showcase/docs/_manifest.json site/content/_manifest.json +git mv showcase/docs/public/media site/public/media +git mv showcase/docs/check.ts site/scripts/check.ts +``` + +Expected: `ls site/content/docs/*.mdx | wc -l` → `129`; +`ls site/content/docs/*.refs.yaml | wc -l` → `3`. + +- [ ] **Step 2: Fix the checker's paths** + +Replace lines 15-20 of `site/scripts/check.ts`: + +```ts +const siteDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const repoRoot = path.dirname(siteDir); +const guidesDir = path.join(siteDir, "content/docs"); +const publicDir = path.join(siteDir, "public"); +const manifestPath = path.join(siteDir, "content/_manifest.json"); +``` + +Change the header comment's first line to +`// Static docs validator. Runs before \`next build\` (via \`pnpm check\`).` +Replace every `docs:check` string in messages with `check`, and change +`docs/public` in the header comment to `public/`. + +- [ ] **Step 3: Wire the script** + +In `site/package.json`, add `"check": "tsx scripts/check.ts"`, and change +`build` to +`"pnpm check && next build && pagefind --site .next/server/app --output-path public/_pagefind"`. + +Run: `pnpm check` (in `site/`) +Expected: `check OK — 129 guide(s), 3 refs sidecar(s), 59 media entr(ies).` + +- [ ] **Step 4: Point the generators at the new directory** + +In `magefiles/clidocs.go`: + +```go +// cliDocsDir is where the generated reference MDX lands, alongside the +// hand-written guides the site renders. +const cliDocsDir = "site/content/docs" +``` + +Run: `mage docs:crd && git status --short site/content/docs` +Expected: no output. The generator rewrote the files it owns byte for byte, +because only their location changed. + +- [ ] **Step 5: Commit** + +Commit only the moves and the edited files. The rest of `showcase/docs/app` +stays uncommitted as the port source. + +```bash +git add site magefiles/clidocs.go showcase/docs/guides showcase/docs/_manifest.json showcase/docs/public showcase/docs/check.ts +git commit -m "Move docs content and checker into site/" +``` + +--- + +### Task 4: Root layout, theme, and the docs routes + +**Files:** + +- Move: `showcase/docs/app/components/{media.tsx,Callout.tsx,Coverage.tsx,ThemeToggle.tsx,theme-toggle.css}` → `site/components/`; + `showcase/docs/app/manifest.ts` → `site/lib/manifest.ts`; + `showcase/docs/app/styles.css` → `site/app/docs/docs.css` +- Create: `site/components/Wordmark.tsx`, `site/components/NavLink.tsx`, + `site/lib/links.ts` (the `linkKind` part), `site/lib/links.test.ts`, + `site/app/docs/layout.tsx`, `site/app/docs/page.tsx`, + `site/app/docs/[slug]/page.tsx` +- Modify: `site/app/layout.tsx`, `site/mdx-components.tsx` + +These files are uncommitted port sources, so copy them with `cp` rather than +`git mv`. Task 9 deletes the originals. + +**Interfaces:** + +- Consumes: `allGuides`, `guideSlugs`, `loadGuide` (Task 2); `buildNav`, `Guide` (Task 2). +- Produces: + - `linkKind(href: string): "docs" | "external" | "plain"` in `@/lib/links` + - ``, `` + - `` + - root layout exports `metadata` and `viewport` + +- [ ] **Step 1: Write the failing `linkKind` test** + +```ts +// site/lib/links.test.ts +import { describe, expect, it } from "vitest"; +import { linkKind } from "./links"; + +describe("linkKind", () => { + it.each([ + ["/docs/safe-tools", "docs"], + ["/docs/owasp-top10#asi03", "docs"], + ["/docs", "docs"], + ["https://example.com", "external"], + ["http://example.com", "external"], + ["#asi03", "plain"], + ["mailto:a@b.c", "plain"], + ["/", "plain"], + ["relative/path", "plain"], + ["/docsx", "plain"], + ] as const)("%s → %s", (href, want) => { + expect(linkKind(href)).toBe(want); + }); +}); +``` + +Run: `pnpm test lib/links.test.ts` → Expected: FAIL (no `./links`). + +- [ ] **Step 2: Implement `linkKind`** + +```ts +// site/lib/links.ts +// How an MDX link renders: docs links navigate client-side, external links +// open a new tab, everything else (same-page anchors, mailto:, the landing +// page) is a plain anchor. +export type LinkKind = "docs" | "external" | "plain"; + +export function linkKind(href: string): LinkKind { + if (href === "/docs" || href.startsWith("/docs/") || href.startsWith("/docs#")) return "docs"; + if (/^https?:\/\//.test(href)) return "external"; + return "plain"; +} +``` + +Run: `pnpm test` → Expected: PASS. + +- [ ] **Step 3: Port the components** + +```bash +mkdir -p site/components site/app/docs +cp showcase/docs/app/components/{media.tsx,Callout.tsx,Coverage.tsx,ThemeToggle.tsx,theme-toggle.css} site/components/ +cp showcase/docs/app/manifest.ts site/lib/manifest.ts +cp showcase/docs/app/styles.css site/app/docs/docs.css +``` + +Then edit: + +- `site/lib/manifest.ts`: change the import to + `import manifest from "@/content/_manifest.json";` +- `site/components/media.tsx`: add `"use client";` as the first line, and + change `from "../manifest"` to `from "@/lib/manifest"`. + +- [ ] **Step 4: Rewrite `ThemeToggle` on `next-themes`** + +Replace `site/components/ThemeToggle.tsx`. The icons are unchanged from the original. `JSX` is imported from `react` because React 19 has no global `JSX` namespace. + +```tsx +"use client"; +import { useEffect, useState, type JSX } from "react"; +import { useTheme } from "next-themes"; +import "./theme-toggle.css"; + +type Mode = "light" | "dark" | "system"; + +// Native radios rather than buttons with aria-checked: a radio group gives +// arrow-key navigation, roving focus and the correct grouping semantics for +// free, and the three options are genuinely mutually exclusive. +const MODES: { id: Mode; label: string; icon: JSX.Element }[] = [ + { + id: "light", + label: "Light", + icon: ( + + ), + }, + { + id: "dark", + label: "Dark", + icon: ( + + ), + }, + { + id: "system", + label: "System", + icon: ( + + ), + }, +]; + +export function ThemeToggle({ className }: { className?: string }) { + const { theme, setTheme } = useTheme(); + // The server cannot know the stored choice, so no radio is checked until + // mount; rendering one would mismatch hydration. + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + return ( +
+ Color theme + {MODES.map((mode) => ( + + ))} +
+ ); +} +``` + + In `site/components/theme-toggle.css`, delete the +`:root[data-theme-switching]` rule and its comment (lines 6-14); +`disableTransitionOnChange` replaces it. Update the file's header comment to +say the palettes come from `.lp` and `.doc-app`. + +- [ ] **Step 5: Add `Wordmark`** + +```tsx +// site/components/Wordmark.tsx +// "light" and "dark" name the INK, not the background, so the light-ink file +// is the one for the dark theme. Both render; CSS on the data-theme attribute +// next-themes stamps before paint shows one, so the mark is right on the first +// frame without waiting for React. +import lightInk from "../../docs/assets/brand/oap-wordmark-light.svg"; +import darkInk from "../../docs/assets/brand/oap-wordmark-dark.svg"; + +export function Wordmark({ className = "" }: { className?: string }) { + return ( + <> + + + + ); +} +``` + +In `site/app/docs/docs.css`, rename `.doc-brand-wordmark--on-dark` and +`.doc-brand-wordmark--on-light` to `.wordmark--on-dark` and +`.wordmark--on-light`. + +- [ ] **Step 6: Root layout with `next-themes`, icons, and theme color** + +```tsx +// site/app/layout.tsx +import type { Metadata, Viewport } from "next"; +import type { ReactNode } from "react"; +import { ThemeProvider } from "next-themes"; +import iconDarkInk from "../../docs/assets/brand/oap-icon-dark.svg"; +import iconLightInk from "../../docs/assets/brand/oap-icon-light.svg"; + +export const metadata: Metadata = { + title: { default: "Open Agent Primitives", template: "%s · OAP docs" }, + description: + "OAP is a Kubernetes-native runtime for LLM agents. Every state-touching tool call is checked against a SpiceDB relationship graph before it runs, so the model is never asked whether it may act.", + // The favicon sits on the browser's tab strip, not on this page, so its ink + // follows the OS scheme rather than the site's toggle. + icons: { + icon: [ + { url: iconDarkInk.src, type: "image/svg+xml", media: "(prefers-color-scheme: light)" }, + { url: iconLightInk.src, type: "image/svg+xml", media: "(prefers-color-scheme: dark)" }, + ], + }, +}; + +export const viewport: Viewport = { + colorScheme: "dark light", + themeColor: [ + { media: "(prefers-color-scheme: dark)", color: "#0c050f" }, + { media: "(prefers-color-scheme: light)", color: "#fcfcfc" }, + ], +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + // next-themes sets data-theme on before hydration; this is the one + // element whose attributes are allowed to differ from the server render. + + + + {children} + + + + ); +} +``` + +The landing page's `og:title`, `og:description` and `og:type` come in Task 6. + +- [ ] **Step 7: Scope `docs.css`'s document-level rules to the docs** + +In `site/app/docs/docs.css`: + +- Replace the `html, body, #root { margin: 0; height: 100%; }` rule with + `html, body { margin: 0; }`. +- Change the `body { background… }` selector to `body:has(.doc-app)`. That + keeps the page background on the document (overscroll included) only when + the docs are mounted. +- Move the `--tt-*` declarations out of `:root` into a new `.doc-app { … }` + block, and the light-theme `--tt-*` (if any) into + `:root[data-theme='light'] .doc-app`. +- Add styles to `.doc-nav-link`, which is now an `
`, not a ` +
e.stopPropagation()} + > + {caption + {caption &&
{caption}
} +
+ + ); +} + +// An autoplay-muted-loop clip with a poster and click-to-expand. This is how a +// guide embeds a scenario clip captured by the engine. +export function Clip({ name, caption }: { name: string; caption?: string }) { + const asset = getAsset(name); + if (!asset || (!asset.webm && !asset.mp4)) return ; + return ( +
+ + {(caption ?? asset.caption) && ( +
{caption ?? asset.caption}
+ )} +
+ ); +} + +export function Screenshot({ + name, + caption, +}: { + name: string; + caption?: string; +}) { + const asset = getAsset(name); + const src = asset?.src ?? asset?.poster; + const [open, setOpen] = useState(false); + if (!src) return ; + const cap = caption ?? asset?.caption; + return ( + <> +
+ {cap setOpen(true)} + /> + {cap &&
{cap}
} +
+ {open && ( + setOpen(false)} /> + )} + + ); +} + +// A named series fans out to manifest entries name-1, name-2, … laid out as a +// responsive strip. +export function ScreenshotSeries({ + name, + captions, +}: { + name: string; + captions?: string[]; +}) { + const shots: { key: string; src: string; caption?: string }[] = []; + for (let i = 1; i < 20; i++) { + const asset = getAsset(`${name}-${i}`); + const src = asset?.src ?? asset?.poster; + if (!src) break; + shots.push({ + key: `${name}-${i}`, + src, + caption: captions?.[i - 1] ?? asset?.caption, + }); + } + const [open, setOpen] = useState<{ src: string; caption?: string } | null>( + null, + ); + if (shots.length === 0) return ; + return ( + <> +
+ {shots.map((s) => ( +
+ {s.caption setOpen({ src: s.src, caption: s.caption })} + /> + {s.caption &&
{s.caption}
} +
+ ))} +
+ {open && ( + setOpen(null)} + /> + )} + + ); +} diff --git a/site/components/theme-toggle.css b/site/components/theme-toggle.css new file mode 100644 index 0000000..1f29eb1 --- /dev/null +++ b/site/components/theme-toggle.css @@ -0,0 +1,107 @@ +/* The theme toggle, shared by the landing page and the docs app. Those two have + * different palettes (.lp and .doc-app), so this file styles itself from a + * small set of its own variables and each stylesheet maps them. The fallbacks + * below are the dark landing values, so the control is never unstyled. */ + +.theme-toggle { + display: inline-flex; + align-items: center; + gap: 1px; + margin: 0; + padding: 1px; + border: 1px solid var(--tt-line, #3f3644); + border-radius: 999px; + background: var(--tt-bg, transparent); + /* A
carries min-inline-size: min-content, which keeps it from + shrinking inside a flex row the way the other nav items do. */ + min-inline-size: 0; +} + +.theme-toggle .tt-opt { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 24px; + border-radius: 999px; + cursor: pointer; + color: var(--tt-fg, #9a94a0); + transition: color 0.12s ease-out, background 0.12s ease-out; +} + +.theme-toggle .tt-opt:hover { + color: var(--tt-fg-hover, #e9e7e9); +} + +/* The radio itself is removed from view but NOT from the accessibility tree or + the focus order, so arrow-key navigation within the group still works. */ +.theme-toggle input { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +.theme-toggle .tt-opt:has(input:checked) { + background: var(--tt-bg-active, #2f2136); + color: var(--tt-fg-active, #f8f7f8); +} + +/* :focus-visible on the input, ring on the label it is inside. */ +.theme-toggle .tt-opt:has(input:focus-visible) { + outline: 2px solid var(--tt-ring, #c7c4ca); + outline-offset: 1px; +} + +.theme-toggle .tt-icon { + display: block; + line-height: 0; +} + +.theme-toggle svg { + width: 13px; + height: 13px; + display: block; + fill: none; + stroke: currentColor; + stroke-width: 1.4; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* The moon is a filled shape; the sun and monitor are strokes. */ +.theme-toggle .tt-opt:nth-of-type(2) svg path { + fill: currentColor; + stroke-width: 1; +} + +.tt-sr { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +/* A fieldset's legend participates in its layout even when visually hidden; + taking it out of flow keeps the pill from gaining a phantom first row. */ +.theme-toggle > legend { + float: left; +} + +@media (prefers-reduced-motion: reduce) { + .theme-toggle .tt-opt { + transition: none; + } +} diff --git a/site/lib/links.test.ts b/site/lib/links.test.ts new file mode 100644 index 0000000..9b741b4 --- /dev/null +++ b/site/lib/links.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { linkKind } from "./links"; + +describe("linkKind", () => { + it.each([ + ["/docs/safe-tools", "docs"], + ["/docs/owasp-top10#asi03", "docs"], + ["/docs", "docs"], + ["https://example.com", "external"], + ["http://example.com", "external"], + ["#asi03", "plain"], + ["mailto:a@b.c", "plain"], + ["/", "plain"], + ["relative/path", "plain"], + ["/docsx", "plain"], + ] as const)("%s → %s", (href, want) => { + expect(linkKind(href)).toBe(want); + }); +}); diff --git a/site/lib/links.ts b/site/lib/links.ts new file mode 100644 index 0000000..ae384fc --- /dev/null +++ b/site/lib/links.ts @@ -0,0 +1,10 @@ +// How an MDX link renders: docs links navigate client-side, external links +// open a new tab, everything else (same-page anchors, mailto:, the landing +// page) is a plain anchor. +export type LinkKind = "docs" | "external" | "plain"; + +export function linkKind(href: string): LinkKind { + if (href === "/docs" || href.startsWith("/docs/") || href.startsWith("/docs#")) return "docs"; + if (/^https?:\/\//.test(href)) return "external"; + return "plain"; +} diff --git a/site/lib/manifest.ts b/site/lib/manifest.ts new file mode 100644 index 0000000..475c1e5 --- /dev/null +++ b/site/lib/manifest.ts @@ -0,0 +1,21 @@ +import manifest from "@/content/_manifest.json"; + +// Media are referenced from MDX by name (e.g. ) +// and resolved here through docs/_manifest.json, which the media pipeline writes. +// A miss renders a placeholder rather than breaking the page. +export interface MediaAsset { + kind: "clip" | "screenshot" | "video"; + webm?: string; + mp4?: string; + poster?: string; + src?: string; + caption?: string; + width?: number; + height?: number; +} + +const MANIFEST = manifest as Record; + +export function getAsset(name: string): MediaAsset | undefined { + return MANIFEST[name]; +} diff --git a/site/mdx-components.tsx b/site/mdx-components.tsx index bf4f27a..9736944 100644 --- a/site/mdx-components.tsx +++ b/site/mdx-components.tsx @@ -1,6 +1,29 @@ import type { MDXComponents } from "mdx/types"; +import Link from "next/link"; +import { Clip, Screenshot, ScreenshotSeries } from "@/components/media"; +import { Callout } from "@/components/Callout"; +import { Coverage } from "@/components/Coverage"; +import { linkKind } from "@/lib/links"; -const components: MDXComponents = {}; +// The component set every guide gets without importing anything. Custom +// components are capitalized so MDX resolves them here, not as HTML tags. +const components: MDXComponents = { + Clip, + Screenshot, + ScreenshotSeries, + Callout, + Coverage, + a: ({ href = "", ...props }) => { + switch (linkKind(href)) { + case "docs": + return ; + case "external": + return ; + default: + return ; + } + }, +}; export function useMDXComponents(): MDXComponents { return components; From b8d12ce4cc84156c32c9b15afea61e5df91cea29 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Mon, 28 Sep 2026 23:28:22 -0700 Subject: [PATCH 07/55] Link docs by path instead of hash route; check links in the build Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- pkg/gen/clidocs/clidocs.go | 2 +- pkg/gen/clidocs/clidocs_test.go | 2 +- pkg/gen/crddocs/crddocs.go | 2 +- pkg/gen/crddocs/crddocs_test.go | 2 +- site/content/docs/admin-activity.mdx | 10 +-- site/content/docs/admin-agents.mdx | 10 +-- site/content/docs/admin-audit.mdx | 4 +- site/content/docs/admin-connectivity.mdx | 4 +- site/content/docs/admin-dashboard.mdx | 22 +++---- site/content/docs/admin-identity.mdx | 6 +- site/content/docs/admin-memory.mdx | 12 ++-- site/content/docs/agent-builder-deliver.mdx | 8 +-- .../docs/agent-builder-first-agent.mdx | 4 +- site/content/docs/agent-builder-install.mdx | 6 +- site/content/docs/agent-builder-workshops.mdx | 4 +- site/content/docs/agent-builder.mdx | 16 ++--- site/content/docs/agent-definition.mdx | 14 ++-- site/content/docs/agent-loop.mdx | 8 +-- site/content/docs/agentclass-agentsession.mdx | 4 +- site/content/docs/artifact-annotations.mdx | 2 +- site/content/docs/artifact-revisions.mdx | 2 +- site/content/docs/artifacts.mdx | 8 +-- site/content/docs/authorization.mdx | 14 ++-- site/content/docs/browser-chat.mdx | 4 +- site/content/docs/budgets-breakers.mdx | 2 +- site/content/docs/channels.mdx | 14 ++-- site/content/docs/cli-reference.mdx | 56 ++++++++-------- site/content/docs/content-guards-egress.mdx | 2 +- site/content/docs/continuity-interactions.mdx | 4 +- site/content/docs/crd-reference.mdx | 66 +++++++++---------- site/content/docs/facts-observations.mdx | 2 +- site/content/docs/giving-credentials.mdx | 2 +- site/content/docs/health-upgrades.mdx | 4 +- site/content/docs/identity.mdx | 14 ++-- site/content/docs/idp-signin.mdx | 2 +- site/content/docs/information-leakage.mdx | 2 +- site/content/docs/install-cluster.mdx | 2 +- site/content/docs/install-desktop.mdx | 6 +- site/content/docs/install-init.mdx | 10 +-- site/content/docs/install-overview.mdx | 6 +- site/content/docs/managing-connections.mdx | 2 +- site/content/docs/mcp-servers.mdx | 4 +- site/content/docs/memory-authorization.mdx | 2 +- site/content/docs/memory.mdx | 14 ++-- site/content/docs/owasp-top10.mdx | 36 +++++----- site/content/docs/packaging-oap.mdx | 2 +- site/content/docs/passthrough-vs-agent.mdx | 2 +- site/content/docs/quickstart.mdx | 12 ++-- site/content/docs/revocation.mdx | 6 +- site/content/docs/safe-tools.mdx | 20 +++--- site/content/docs/sandboxed-execution.mdx | 2 +- site/content/docs/session-lifecycle.mdx | 18 ++--- site/content/docs/settings-config.mdx | 6 +- site/content/docs/skills.mdx | 2 +- site/content/docs/slack.mdx | 10 +-- site/content/docs/subagents-delegation.mdx | 8 +-- site/content/docs/supply-chain-pinning.mdx | 2 +- site/content/docs/terminal-chat.mdx | 2 +- site/content/docs/toolspec-validation.mdx | 4 +- site/content/docs/triggers.mdx | 4 +- site/content/docs/what-is-oap.mdx | 18 ++--- site/content/docs/workspaces.mdx | 2 +- site/lib/links.test.ts | 44 ++++++++++++- site/lib/links.ts | 50 ++++++++++++++ site/scripts/check.ts | 14 ++++ 65 files changed, 378 insertions(+), 272 deletions(-) diff --git a/pkg/gen/clidocs/clidocs.go b/pkg/gen/clidocs/clidocs.go index 5f4f248..888c17c 100644 --- a/pkg/gen/clidocs/clidocs.go +++ b/pkg/gen/clidocs/clidocs.go @@ -81,7 +81,7 @@ func overviewPage(root *cobra.Command, fams []*cobra.Command) string { b.WriteString("## Families\n\n") b.WriteString("| Family | What it does |\n| --- | --- |\n") for _, f := range fams { - fmt.Fprintf(&b, "| [`oap %s`](#/oap-%s) | %s |\n", f.Name(), f.Name(), mdxutil.TableText(f.Short)) + fmt.Fprintf(&b, "| [`oap %s`](/docs/oap-%s) | %s |\n", f.Name(), f.Name(), mdxutil.TableText(f.Short)) } b.WriteString("\n## Global flags\n\n") b.WriteString("These persistent flags apply to every command:\n\n") diff --git a/pkg/gen/clidocs/clidocs_test.go b/pkg/gen/clidocs/clidocs_test.go index 44a8275..156b508 100644 --- a/pkg/gen/clidocs/clidocs_test.go +++ b/pkg/gen/clidocs/clidocs_test.go @@ -35,7 +35,7 @@ func TestGenerate(t *testing.T) { over := string(overB) assert.Contains(t, over, "section: 'Reference'") assert.Contains(t, over, "group: 'CLI'") - assert.Contains(t, over, "[`oap agent`](#/oap-agent)", "family listed in overview") + assert.Contains(t, over, "[`oap agent`](/docs/oap-agent)", "family listed in overview") assert.Contains(t, over, "--namespace", "global persistent flags rendered on the overview") famB, err := os.ReadFile(filepath.Join(dir, "oap-agent.mdx")) diff --git a/pkg/gen/crddocs/crddocs.go b/pkg/gen/crddocs/crddocs.go index cec7de7..7d35df3 100644 --- a/pkg/gen/crddocs/crddocs.go +++ b/pkg/gen/crddocs/crddocs.go @@ -135,7 +135,7 @@ func overviewPage(crds []crdInfo) string { b.WriteString("full spec.\n\n") b.WriteString("| Kind | Scope | Summary |\n| --- | --- | --- |\n") for _, c := range crds { - fmt.Fprintf(&b, "| [%s](#/crd-%s) | %s | %s |\n", c.kind, c.singular, c.scope, mdxutil.TableText(c.desc)) + fmt.Fprintf(&b, "| [%s](/docs/crd-%s) | %s | %s |\n", c.kind, c.singular, c.scope, mdxutil.TableText(c.desc)) } return b.String() } diff --git a/pkg/gen/crddocs/crddocs_test.go b/pkg/gen/crddocs/crddocs_test.go index 6558882..32f0667 100644 --- a/pkg/gen/crddocs/crddocs_test.go +++ b/pkg/gen/crddocs/crddocs_test.go @@ -67,7 +67,7 @@ func TestGenerate(t *testing.T) { require.NoError(t, err) over := string(overB) assert.Contains(t, over, "group: 'CRD reference'") - assert.Contains(t, over, "[Widget](#/crd-widget)", "kind listed in the overview") + assert.Contains(t, over, "[Widget](/docs/crd-widget)", "kind listed in the overview") pageB, err := os.ReadFile(filepath.Join(out, "crd-widget.mdx")) require.NoError(t, err) diff --git a/site/content/docs/admin-activity.mdx b/site/content/docs/admin-activity.mdx index 8e0f6e4..6235a62 100644 --- a/site/content/docs/admin-activity.mdx +++ b/site/content/docs/admin-activity.mdx @@ -11,7 +11,7 @@ export const meta = {

The console's Live views are the real-time window on the fleet: which - sessions are running, what tools they're calling right now, and what's + sessions are running, what tools they're calling right now, and what's parked waiting for someone to decide.

@@ -26,21 +26,21 @@ is the at-a-glance version. ## Live tool calls **Live tool calls** streams tool invocations as they happen, across every session — the moment-to-moment view of -what the agents are actually doing. Each call is one pass through the [gated pipeline](#/agent-loop). +what the agents are actually doing. Each call is one pass through the [gated pipeline](/docs/agent-loop). ## Approvals -**Approvals** is the queue of things waiting on a person: tool calls and [information-leakage](#/information-leakage) -gates that need a decision. It's the operator's side of the [in-channel approvals](#/authorization) — the same +**Approvals** is the queue of things waiting on a person: tool calls and [information-leakage](/docs/information-leakage) +gates that need a decision. It's the operator's side of the [in-channel approvals](/docs/authorization) — the same decisions, gathered in one place. ## Workshops -**Workshops** lists every open [agent-builder](#/agent-builder) workshop — who started it, what phase it is +**Workshops** lists every open [agent-builder](/docs/agent-builder) workshop — who started it, what phase it is in, and the install request waiting on it, if the person asked for one. **Install** applies the drafted agent into a namespace you choose, answering any install-time questions the draft declares; **Decline** ends the request; **Kill** tears the workshop down outright. Installing is the one action a builder cannot take for diff --git a/site/content/docs/admin-agents.mdx b/site/content/docs/admin-agents.mdx index 892f471..cb44ad9 100644 --- a/site/content/docs/admin-agents.mdx +++ b/site/content/docs/admin-agents.mdx @@ -11,14 +11,14 @@ export const meta = {

The Config views are where you review what an agent is allowed to be — the reviewed - building blocks that make up an agent definition. + building blocks that make up an agent definition.

## Agents **Agents** lists the configured AgentClasses and their readiness — whether each is `Valid`, how many tools and skills it has, and its provenance. Installing one from here (**Install agent**) runs the same permissioned -`.oap` [install path](#/packaging-oap) as the CLI. +`.oap` [install path](/docs/packaging-oap) as the CLI. @@ -26,9 +26,9 @@ skills it has, and its provenance. Installing one from here (**Install agent**) Three more config views cover what an agent can *do*: -- **Tools** — the [MCP servers, sandboxed CLIs, sidecars, and toolkits](#/safe-tools) available to agents. -- **Skills** — the instruction and executable [skills](#/skills) agents can be granted. -- **Sources** — the git-backed [skill sources](#/skills) that materialize those skills, and their sync state. +- **Tools** — the [MCP servers, sandboxed CLIs, sidecars, and toolkits](/docs/safe-tools) available to agents. +- **Skills** — the instruction and executable [skills](/docs/skills) agents can be granted. +- **Sources** — the git-backed [skill sources](/docs/skills) that materialize those skills, and their sync state. Each mirrors an `oap` command (`oap tools`, `oap skill`, `oap skill source`), so the console shows you what the CLI would — reviewed, in one place. diff --git a/site/content/docs/admin-audit.mdx b/site/content/docs/admin-audit.mdx index d6445d6..89045fa 100644 --- a/site/content/docs/admin-audit.mdx +++ b/site/content/docs/admin-audit.mdx @@ -17,7 +17,7 @@ export const meta = { ## Logs **Logs** is the cross-session audit: approvals, authorization decisions, tool calls, and scope changes, filtered -by kind, outcome, agent, or tool. It's the read side of the [tamper-evident log](#/audit-log) — the same signed, +by kind, outcome, agent, or tool. It's the read side of the [tamper-evident log](/docs/audit-log) — the same signed, hash-chained records `oap audit verify` checks offline — surfaced for browsing rather than verification. @@ -26,6 +26,6 @@ hash-chained records `oap audit verify` checks offline — surfaced for browsing **Budget** breaks down estimated spend by model, agent, session, and user — a running total (list-price estimate) that resets daily. It's the console view of the same accounting the per-session -[budgets](#/session-lifecycle) enforce. +[budgets](/docs/session-lifecycle) enforce. diff --git a/site/content/docs/admin-connectivity.mdx b/site/content/docs/admin-connectivity.mdx index 7eb7518..1c4524b 100644 --- a/site/content/docs/admin-connectivity.mdx +++ b/site/content/docs/admin-connectivity.mdx @@ -16,14 +16,14 @@ export const meta = { ## Channels -**Channels** shows where agents are reachable — each [channel](#/channels) binding (Slack, browser, a webhook), +**Channels** shows where agents are reachable — each [channel](/docs/channels) binding (Slack, browser, a webhook), which agent it's bound to, its role, and its connection status. It's the console view of `oap channel`. ## Directory -**Directory** shows the directory syncs feeding the [authorization graph](#/authorization) — Slack workspace and +**Directory** shows the directory syncs feeding the [authorization graph](/docs/authorization) — Slack workspace and channel memberships, GitHub org membership, 1Password groups. These keep SpiceDB's picture of who's a member of what current, so an authorization check reflects reality rather than a stale snapshot. diff --git a/site/content/docs/admin-dashboard.mdx b/site/content/docs/admin-dashboard.mdx index 54a5f3d..00ee103 100644 --- a/site/content/docs/admin-dashboard.mdx +++ b/site/content/docs/admin-dashboard.mdx @@ -24,26 +24,26 @@ The console groups its views by what you're doing: - **Live** — agent **sessions** in flight, **live tool calls** as they happen, pending **approvals**, and active work. - **Config** — the **agents**, **tools**, **skills**, **sources**, **channels**, and **identity** wiring, plus - **users**, **access**, and tiered [**settings**](#/settings-config). It's where you review what an agent is + **users**, **access**, and tiered [**settings**](/docs/settings-config). It's where you review what an agent is allowed to be. - **Audit** — the **logs**, past **sessions**, **budget** and spend, **artifacts**, and the **memory** and - **knowledge** stores. This is the read side of the [tamper-evident record](#/audit-log). + **knowledge** stores. This is the read side of the [tamper-evident record](/docs/audit-log). ## Each section in depth -- **[Live activity](#/admin-activity)** — sessions in flight, live tool calls, and the approvals queue. -- **[Agents, tools & skills](#/admin-agents)** — the reviewed building blocks of an agent. -- **[Channels & directory](#/admin-connectivity)** — where agents are reached, and the directory syncs. -- **[Identity, users & access](#/admin-identity)** — who acts, whose credentials, and who administers. -- **[Memory, knowledge & artifacts](#/admin-memory)** — the stores agents write to and produce. -- **[Audit log & budget](#/admin-audit)** — what happened, and what it cost. +- **[Live activity](/docs/admin-activity)** — sessions in flight, live tool calls, and the approvals queue. +- **[Agents, tools & skills](/docs/admin-agents)** — the reviewed building blocks of an agent. +- **[Channels & directory](/docs/admin-connectivity)** — where agents are reached, and the directory syncs. +- **[Identity, users & access](/docs/admin-identity)** — who acts, whose credentials, and who administers. +- **[Memory, knowledge & artifacts](/docs/admin-memory)** — the stores agents write to and produce. +- **[Audit log & budget](/docs/admin-audit)** — what happened, and what it cost. -Config also includes the tiered [Settings](#/settings-config), shown in the console per setting. +Config also includes the tiered [Settings](/docs/settings-config), shown in the console per setting. ## Reached like any other surface -The dashboard is a loopback-only tab on [Desktop](#/install-desktop) and a signed-in, authorized surface on a -cluster — the same [sign-in](#/idp-signin) and per-request SpiceDB checks as the browser chat. Being an admin +The dashboard is a loopback-only tab on [Desktop](/docs/install-desktop) and a signed-in, authorized surface on a +cluster — the same [sign-in](/docs/idp-signin) and per-request SpiceDB checks as the browser chat. Being an admin is itself a permission in the graph, not a static role file, so who can see and change what is answered the same way as who can call a tool. diff --git a/site/content/docs/admin-identity.mdx b/site/content/docs/admin-identity.mdx index 99714e4..a51eaa7 100644 --- a/site/content/docs/admin-identity.mdx +++ b/site/content/docs/admin-identity.mdx @@ -16,7 +16,7 @@ export const meta = { ## Identity -**Identity** manages agent [identities and credentials](#/identity), and the [login providers](#/idp-signin) +**Identity** manages agent [identities and credentials](/docs/identity), and the [login providers](/docs/idp-signin) people sign in through. Its two tabs — *Agent identities* (how agents authenticate to upstreams) and *Login providers* (how humans sign in) — are the console view of `oap identity` and `oap idp`. @@ -25,7 +25,7 @@ providers* (how humans sign in) — are the console view of `oap identity` and ` ## Users & Access - **Users** — the people linked to the platform (via `oap user-identity`), and the credentials they've connected - through the [self-service portal](#/managing-connections). -- **Access** — the platform admins and the SpiceDB [authorization schema](#/authorization) itself. Being an + through the [self-service portal](/docs/managing-connections). +- **Access** — the platform admins and the SpiceDB [authorization schema](/docs/authorization) itself. Being an admin is a permission in the graph, so this is where you see and manage who holds it — the same graph every other check consults. diff --git a/site/content/docs/admin-memory.mdx b/site/content/docs/admin-memory.mdx index 8219944..77a1e8b 100644 --- a/site/content/docs/admin-memory.mdx +++ b/site/content/docs/admin-memory.mdx @@ -10,27 +10,27 @@ export const meta = { # Memory, knowledge & artifacts

- The stores an agent writes to are readable from the console too — what it remembers, - the graph it's built, and the artifacts it's produced. + The stores an agent writes to are readable from the console too — what it remembers, + the graph it's built, and the artifacts it's produced.

## Memory -**Memory** is the read view into the searchable, authorized [memory store](#/memory-storage-search) — the kinds +**Memory** is the read view into the searchable, authorized [memory store](/docs/memory-storage-search) — the kinds of entry, how many of each, and their scopes. It's the console counterpart of `oap memory`, and it's gated by -the [same per-subject authorization](#/memory-authorization) as the store itself. +the [same per-subject authorization](/docs/memory-authorization) as the store itself. ## Knowledge -**Knowledge** surfaces the [knowledge graph](#/memory-storage-search) — entities, facts, and communities +**Knowledge** surfaces the [knowledge graph](/docs/memory-storage-search) — entities, facts, and communities (`oap kg`). It's available when the graph provider is configured; without it, the view simply reports that knowledge isn't wired up on this install. ## Artifacts -**Artifacts** lists the durable, versioned [outputs](#/artifacts) agents have rendered — each with its +**Artifacts** lists the durable, versioned [outputs](/docs/artifacts) agents have rendered — each with its revisions. It's the read side of `oap artifact`. diff --git a/site/content/docs/agent-builder-deliver.mdx b/site/content/docs/agent-builder-deliver.mdx index 290ddf2..32d9278 100644 --- a/site/content/docs/agent-builder-deliver.mdx +++ b/site/content/docs/agent-builder-deliver.mdx @@ -10,7 +10,7 @@ export const meta = {

A build ends in three things: a test you watched, a draft you keep, and — if you ask — a request an admin - decides. This page is the second half of Your first custom agent, + decides. This page is the second half of Your first custom agent, for when you want to know exactly what each of those means.

@@ -38,7 +38,7 @@ another test. ## The saved draft **Deliver** always exports the draft first, whether or not it is installed. What you get is a `.oap` bundle — -the same [packaging](#/packaging-oap) every agent here ships as — attached to the conversation and offered on +the same [packaging](/docs/packaging-oap) every agent here ships as — attached to the conversation and offered on the page as a download. It carries the agent's definition, its tools, its skills, and the questions an install must answer. It carries **nothing of your accounts**: a bundle declares the credentials it needs and never their values. @@ -55,7 +55,7 @@ was built in is not part of the draft and does not need to exist any more. for the agent and the digest of the draft as tested, and it waits for a platform admin. The builder tells you so in plain terms — it is someone else's decision to make — and marks the Deliver stage accordingly. -The admin sees the request under **Workshops** in the [admin console](#/admin-activity) alongside the other +The admin sees the request under **Workshops** in the [admin console](/docs/admin-activity) alongside the other open workshops: who started the build, what phase it is in, and the state of the request. @@ -83,4 +83,4 @@ stays theirs. Once installed, the row records where the agent landed: An installed agent lives in its target namespace like any other; the workshop that built it is released. A declined request ends the same way — the workshop is released once the session ends and the request has a decision, and the draft is still on the page and in the conversation for as long as the session's records -are kept. See [Workshops and limits](#/agent-builder-workshops) for what "released" means and when it happens. +are kept. See [Workshops and limits](/docs/agent-builder-workshops) for what "released" means and when it happens. diff --git a/site/content/docs/agent-builder-first-agent.mdx b/site/content/docs/agent-builder-first-agent.mdx index 802a948..107e944 100644 --- a/site/content/docs/agent-builder-first-agent.mdx +++ b/site/content/docs/agent-builder-first-agent.mdx @@ -19,7 +19,7 @@ export const meta = { ## 1. Start the builder Sign in to the web UI, open **New session**, and pick **Agent Builder**. It appears in the picker only for the -people named when the builder was [turned on](#/agent-builder-install). Leave the message empty — the workshop +people named when the builder was [turned on](/docs/agent-builder-install). Leave the message empty — the workshop page is where you say what you want — and press **Open UI**. @@ -97,7 +97,7 @@ choices: That is the whole build. The saved draft is yours whether or not it is ever installed; the admin's side of the -story is in [Testing, delivery, and install](#/agent-builder-deliver). +story is in [Testing, delivery, and install](/docs/agent-builder-deliver). Describe an agent that needs a service — "summarize new tickets from our tracker every morning", "answer diff --git a/site/content/docs/agent-builder-install.mdx b/site/content/docs/agent-builder-install.mdx index 82dbc92..9e9eb4c 100644 --- a/site/content/docs/agent-builder-install.mdx +++ b/site/content/docs/agent-builder-install.mdx @@ -23,7 +23,7 @@ oap install --builder-starters user:$(oap identity canonical-id you@example.com) `oap identity canonical-id` turns an email into the identifier the platform uses for that person. You can list several identities, separated by commas, and a group as `group:` — the same subjects the rest of the -[authorization model](#/authorization) uses. +[authorization model](/docs/authorization) uses. That is the whole install. It puts one `AgentClass`, `agent-builder`, in the platform's own namespace, `agentprimitives-system`, with its workshop sidecar and its skills, and records the class as sanctioned for @@ -37,7 +37,7 @@ oap install --without-builder ## On the Desktop app, or after `oap init` -Neither the [Desktop app](#/install-desktop) nor [`oap init`](#/install-init) installs the builder: neither has +Neither the [Desktop app](/docs/install-desktop) nor [`oap init`](/docs/install-init) installs the builder: neither has an identity to hand it, and an empty starter list would lock everyone out of a class nobody could then fix from the UI. Add it afterwards with a second `oap install` — installing is idempotent, so re-running it against a cluster that is already up only adds what is missing. @@ -80,4 +80,4 @@ picker; anyone else does not. Pick it and the workshop page opens: | A sanction in `ClusterAgentSettings` | cluster-wide | `limits.builderClasses` names the class and its sidecar. Only a sanctioned class gets a workshop. The same settings hold `limits.maxWorkshopsPerStarter`, the per-person limit (default 3). | Each build then creates a `Workshop` for that session — its own namespace, its own permissions, its own -sidecar identity — and deletes it when the build is over. See [Workshops and limits](#/agent-builder-workshops). +sidecar identity — and deletes it when the build is over. See [Workshops and limits](/docs/agent-builder-workshops). diff --git a/site/content/docs/agent-builder-workshops.mdx b/site/content/docs/agent-builder-workshops.mdx index e92799f..36e908c 100644 --- a/site/content/docs/agent-builder-workshops.mdx +++ b/site/content/docs/agent-builder-workshops.mdx @@ -30,7 +30,7 @@ workshop and nothing else; a grant for one workshop means nothing in another. ## How many you may have open Each person may keep a small number of workshops open at once — **three by default**, set cluster-wide by -`limits.maxWorkshopsPerStarter` in [settings](#/settings-config). "Open" means live: a build still going, or one +`limits.maxWorkshopsPerStarter` in [settings](/docs/settings-config). "Open" means live: a build still going, or one that finished but whose workshop has not been released yet. Starting a builder session past the limit is refused before anything is created. The refusal names the open @@ -62,7 +62,7 @@ request to wait for, so a session that ends after *Hand the draft to someone* re workshops stop counting toward the limit. - The [admin console](#/admin-dashboard) lists every open workshop with its starter, its install request if it + The [admin console](/docs/admin-dashboard) lists every open workshop with its starter, its install request if it made one, and the option to delete it outright. The per-person limit is `spec.limits.maxWorkshopsPerStarter` on the cluster's settings; the list of classes allowed to open a workshop at all is `spec.limits.builderClasses`, seeded by oap install for the builder and never widened by a diff --git a/site/content/docs/agent-builder.mdx b/site/content/docs/agent-builder.mdx index 39665c2..15dff53 100644 --- a/site/content/docs/agent-builder.mdx +++ b/site/content/docs/agent-builder.mdx @@ -52,22 +52,22 @@ as it goes, so you always see where it is and what has been settled. Each stage that changes the draft is something you approve **once**, on a card the builder raises before it starts. Every change inside that stage then runs without another card; a stage the builder never planned for -raises a fresh one. The details are in [Your first custom agent](#/agent-builder-first-agent). +raises a fresh one. The details are in [Your first custom agent](/docs/agent-builder-first-agent). ## Who can use it Access is set when the builder is installed: `oap install --builder-starters` names the people and groups who may start it. Anyone else does not see it in the session picker at all. See -[Turn on the agent builder](#/agent-builder-install). +[Turn on the agent builder](/docs/agent-builder-install). A person may have a small number of workshops open at once — three by default. Starting a fourth is refused with the list of the ones still open and a way out: open one of them and ask it to close the others. A finished -build releases its workshop on its own. See [Workshops and limits](#/agent-builder-workshops). +build releases its workshop on its own. See [Workshops and limits](/docs/agent-builder-workshops). ## What an admin sees "Install for real" does not install anything. It records a request that a platform admin reviews in the -[admin console](#/admin-dashboard): the suggested name, who asked, and the draft as tested. The admin installs it +[admin console](/docs/admin-dashboard): the suggested name, who asked, and the draft as tested. The admin installs it into the namespace it belongs in, or declines it, and the person is told either way. The saved draft is the person's regardless — they can hand it to someone else, or ask again later. @@ -81,11 +81,11 @@ person's regardless — they can hand it to someone else, or ask again later. ## In this section -- **[Turn on the agent builder](#/agent-builder-install)** — the one `oap install` flag, and how to find your +- **[Turn on the agent builder](/docs/agent-builder-install)** — the one `oap install` flag, and how to find your identity. -- **[Your first custom agent](#/agent-builder-first-agent)** — a full build, stage by stage, with the page as +- **[Your first custom agent](/docs/agent-builder-first-agent)** — a full build, stage by stage, with the page as it changes. -- **[Testing, delivery, and install](#/agent-builder-deliver)** — what the test card tells you, what the saved +- **[Testing, delivery, and install](/docs/agent-builder-deliver)** — what the test card tells you, what the saved draft is, and how an admin installs it. -- **[Workshops and limits](#/agent-builder-workshops)** — the per-person limit, closing workshops, and what +- **[Workshops and limits](/docs/agent-builder-workshops)** — the per-person limit, closing workshops, and what releases one. diff --git a/site/content/docs/agent-definition.mdx b/site/content/docs/agent-definition.mdx index c0ed240..1ccb823 100644 --- a/site/content/docs/agent-definition.mdx +++ b/site/content/docs/agent-definition.mdx @@ -32,19 +32,19 @@ thing that acts. ## Go deeper -- **[AgentClass & AgentSession](#/agentclass-agentsession)** — the template/instance split, field by field. -- **[The agent loop](#/agent-loop)** — how a turn runs, and where the safety hooks sit. -- **[Skills](#/skills)** — reusable capabilities an agent can be granted. -- **[Packaging](#/packaging-oap)** — the `.oap` bundle: shipping an agent and its dependency graph as one signed artifact. +- **[AgentClass & AgentSession](/docs/agentclass-agentsession)** — the template/instance split, field by field. +- **[The agent loop](/docs/agent-loop)** — how a turn runs, and where the safety hooks sit. +- **[Skills](/docs/skills)** — reusable capabilities an agent can be granted. +- **[Packaging](/docs/packaging-oap)** — the `.oap` bundle: shipping an agent and its dependency graph as one signed artifact. The class as a reviewable, budgeted, isolated unit is OAP's answer to several agentic threats: diff --git a/site/content/docs/agent-loop.mdx b/site/content/docs/agent-loop.mdx index cbfe47f..54ea164 100644 --- a/site/content/docs/agent-loop.mdx +++ b/site/content/docs/agent-loop.mdx @@ -37,10 +37,10 @@ the checks. The hooks run **before** dispatch, in the pre-tool-call stage: -- **Authorization** — can this subject take this action, on this resource, right now? ([Authorization](#/authorization)) -- **Plan gate** — is this call inside an approved plan, or does it need one? ([Plan gating](#/plan-gating)) -- **Content guards** — does the call's input or the tool's output need inspecting or bounding? ([Content guards](#/content-guards-egress)) -- **Rate limits & circuit breakers** — has this tool run away? ([Budgets & breakers](#/budgets-breakers)) +- **Authorization** — can this subject take this action, on this resource, right now? ([Authorization](/docs/authorization)) +- **Plan gate** — is this call inside an approved plan, or does it need one? ([Plan gating](/docs/plan-gating)) +- **Content guards** — does the call's input or the tool's output need inspecting or bounding? ([Content guards](/docs/content-guards-egress)) +- **Rate limits & circuit breakers** — has this tool run away? ([Budgets & breakers](/docs/budgets-breakers)) A call that any gate denies **never reaches the tool** — no sandbox exec, no MCP request. The denial comes back to the model as an error result, which it reads like any other content. And nothing is allowed to hang diff --git a/site/content/docs/agentclass-agentsession.mdx b/site/content/docs/agentclass-agentsession.mdx index cf6fdbf..8f74fb3 100644 --- a/site/content/docs/agentclass-agentsession.mdx +++ b/site/content/docs/agentclass-agentsession.mdx @@ -20,10 +20,10 @@ export const meta = { An AgentClass is a Kubernetes resource, so you diff it, review it, and pin it like any other. In one place it declares the whole agent: -- **Model** — which LLM (or nothing, to use the [cluster default](#/install-overview)). +- **Model** — which LLM (or nothing, to use the [cluster default](/docs/install-overview)). - **Prompt** — the system prompt that defines the agent's job. - **Tools** — the toolspecs, MCP servers, and skills it may use, and nothing else. -- **Identity** — whose credentials it acts as ([agent, passthrough, ask, or dynamic](#/identity-modes)). +- **Identity** — whose credentials it acts as ([agent, passthrough, ask, or dynamic](/docs/identity-modes)). - **Authorization** — the permissions and resource *slots* it can be granted, checked per action. - **Channels** — where it can be reached (bound separately, per install). - **Budgets** — ceilings on tokens, turns, and wall-clock time. diff --git a/site/content/docs/artifact-annotations.mdx b/site/content/docs/artifact-annotations.mdx index a1f5dc6..3a4407e 100644 --- a/site/content/docs/artifact-annotations.mdx +++ b/site/content/docs/artifact-annotations.mdx @@ -36,7 +36,7 @@ different one. The other kind of markup is metadata on the artifact itself. Every artifact has a **name** and **description**, and every revision records a short **change note** — read down the history and you get a changelog for free. -A **tag** is a named pointer to a particular [revision](#/artifact-revisions) — `approved`, `published`, `v2` — +A **tag** is a named pointer to a particular [revision](/docs/artifact-revisions) — `approved`, `published`, `v2` — so you can refer to a meaningful version by name instead of a sequence number. One tag is reserved: **`latest`** always follows the newest revision, while a tag like `approved` stays put on the exact bytes someone signed off on until you deliberately move it. An artifact can also be marked **internal** — used by the agent as part of diff --git a/site/content/docs/artifact-revisions.mdx b/site/content/docs/artifact-revisions.mdx index 07cd4e2..d239f51 100644 --- a/site/content/docs/artifact-revisions.mdx +++ b/site/content/docs/artifact-revisions.mdx @@ -30,7 +30,7 @@ while an exact earlier revision remains addressable if you need the version some ## The durable handle -Each revision carries the reference to its bytes in the [artifact store](#/artifacts). That reference is the +Each revision carries the reference to its bytes in the [artifact store](/docs/artifacts). That reference is the one durable handle to the output once the transient render is cleaned up — it's what makes an artifact outlive the session that made it. diff --git a/site/content/docs/artifacts.mdx b/site/content/docs/artifacts.mdx index f66b0f9..9e33d1b 100644 --- a/site/content/docs/artifacts.mdx +++ b/site/content/docs/artifacts.mdx @@ -34,13 +34,13 @@ every output on restart. `oap artifact` inspects them from the CLI. ## Go deeper -- **[Revisions & versioning](#/artifact-revisions)** — immutable revisions, a moving `latest`, and revising in +- **[Revisions & versioning](/docs/artifact-revisions)** — immutable revisions, a moving `latest`, and revising in place. -- **[Annotations & tags](#/artifact-annotations)** — names, descriptions, and named tags that point at a +- **[Annotations & tags](/docs/artifact-annotations)** — names, descriptions, and named tags that point at a revision. -- **[Rendered inert](#/artifact-safety)** — sanitize, constrain with CSP, and isolate the origin so an +- **[Rendered inert](/docs/artifact-safety)** — sanitize, constrain with CSP, and isolate the origin so an artifact's scripts can't run. -- **[Delivery & completion](#/artifact-delivery)** — producing isn't delivering, and a session can be required +- **[Delivery & completion](/docs/artifact-delivery)** — producing isn't delivering, and a session can be required to actually hand its work over. diff --git a/site/content/docs/authorization.mdx b/site/content/docs/authorization.mdx index d7edcf8..8d20327 100644 --- a/site/content/docs/authorization.mdx +++ b/site/content/docs/authorization.mdx @@ -34,22 +34,22 @@ approval can never be transplanted onto something it wasn't for. ## Go deeper -- **[Multiplayer sessions](#/multiplayer-sessions)** — more than one human in a shared session, gated at the +- **[Multiplayer sessions](/docs/multiplayer-sessions)** — more than one human in a shared session, gated at the door and at the dangerous step. -- **[Plan gating & approvals](#/plan-gating)** — approving a whole multi-step plan at once, scoped to exactly +- **[Plan gating & approvals](/docs/plan-gating)** — approving a whole multi-step plan at once, scoped to exactly the plan — and the exact resources — it named. -- **[Shared permissions](#/shared-permissions)** — data owned by a resource, released only with its owner's +- **[Shared permissions](/docs/shared-permissions)** — data owned by a resource, released only with its owner's approval, routed to the owner rather than the requester. -- **[Information leakage & egress](#/information-leakage)** — the right data reaching the wrong audience is a +- **[Information leakage & egress](/docs/information-leakage)** — the right data reaching the wrong audience is a permission question too, gated per-datum and routed to the data owner. diff --git a/site/content/docs/browser-chat.mdx b/site/content/docs/browser-chat.mdx index 36d7ce4..2201f02 100644 --- a/site/content/docs/browser-chat.mdx +++ b/site/content/docs/browser-chat.mdx @@ -11,7 +11,7 @@ export const meta = {

The web daemon serves a chat at /sessions — a browser surface for talking to any installed - agent, with no Slack app and no terminal. It's what the Desktop menubar's + agent, with no Slack app and no terminal. It's what the Desktop menubar's "New chat…" opens, and it renders the same threads, approvals, and status as every other channel.

@@ -32,7 +32,7 @@ on `agentsession#interact` before the message and again before the live stream. direct line between you and an agent, not a console anyone can pick up. The *multiplayer* story — many people in one conversation, each authorized independently, approving each -other's steps — is [Slack](#/slack)'s, where the identities and the thread are real. Reach for the browser chat +other's steps — is [Slack](/docs/slack)'s, where the identities and the thread are real. Reach for the browser chat to drive an agent yourself; reach for Slack when a team shares the thread. diff --git a/site/content/docs/budgets-breakers.mdx b/site/content/docs/budgets-breakers.mdx index 6297a36..7a78cd4 100644 --- a/site/content/docs/budgets-breakers.mdx +++ b/site/content/docs/budgets-breakers.mdx @@ -43,7 +43,7 @@ Also optional: a byte budget on what moves through a tool. error**, so an oversized payload never reaches the model. Every one of these is configured on the AgentClass — and a cluster-level ceiling can clamp what an individual -class is allowed to loosen — with every trip written to the [audit log](#/audit-log). +class is allowed to loosen — with every trip written to the [audit log](/docs/audit-log). The breakers are on out of the box, and their action is to *deny*, not merely warn. Containing a cascading diff --git a/site/content/docs/channels.mdx b/site/content/docs/channels.mdx index ac67d28..39f7d8c 100644 --- a/site/content/docs/channels.mdx +++ b/site/content/docs/channels.mdx @@ -29,18 +29,18 @@ approve is an authorization question, not "whoever can type." ## Go deeper -- **[Slack](#/slack)** — the richest surface: the app-provisioning wizard, threads, in-channel interactions, and - the App Home hub. (Most of the [Authorization](#/authorization) guides are shown through Slack.) -- **[Browser web chat](#/browser-chat)** — the built-in `/sessions` chat served by the web daemon. -- **[Local terminal](#/terminal-chat)** — a full-screen TUI (`oap agent chat`) with zero external setup. -- **[Continuity & interactions](#/continuity-interactions)** — how threads persist and how approvals, notices, and status render across kinds. +- **[Slack](/docs/slack)** — the richest surface: the app-provisioning wizard, threads, in-channel interactions, and + the App Home hub. (Most of the [Authorization](/docs/authorization) guides are shown through Slack.) +- **[Browser web chat](/docs/browser-chat)** — the built-in `/sessions` chat served by the web daemon. +- **[Local terminal](/docs/terminal-chat)** — a full-screen TUI (`oap agent chat`) with zero external setup. +- **[Continuity & interactions](/docs/continuity-interactions)** — how threads persist and how approvals, notices, and status render across kinds. diff --git a/site/content/docs/cli-reference.mdx b/site/content/docs/cli-reference.mdx index 0ea7dbe..dac5c15 100644 --- a/site/content/docs/cli-reference.mdx +++ b/site/content/docs/cli-reference.mdx @@ -15,34 +15,34 @@ from the CLI, so it matches the binary you have. Pick a family for its subcomman | Family | What it does | | --- | --- | -| [`oap agent`](#/oap-agent) | Manage and run AgentClass resources | -| [`oap artifact`](#/oap-artifact) | Inspect versioned artifacts and their revisions | -| [`oap audit`](#/oap-audit) | Verify the tamper-evident audit log of a session | -| [`oap build`](#/oap-build) | Build (and load) Docker images | -| [`oap channel`](#/oap-channel) | Manage Channel CRs (Slack, future webhook/cron, ...). | -| [`oap check`](#/oap-check) | verify namespace, CRDs, services, and every registered component's health | -| [`oap class`](#/oap-class) | Inspect SpiceboxClass resources (sandbox image + capability descriptors) | -| [`oap clean`](#/oap-clean) | Remove everything oap installed (CRs → operator → CRDs → namespace) | -| [`oap desktop`](#/oap-desktop) | Run the oap macOS menubar app (local VM + built-in chat; macOS/Apple Silicon only) | -| [`oap identity`](#/oap-identity) | Manage AgentIdentity resources | -| [`oap idp`](#/oap-idp) | Manage the cluster identity provider | -| [`oap image`](#/oap-image) | Work with local Docker images and the current cluster | -| [`oap init`](#/oap-init) | Bring up agent-primitives end-to-end (build + install + check) | -| [`oap install`](#/oap-install) | Install the operator + CRDs into the configured cluster | -| [`oap kg`](#/oap-kg) | Query the knowledge graph | -| [`oap login`](#/oap-login) | Log in to this cluster's identity provider and cache the assertion | -| [`oap logout`](#/oap-logout) | Remove the cached identity assertion | -| [`oap memory`](#/oap-memory) | Inspect and manage session memory entries | -| [`oap pin`](#/oap-pin) | Inspect and update dependency pin baselines | -| [`oap plangate`](#/oap-plangate) | Inspect what the plan gate recorded for a session | -| [`oap platform`](#/oap-platform) | Manage platform-level admin access (the admin UI gate) | -| [`oap sandbox`](#/oap-sandbox) | Inspect SpiceboxSession resources (per-bundle running sandbox pods) | -| [`oap session`](#/oap-session) | Inspect AgentSession resources | -| [`oap settings`](#/oap-settings) | Manage cluster-wide agent settings (security defaults wizard + apply). | -| [`oap skill`](#/oap-skill) | List, view, create, and delete agent Skills and SkillSources | -| [`oap spicedb`](#/oap-spicedb) | Apply schema and check permissions against the system SpiceDB. | -| [`oap tools`](#/oap-tools) | Manage tools used by agents (kind-agnostic) | -| [`oap user-identity`](#/oap-user-identity) | Manage UserIdentity resources (per-user credential catalogs) | +| [`oap agent`](/docs/oap-agent) | Manage and run AgentClass resources | +| [`oap artifact`](/docs/oap-artifact) | Inspect versioned artifacts and their revisions | +| [`oap audit`](/docs/oap-audit) | Verify the tamper-evident audit log of a session | +| [`oap build`](/docs/oap-build) | Build (and load) Docker images | +| [`oap channel`](/docs/oap-channel) | Manage Channel CRs (Slack, future webhook/cron, ...). | +| [`oap check`](/docs/oap-check) | verify namespace, CRDs, services, and every registered component's health | +| [`oap class`](/docs/oap-class) | Inspect SpiceboxClass resources (sandbox image + capability descriptors) | +| [`oap clean`](/docs/oap-clean) | Remove everything oap installed (CRs → operator → CRDs → namespace) | +| [`oap desktop`](/docs/oap-desktop) | Run the oap macOS menubar app (local VM + built-in chat; macOS/Apple Silicon only) | +| [`oap identity`](/docs/oap-identity) | Manage AgentIdentity resources | +| [`oap idp`](/docs/oap-idp) | Manage the cluster identity provider | +| [`oap image`](/docs/oap-image) | Work with local Docker images and the current cluster | +| [`oap init`](/docs/oap-init) | Bring up agent-primitives end-to-end (build + install + check) | +| [`oap install`](/docs/oap-install) | Install the operator + CRDs into the configured cluster | +| [`oap kg`](/docs/oap-kg) | Query the knowledge graph | +| [`oap login`](/docs/oap-login) | Log in to this cluster's identity provider and cache the assertion | +| [`oap logout`](/docs/oap-logout) | Remove the cached identity assertion | +| [`oap memory`](/docs/oap-memory) | Inspect and manage session memory entries | +| [`oap pin`](/docs/oap-pin) | Inspect and update dependency pin baselines | +| [`oap plangate`](/docs/oap-plangate) | Inspect what the plan gate recorded for a session | +| [`oap platform`](/docs/oap-platform) | Manage platform-level admin access (the admin UI gate) | +| [`oap sandbox`](/docs/oap-sandbox) | Inspect SpiceboxSession resources (per-bundle running sandbox pods) | +| [`oap session`](/docs/oap-session) | Inspect AgentSession resources | +| [`oap settings`](/docs/oap-settings) | Manage cluster-wide agent settings (security defaults wizard + apply). | +| [`oap skill`](/docs/oap-skill) | List, view, create, and delete agent Skills and SkillSources | +| [`oap spicedb`](/docs/oap-spicedb) | Apply schema and check permissions against the system SpiceDB. | +| [`oap tools`](/docs/oap-tools) | Manage tools used by agents (kind-agnostic) | +| [`oap user-identity`](/docs/oap-user-identity) | Manage UserIdentity resources (per-user credential catalogs) | ## Global flags diff --git a/site/content/docs/content-guards-egress.mdx b/site/content/docs/content-guards-egress.mdx index dadfc2c..42e2c6c 100644 --- a/site/content/docs/content-guards-egress.mdx +++ b/site/content/docs/content-guards-egress.mdx @@ -41,4 +41,4 @@ link to — somewhere it shouldn't, before that URL ever reaches the model. Content guards inspect *what* a tool's I/O contains. The related question — *who is allowed to receive* the data a tool read, once the agent goes to send it — is an authorization question, and it lives with the rest of them: -see [Information leakage & egress](#/information-leakage). +see [Information leakage & egress](/docs/information-leakage). diff --git a/site/content/docs/continuity-interactions.mdx b/site/content/docs/continuity-interactions.mdx index 914a2af..f03b1d0 100644 --- a/site/content/docs/continuity-interactions.mdx +++ b/site/content/docs/continuity-interactions.mdx @@ -33,8 +33,8 @@ approvals: they come along for free. ## The same interaction, every surface An approval isn't Slack-specific. The same underlying interaction renders as a Block Kit card in -[Slack](#/slack), an inline prompt in the [terminal](#/terminal-chat), and a card in the -[browser](#/browser-chat) — asking the identical question and recording the identical decision. Live status and +[Slack](/docs/slack), an inline prompt in the [terminal](/docs/terminal-chat), and a card in the +[browser](/docs/browser-chat) — asking the identical question and recording the identical decision. Live status and progress — "thinking", a tool running, a turn's cost — surface the same way, and a silence watchdog catches a session that goes quiet rather than letting it hang. diff --git a/site/content/docs/crd-reference.mdx b/site/content/docs/crd-reference.mdx index e6944bb..ca79f24 100644 --- a/site/content/docs/crd-reference.mdx +++ b/site/content/docs/crd-reference.mdx @@ -14,36 +14,36 @@ full spec. | Kind | Scope | Summary | | --- | --- | --- | -| [AgentClass](#/crd-agentclass) | Namespaced | AgentClass is the reviewable template an agent is defined by: model, system | -| [AgentIdentity](#/crd-agentidentity) | Namespaced | AgentIdentity is the named credential set an agent acts as: reusable static | -| [AgentSession](#/crd-agentsession) | Namespaced | AgentSession is one running instance of an AgentClass: a conversation with | -| [AgentSessionGrants](#/crd-agentsessiongrants) | Namespaced | AgentSessionGrants is the per-AgentClass declaration of the (resourceType, | -| [AgentSettings](#/crd-agentsettings) | Namespaced | AgentSettings is the namespace tier of agent governance settings: limits | -| [AgentUI](#/crd-agentui) | Namespaced | AgentUI is a bundle-authored DECLARATION of an agent-defined view: a page | -| [ArtifactRender](#/crd-artifactrender) | Namespaced | ArtifactRender is one request to turn agent-supplied bytes into a | -| [Channel](#/crd-channel) | Namespaced | Channel binds one transport conversation -- a Slack channel, a scheduled | -| [ClusterAgentSettings](#/crd-clusteragentsettings) | Cluster | ClusterAgentSettings is the cluster-wide top tier of agent governance | -| [ClusterIdentityProvider](#/crd-clusteridentityprovider) | Cluster | ClusterIdentityProvider configures cluster-wide human login: which | -| [ClusterSkill](#/crd-clusterskill) | Cluster | ClusterSkill is a cluster-scoped Skill, visible to every namespace. A | -| [ClusterSkillSource](#/crd-clusterskillsource) | Cluster | ClusterSkillSource is a git repo that materializes cluster-scoped | -| [CredentialUpdateRequest](#/crd-credentialupdaterequest) | Namespaced | CredentialUpdateRequest is an agent's REQUEST that a human replace a | -| [MCPServer](#/crd-mcpserver) | Namespaced | MCPServer declares one MCP endpoint an agent may call: where it lives | -| [PublicEndpoint](#/crd-publicendpoint) | Cluster | PublicEndpoint is how this cluster is reached from the public Internet. | -| [RelationshipSource](#/crd-relationshipsource) | Namespaced | RelationshipSource declares one upstream directory (Slack first) to poll | -| [SessionHold](#/crd-sessionhold) | Namespaced | SessionHold parks an AgentSession for forensic review and gates its return to | -| [SessionUserIdentity](#/crd-sessionuseridentity) | Namespaced | SessionUserIdentity is one session's narrowing of a user's UserIdentity | -| [SidecarToolbox](#/crd-sidecartoolbox) | Namespaced | SidecarToolbox declares a user-supplied MCP server that runs as a sidecar | -| [Skill](#/crd-skill) | Namespaced | Skill is one agentskills.io skill: a SKILL.md (frontmatter + body) plus | -| [SkillSource](#/crd-skillsource) | Namespaced | SkillSource is a git repo that yields one or more Skills. | -| [SpiceDBBootstrap](#/crd-spicedbbootstrap) | Namespaced | SpiceDBBootstrap declaratively seeds SpiceDB state: an optional schema | -| [SpiceboxClass](#/crd-spiceboxclass) | Cluster | SpiceboxClass is the template a tool sandbox is cut from: image, resources, | -| [SpiceboxSession](#/crd-spiceboxsession) | Namespaced | SpiceboxSession is one live sandbox instantiated from a SpiceboxClass: the | -| [SpiceboxToolchain](#/crd-spiceboxtoolchain) | Cluster | SpiceboxToolchain is a language or tooling overlay a sandbox can mount -- a | -| [SpiceboxToolkit](#/crd-spiceboxtoolkit) | Cluster | SpiceboxToolkit is the machine-readable description of one CLI a sandbox | -| [SpiceboxToolspec](#/crd-spiceboxtoolspec) | Cluster | SpiceboxToolspec is one sandbox tool as the agent sees it: a SpiceboxToolkit | -| [SubagentRequest](#/crd-subagentrequest) | Namespaced | SubagentRequest is a runner's request to delegate a task to a child session. | -| [ToolCall](#/crd-toolcall) | Namespaced | ToolCall is one execution of one sandbox tool inside a SpiceboxSession: the | -| [UserIdentity](#/crd-useridentity) | Cluster | UserIdentity is a human's credential catalog: the credentials one person has | -| [Workshop](#/crd-workshop) | Namespaced | Workshop is one builder session's isolated build space: the durable record | -| [WorkshopProbe](#/crd-workshopprobe) | Namespaced | WorkshopProbe is the tool-discovery probe object for an agent-builder | -| [WorkspaceSource](#/crd-workspacesource) | Namespaced | WorkspaceSource declares a named, pluggable origin that per-session | +| [AgentClass](/docs/crd-agentclass) | Namespaced | AgentClass is the reviewable template an agent is defined by: model, system | +| [AgentIdentity](/docs/crd-agentidentity) | Namespaced | AgentIdentity is the named credential set an agent acts as: reusable static | +| [AgentSession](/docs/crd-agentsession) | Namespaced | AgentSession is one running instance of an AgentClass: a conversation with | +| [AgentSessionGrants](/docs/crd-agentsessiongrants) | Namespaced | AgentSessionGrants is the per-AgentClass declaration of the (resourceType, | +| [AgentSettings](/docs/crd-agentsettings) | Namespaced | AgentSettings is the namespace tier of agent governance settings: limits | +| [AgentUI](/docs/crd-agentui) | Namespaced | AgentUI is a bundle-authored DECLARATION of an agent-defined view: a page | +| [ArtifactRender](/docs/crd-artifactrender) | Namespaced | ArtifactRender is one request to turn agent-supplied bytes into a | +| [Channel](/docs/crd-channel) | Namespaced | Channel binds one transport conversation -- a Slack channel, a scheduled | +| [ClusterAgentSettings](/docs/crd-clusteragentsettings) | Cluster | ClusterAgentSettings is the cluster-wide top tier of agent governance | +| [ClusterIdentityProvider](/docs/crd-clusteridentityprovider) | Cluster | ClusterIdentityProvider configures cluster-wide human login: which | +| [ClusterSkill](/docs/crd-clusterskill) | Cluster | ClusterSkill is a cluster-scoped Skill, visible to every namespace. A | +| [ClusterSkillSource](/docs/crd-clusterskillsource) | Cluster | ClusterSkillSource is a git repo that materializes cluster-scoped | +| [CredentialUpdateRequest](/docs/crd-credentialupdaterequest) | Namespaced | CredentialUpdateRequest is an agent's REQUEST that a human replace a | +| [MCPServer](/docs/crd-mcpserver) | Namespaced | MCPServer declares one MCP endpoint an agent may call: where it lives | +| [PublicEndpoint](/docs/crd-publicendpoint) | Cluster | PublicEndpoint is how this cluster is reached from the public Internet. | +| [RelationshipSource](/docs/crd-relationshipsource) | Namespaced | RelationshipSource declares one upstream directory (Slack first) to poll | +| [SessionHold](/docs/crd-sessionhold) | Namespaced | SessionHold parks an AgentSession for forensic review and gates its return to | +| [SessionUserIdentity](/docs/crd-sessionuseridentity) | Namespaced | SessionUserIdentity is one session's narrowing of a user's UserIdentity | +| [SidecarToolbox](/docs/crd-sidecartoolbox) | Namespaced | SidecarToolbox declares a user-supplied MCP server that runs as a sidecar | +| [Skill](/docs/crd-skill) | Namespaced | Skill is one agentskills.io skill: a SKILL.md (frontmatter + body) plus | +| [SkillSource](/docs/crd-skillsource) | Namespaced | SkillSource is a git repo that yields one or more Skills. | +| [SpiceDBBootstrap](/docs/crd-spicedbbootstrap) | Namespaced | SpiceDBBootstrap declaratively seeds SpiceDB state: an optional schema | +| [SpiceboxClass](/docs/crd-spiceboxclass) | Cluster | SpiceboxClass is the template a tool sandbox is cut from: image, resources, | +| [SpiceboxSession](/docs/crd-spiceboxsession) | Namespaced | SpiceboxSession is one live sandbox instantiated from a SpiceboxClass: the | +| [SpiceboxToolchain](/docs/crd-spiceboxtoolchain) | Cluster | SpiceboxToolchain is a language or tooling overlay a sandbox can mount -- a | +| [SpiceboxToolkit](/docs/crd-spiceboxtoolkit) | Cluster | SpiceboxToolkit is the machine-readable description of one CLI a sandbox | +| [SpiceboxToolspec](/docs/crd-spiceboxtoolspec) | Cluster | SpiceboxToolspec is one sandbox tool as the agent sees it: a SpiceboxToolkit | +| [SubagentRequest](/docs/crd-subagentrequest) | Namespaced | SubagentRequest is a runner's request to delegate a task to a child session. | +| [ToolCall](/docs/crd-toolcall) | Namespaced | ToolCall is one execution of one sandbox tool inside a SpiceboxSession: the | +| [UserIdentity](/docs/crd-useridentity) | Cluster | UserIdentity is a human's credential catalog: the credentials one person has | +| [Workshop](/docs/crd-workshop) | Namespaced | Workshop is one builder session's isolated build space: the durable record | +| [WorkshopProbe](/docs/crd-workshopprobe) | Namespaced | WorkshopProbe is the tool-discovery probe object for an agent-builder | +| [WorkspaceSource](/docs/crd-workspacesource) | Namespaced | WorkspaceSource declares a named, pluggable origin that per-session | diff --git a/site/content/docs/facts-observations.mdx b/site/content/docs/facts-observations.mdx index d51c906..e1f7c6b 100644 --- a/site/content/docs/facts-observations.mdx +++ b/site/content/docs/facts-observations.mdx @@ -48,6 +48,6 @@ The ordering of observations doesn't change the outcome. A tool declares an **`observes`** rule — a small expression over the call's arguments and result — saying which resources it's about and which facts to record; after a successful call, the matching facts are written. On the -consuming side, an [authorization slot](#/plan-gating) can *require* a fact before it grants authority: "this +consuming side, an [authorization slot](/docs/plan-gating) can *require* a fact before it grants authority: "this repository may be operated on only once we've observed that it belongs to the approver." The agent's power is bound to what's actually known, not to what it claims. diff --git a/site/content/docs/giving-credentials.mdx b/site/content/docs/giving-credentials.mdx index ddb3f11..acff48f 100644 --- a/site/content/docs/giving-credentials.mdx +++ b/site/content/docs/giving-credentials.mdx @@ -45,5 +45,5 @@ the provider's docs and guides you through. The runner process never sees a credential's bytes: the broker resolves a descriptor into an injectable env var or header at the moment of use, scoped to the one tool that needs it. An agent holds references to what it - may use — not the workspace's whole set of secrets. See ASI03. + may use — not the workspace's whole set of secrets. See ASI03. diff --git a/site/content/docs/health-upgrades.mdx b/site/content/docs/health-upgrades.mdx index 56188e7..cbacfac 100644 --- a/site/content/docs/health-upgrades.mdx +++ b/site/content/docs/health-upgrades.mdx @@ -19,7 +19,7 @@ export const meta = { `oap check` verifies each component and reports `✓ / ⚠ / ✗` per one, so "is the platform healthy?" has a concrete answer rather than a guess. `oap check --watch` keeps re-checking every couple of seconds — useful -while an install or upgrade is converging. It's the same readiness the [install](#/install-overview) itself +while an install or upgrade is converging. It's the same readiness the [install](/docs/install-overview) itself waits on, available on demand. @@ -28,7 +28,7 @@ waits on, available on demand. An upgrade is a **re-install**: `oap install` applies the current platform manifests over the running one. The apply is idempotent — an unchanged component is left alone, and only what actually differs is updated — so -re-running it is safe. The resolved [cluster kind](#/install-cluster) is stamped onto the components at install, +re-running it is safe. The resolved [cluster kind](/docs/install-cluster) is stamped onto the components at install, which is why upgrading a pre-cluster-kind install means re-running `oap install` so the kind is set explicitly. diff --git a/site/content/docs/identity.mdx b/site/content/docs/identity.mdx index b87456b..74570f7 100644 --- a/site/content/docs/identity.mdx +++ b/site/content/docs/identity.mdx @@ -34,19 +34,19 @@ what everything at runtime reads. ## Go deeper -- **[Identity modes](#/identity-modes)** — the four modes, and static vs. resolved (`effectiveIdentityMode`). -- **[User-passthrough vs. agent-identity](#/passthrough-vs-agent)** — the two ways an agent authenticates, and +- **[Identity modes](/docs/identity-modes)** — the four modes, and static vs. resolved (`effectiveIdentityMode`). +- **[User-passthrough vs. agent-identity](/docs/passthrough-vs-agent)** — the two ways an agent authenticates, and the credential flow behind each. -- **[Choosing identity per session](#/choosing-identity)** — how `ask` and `dynamic` present the choice, and why +- **[Choosing identity per session](/docs/choosing-identity)** — how `ask` and `dynamic` present the choice, and why a passthrough session *pauses* rather than failing. -- **[Giving an agent credentials](#/giving-credentials)** — the `AgentIdentity` resource, credential types +- **[Giving an agent credentials](/docs/giving-credentials)** — the `AgentIdentity` resource, credential types (static, OAuth, federated, GitHub App), and the guided setup flows. -- **[Enterprise managed auth](#/enterprise-auth)** — deriving an upstream token from a user's enterprise IdP (ID-JAG federation). -- **[Managing your connections](#/managing-connections)** — the self-service portal to link, review, and revoke credentials. +- **[Enterprise managed auth](/docs/enterprise-auth)** — deriving an upstream token from a user's enterprise IdP (ID-JAG federation). +- **[Managing your connections](/docs/managing-connections)** — the self-service portal to link, review, and revoke credentials.
))} @@ -723,10 +653,8 @@ export function Landing() { Twenty-seven controls, in six areas.

- Each control is a property of the platform rather than an instruction - to the model, so no phrasing, context length or tool output changes - the answer. The security model documents every one and names the - package that implements it. + Each control is enforced by the platform, not requested of the model. + No prompt or tool output can change the answer.

    {AREAS.map((a) => ( @@ -750,11 +678,8 @@ export function Landing() { ))}

- Some controls are opt-in per agent class, including plan gating and - the information-leakage gate. Hostname-level egress is recorded on - session status but enforced only by a DNS-aware CNI. Network policy at - layers 3 and 4 is on by default, and the sandbox fails closed to - deny-all when a class says nothing. + Plan gating and leakage tracking are opt-in per agent class. Network + policy is on by default.

@@ -764,9 +689,8 @@ export function Landing() { Build an agent by talking to an agent.

- You don’t have to start with manifest files. Agent Builder is an - OAP agent whose job is to create other agents. Describe what you need - in plain language, then test the result live. + Agent Builder is an OAP agent that creates other agents. Describe what + you need in plain language, then test it live.

{BUILDER.map((b) => ( @@ -783,10 +707,8 @@ export function Landing() {

- oap init leaves Agent Builder off, because there is no - safe default for who may start it; you enable it by naming the people - or groups allowed to. It is subject to every control above: broad - freedom inside its workshop, and no authority outside it. + Off by default. You turn it on by naming who may use it, and it + follows every control above.

@@ -796,8 +718,7 @@ export function Landing() { The rest of the platform, included.

- These capabilities are common to agent platforms. OAP includes them, - and they are listed here so the set is complete. + The capabilities you’d expect from any agent platform.

{EVERYTHING.map((e) => ( @@ -812,15 +733,13 @@ export function Landing() { {/* ------------------------------------------------------- 06 owasp --- */}
- The OWASP Agentic Top 10, including where we fall short. + Coverage of the OWASP Agentic Top 10. Draft

- We keep a coverage map of OAP against the OWASP Top 10 for Agentic - Applications, and we publish the gaps column alongside the ratings. It - is an advisory self-assessment for orientation, not a certification, - an audit or a penetration test. It says a control exists and is wired - into the relevant path. It does not say the control is free of bugs. + How OAP maps to each risk in the OWASP Top 10 for Agentic + Applications, with the remaining gaps listed alongside. This is a + self-assessment, not a certification.

@@ -862,18 +781,14 @@ export function Landing() {

How we know the gates still hold

- Three test suites gate every merge. Scripted whole-session scenarios - assert on the tool result rather than the reply, golden - authorization traces turn any change in what was checked into a diff - someone has to read, and replays of captured real sessions show a - real model produced the interaction at least once. None of them pins - what a model will do next. + Every merge runs whole-session scenarios, golden authorization + traces and replays of real sessions. A permission check that stops + working fails the build.

- OWASP materials are referenced under CC BY-SA 4.0. This assessment is - not affiliated with or endorsed by OWASP. Coverage levels are a - qualitative judgement of breadth, not a score. + OWASP materials are used under CC BY-SA 4.0. Not affiliated with or + endorsed by OWASP.

@@ -882,11 +797,9 @@ export function Landing() {

Defense in depth

Every layer assumes the others may fail.

- To misuse an OAP agent, an attacker has to get past a plan a human - approved, a slot that cannot be reopened, a tool lens that cannot be - widened, an authorization check on every call, and a sandbox that - never held the credential in the first place. None of them is - sufficient alone. + To misuse an agent, an attacker has to get past an approved plan, a + locked slot, a tool spec, a check on every call, and a sandbox that + never held the credential.

); diff --git a/site/app/(landing)/landing.css b/site/app/(landing)/landing.css index a37adf4..c65104f 100644 --- a/site/app/(landing)/landing.css +++ b/site/app/(landing)/landing.css @@ -60,8 +60,11 @@ --lp-radius: 0.5rem; --lp-rail: 1180px; - --lp-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; - --lp-mono: 'SFMono-Regular', 'Menlo', 'Consolas', 'Liberation Mono', monospace; + --lp-sans: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, + sans-serif; + --lp-mono: + "SFMono-Regular", "Menlo", "Consolas", "Liberation Mono", monospace; color-scheme: dark; } @@ -80,7 +83,7 @@ * heading 18.43/17.69/15.37, fg 17.34/16.64/14.46, muted 6.16/5.91/5.14, * link 8.49/8.15/7.08, state 5.42/5.21/4.52, deny 7.02/6.74/5.85, * warn 5.63/5.40/4.69. Ink text on the primary button is 17.69:1. */ -:root[data-theme='light'] { +:root[data-theme="light"] { --lp-bg: #fcfcfc; /* site light ground */ --lp-card: #f8f7f8; /* stone-025 */ --lp-surface-2: #e9e7e9; /* stone-050 */ @@ -201,11 +204,11 @@ body:has(.lp) { } .lp-lede { - font-size: 16px; - line-height: 1.68; + font-size: 17px; + line-height: 1.65; color: var(--lp-muted); - max-width: 62ch; - margin: 0 0 8px; + max-width: 58ch; + margin: 0 0 14px; } .lp-lede strong { @@ -215,7 +218,7 @@ body:has(.lp) { .lp-note { font-family: var(--lp-mono); - font-size: 11.5px; + font-size: 12px; line-height: 1.6; color: var(--lp-muted); max-width: 78ch; @@ -374,12 +377,6 @@ body:has(.lp) { padding-top: 6px; } -/* Small, checkable, boring facts. They do more for an infra reader than an - adjective would, and each one is verifiable from the repository. */ -.lp-chips.lp-hero-chips { - margin-top: 16px; -} - /* The README's four trust questions, set as a quiet numbered list under the subhead. They frame the page; the sections below answer them. */ .lp-hero-qs-head { @@ -412,7 +409,7 @@ body:has(.lp) { } .lp-hero-qs li::before { - content: '0' counter(lp-q); + content: "0" counter(lp-q); font-family: var(--lp-mono); font-size: 11px; color: var(--lp-state); @@ -462,7 +459,7 @@ body:has(.lp) { color: var(--lp-term-fg); } -.lp-term-tab[aria-selected='true'] { +.lp-term-tab[aria-selected="true"] { color: var(--lp-term-fg); border-bottom-color: var(--lp-term-accent); } @@ -547,8 +544,8 @@ body:has(.lp) { } .lp-slab p { - font-size: 13.5px; - line-height: 1.62; + font-size: 14.5px; + line-height: 1.6; color: var(--lp-muted); margin: 0; } @@ -572,10 +569,10 @@ body:has(.lp) { } .lp-points li { - font-size: 13.5px; - line-height: 1.6; + font-size: 14.5px; + line-height: 1.55; color: var(--lp-muted); - margin: 0 0 9px; + margin: 0 0 12px; } .lp-points strong { @@ -730,31 +727,12 @@ body:has(.lp) { } .lp-prim p { - font-size: 14px; - line-height: 1.62; + font-size: 15px; + line-height: 1.6; color: var(--lp-muted); margin: 0 0 14px; } -.lp-chips { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin: 0; - padding: 0; - list-style: none; -} - -.lp-chips li { - font-family: var(--lp-mono); - font-size: 10.5px; - letter-spacing: 0.02em; - color: var(--lp-muted); - border: 1px solid var(--lp-line); - border-radius: 999px; - padding: 2px 9px; -} - /* ---------------------------------------------------------- decision box --- */ .lp-decide { @@ -853,7 +831,7 @@ body:has(.lp) { .lp-caption { font-family: var(--lp-mono); - font-size: 11px; + font-size: 11.5px; line-height: 1.6; color: var(--lp-muted); margin: 14px 0 0; @@ -1101,11 +1079,6 @@ body:has(.lp) { .lp-nav-links a:not(.lp-btn) { display: none; } - /* The toggle costs the nav 84px, which is what pushed the wordmark onto a - second line. The logomark alone still reads as the brand. */ - .lp-nav-brand span { - display: none; - } .lp-ghost { display: none; } From d839876ffd50e47547ddfdcf57efee1b50c947e2 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 11:45:36 -0700 Subject: [PATCH 28/55] Put the AuthZed credit and theme toggle in a footer bottom bar Split the landing footer into two rows: the mark and links on top, and a bottom bar with "Built by AuthZed, using SpiceDB" on the left and the theme toggle on the right. Raise the landing page's sans-serif text one pixel throughout; the monospace labels keep their 12px ceiling. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- site/app/(landing)/Landing.tsx | 26 ++++++++------ site/app/(landing)/landing.css | 65 ++++++++++++++++++++++------------ 2 files changed, 58 insertions(+), 33 deletions(-) diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index f62c80c..e8a5edd 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -815,17 +815,21 @@ export function Landing() { ); diff --git a/site/app/(landing)/landing.css b/site/app/(landing)/landing.css index c65104f..3651710 100644 --- a/site/app/(landing)/landing.css +++ b/site/app/(landing)/landing.css @@ -119,7 +119,7 @@ body:has(.lp) { background: var(--lp-bg); color: var(--lp-fg); font-family: var(--lp-sans); - font-size: 15px; + font-size: 16px; line-height: 1.6; -webkit-font-smoothing: antialiased; } @@ -204,7 +204,7 @@ body:has(.lp) { } .lp-lede { - font-size: 17px; + font-size: 18px; line-height: 1.65; color: var(--lp-muted); max-width: 58ch; @@ -251,7 +251,7 @@ body:has(.lp) { } .lp-nav-brand span { - font-size: 13px; + font-size: 14px; letter-spacing: 0.01em; } @@ -266,7 +266,7 @@ body:has(.lp) { .lp-nav-links a:not(.lp-btn) { color: var(--lp-link); text-decoration: none; - font-size: 13px; + font-size: 14px; } .lp-nav-links a:not(.lp-btn):hover { @@ -284,7 +284,7 @@ body:has(.lp) { height: 36px; padding: 0 16px; border-radius: 999px; - font-size: 13.5px; + font-size: 14.5px; font-weight: 500; text-decoration: none; border: 1px solid transparent; @@ -355,7 +355,7 @@ body:has(.lp) { } .lp-hero-sub { - font-size: 17px; + font-size: 18px; line-height: 1.62; color: var(--lp-muted); max-width: 60ch; @@ -380,7 +380,7 @@ body:has(.lp) { /* The README's four trust questions, set as a quiet numbered list under the subhead. They frame the page; the sections below answer them. */ .lp-hero-qs-head { - font-size: 14px; + font-size: 15px; color: var(--lp-fg); margin: 0 0 10px; } @@ -397,7 +397,7 @@ body:has(.lp) { counter-increment: lp-q; display: grid; grid-template-columns: 28px 1fr; - font-size: 14.5px; + font-size: 15.5px; line-height: 1.55; color: var(--lp-muted); padding: 7px 0; @@ -535,7 +535,7 @@ body:has(.lp) { } .lp-slab h3 { - font-size: 17px; + font-size: 18px; font-weight: 500; line-height: 1.25; letter-spacing: -0.012em; @@ -544,7 +544,7 @@ body:has(.lp) { } .lp-slab p { - font-size: 14.5px; + font-size: 15.5px; line-height: 1.6; color: var(--lp-muted); margin: 0; @@ -569,7 +569,7 @@ body:has(.lp) { } .lp-points li { - font-size: 14.5px; + font-size: 15.5px; line-height: 1.55; color: var(--lp-muted); margin: 0 0 12px; @@ -624,7 +624,7 @@ body:has(.lp) { .lp-compare { width: 100%; border-collapse: collapse; - font-size: 14px; + font-size: 15px; background: var(--lp-card); } @@ -705,7 +705,7 @@ body:has(.lp) { } .lp-prim h3 { - font-size: 19px; + font-size: 20px; font-weight: 500; line-height: 1.2; letter-spacing: -0.015em; @@ -727,7 +727,7 @@ body:has(.lp) { } .lp-prim p { - font-size: 15px; + font-size: 16px; line-height: 1.6; color: var(--lp-muted); margin: 0 0 14px; @@ -857,7 +857,7 @@ body:has(.lp) { } .lp-evidence p:last-child { - font-size: 14px; + font-size: 15px; line-height: 1.65; color: var(--lp-muted); max-width: 88ch; @@ -874,7 +874,7 @@ body:has(.lp) { .lp-owasp { width: 100%; border-collapse: collapse; - font-size: 13.5px; + font-size: 14.5px; } .lp-owasp caption { @@ -943,7 +943,7 @@ body:has(.lp) { .lp-owasp td.lp-gap { color: var(--lp-muted); - font-size: 13px; + font-size: 14px; } .lp-cov { @@ -1016,13 +1016,34 @@ body:has(.lp) { .lp-footer { border-top: 1px solid var(--lp-line); - padding: 26px 40px 34px; + padding: 26px 40px 28px; + font-size: 13.5px; + color: var(--lp-muted); +} + +.lp-footer-top { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; - font-size: 12.5px; - color: var(--lp-muted); +} + +/* Credit on the left, the theme toggle on the right: the conventional home + for a site-wide preference most visitors never change, since the default + follows the system. */ +.lp-footer-bottom { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px 18px; + margin-top: 22px; + padding-top: 18px; + border-top: 1px solid var(--lp-line-soft); +} + +.lp-footer-credit { + margin: 0; } .lp-footer a { @@ -1030,7 +1051,7 @@ body:has(.lp) { text-decoration: none; } -.lp-footer span a { +.lp-footer-credit a { color: var(--lp-fg); } @@ -1088,7 +1109,7 @@ body:has(.lp) { .lp-compare th, .lp-compare td { padding: 10px 12px; - font-size: 13px; + font-size: 14px; } } From 834fa3afb43f5871dac3b5e3a4644c9ba3da760b Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 11:47:24 -0700 Subject: [PATCH 29/55] Drop the decorative section numerals from the landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oversized "01"–"06" behind each section numbered chapters that no longer form a sequence, and competed with the numbering that does carry meaning (the four hero questions, the six primitives). Each section is still marked by its kicker and the rule above it. The section clipping that existed only to contain the numerals goes with them. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- site/app/(landing)/Landing.tsx | 17 ++++++----------- site/app/(landing)/landing.css | 26 -------------------------- 2 files changed, 6 insertions(+), 37 deletions(-) diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index e8a5edd..fcabf2c 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -431,19 +431,14 @@ function InstallTerminal() { } function SectionHead({ - n, kicker, children, }: { - n: string; kicker: string; children: ReactNode; }) { return ( <> -

{kicker}

{children}

@@ -511,7 +506,7 @@ export function Landing() { {/* ---------------------------------------------------- 01 problem --- */}
- + A credential is almost always broader than the task.

@@ -625,7 +620,7 @@ export function Landing() { {/* ------------------------------------------------- 02 primitives --- */}

- + Six concerns every agent has to solve.

@@ -649,7 +644,7 @@ export function Landing() { {/* ---------------------------------------------------- 03 secure --- */}

- + Twenty-seven controls, in six areas.

@@ -685,7 +680,7 @@ export function Landing() { {/* --------------------------------------------- 04 agent builder --- */}

- + Build an agent by talking to an agent.

@@ -714,7 +709,7 @@ export function Landing() { {/* -------------------------------------------- 05 everything else --- */}

- + The rest of the platform, included.

@@ -732,7 +727,7 @@ export function Landing() { {/* ------------------------------------------------------- 06 owasp --- */}

- + Coverage of the OWASP Agentic Top 10. Draft diff --git a/site/app/(landing)/landing.css b/site/app/(landing)/landing.css index 3651710..1b44e52 100644 --- a/site/app/(landing)/landing.css +++ b/site/app/(landing)/landing.css @@ -54,10 +54,6 @@ --lp-term-accent: #72b1ad; --lp-term-line: #2a2231; - /* The chapter numeral needs more ink on paper than on a dark ground to sit at - the same perceived weight. */ - --lp-ghost-opacity: 0.045; - --lp-radius: 0.5rem; --lp-rail: 1180px; --lp-sans: @@ -101,8 +97,6 @@ --lp-deny: #9e2e3f; /* red-600 hue, darkened */ --lp-warn: #8b5a41; /* sand-600 hue, darkened */ - --lp-ghost-opacity: 0.08; - color-scheme: light; } @@ -156,23 +150,6 @@ body:has(.lp) { .lp-section { border-top: 1px solid var(--lp-line); padding: 76px 40px; - position: relative; - overflow: hidden; -} - -/* The chapter numeral, set huge and nearly invisible in the section's corner. - Decorative only, so it is hidden from assistive tech. */ -.lp-ghost { - position: absolute; - top: -0.22em; - right: 16px; - font-family: var(--lp-mono); - font-size: clamp(150px, 20vw, 300px); - line-height: 1; - color: var(--lp-state); - opacity: var(--lp-ghost-opacity); - pointer-events: none; - user-select: none; } .lp-kicker { @@ -1100,9 +1077,6 @@ body:has(.lp) { .lp-nav-links a:not(.lp-btn) { display: none; } - .lp-ghost { - display: none; - } .lp-owasp td.lp-gap { display: none; } From 4274185706042a42ab8ae18d2ae4c7306151b9c1 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 11:58:03 -0700 Subject: [PATCH 30/55] Fall back to navigator.platform when the platform hint is empty Chromium can report navigator.userAgentData.platform as "" (some privacy configurations, and after device emulation). The `??` fallback kept the empty string, so a Mac was treated as non-Apple: the hint read "Ctrl K" and Cmd+K did nothing. platformName now lets an empty hint fall through, with table tests for each case. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- site/components/Search.tsx | 3 ++- site/lib/shortcut.test.ts | 12 ++++++++++++ site/lib/shortcut.ts | 15 ++++++++++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/site/components/Search.tsx b/site/components/Search.tsx index c70dc4c..47e8aba 100644 --- a/site/components/Search.tsx +++ b/site/components/Search.tsx @@ -10,6 +10,7 @@ import { import { isApplePlatform, isSearchShortcut, + platformName, searchShortcutLabel, } from "@/lib/shortcut"; import { @@ -42,7 +43,7 @@ export function Search() { userAgentData?: { platform?: string }; }; const isApple = isApplePlatform( - nav.userAgentData?.platform ?? nav.platform ?? "", + platformName(nav.userAgentData?.platform, nav.platform), ); setApple(isApple); const onKey = (e: KeyboardEvent) => { diff --git a/site/lib/shortcut.test.ts b/site/lib/shortcut.test.ts index 2174d57..14a688b 100644 --- a/site/lib/shortcut.test.ts +++ b/site/lib/shortcut.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { isApplePlatform, isSearchShortcut, + platformName, searchShortcutLabel, } from "./shortcut"; @@ -19,6 +20,17 @@ const key = ( ...mods, }); +describe("platformName", () => { + it.each([ + ["client hint wins when present", "macOS", "MacIntel", "macOS"], + ["empty hint falls back to navigator.platform", "", "MacIntel", "MacIntel"], + ["missing hint falls back", undefined, "Win32", "Win32"], + ["nothing known yields empty", undefined, undefined, ""], + ])("%s", (_name, hint, legacy, want) => + expect(platformName(hint, legacy)).toBe(want), + ); +}); + describe("isApplePlatform", () => { it.each([ ["macOS", true], diff --git a/site/lib/shortcut.ts b/site/lib/shortcut.ts index e0dfa39..d2796b0 100644 --- a/site/lib/shortcut.ts +++ b/site/lib/shortcut.ts @@ -11,7 +11,20 @@ export interface ShortcutEvent { shiftKey: boolean; } -/** `platform` is navigator.userAgentData?.platform or navigator.platform. */ +/** + * Picks the platform string to test: the User-Agent Client Hints value when it + * is non-empty, else the legacy `navigator.platform`. Chromium reports the hint + * as "" in some configurations (and after device emulation), so an empty hint + * must fall through rather than win. + */ +export function platformName( + hint: string | undefined, + legacy: string | undefined, +): string { + return hint || legacy || ""; +} + +/** `platform` is the result of platformName. */ export function isApplePlatform(platform: string): boolean { return /mac|iphone|ipad|ipod/i.test(platform); } From 28bb8af77721ff88f9d598faa6bf16be10e3289a Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 11:58:03 -0700 Subject: [PATCH 31/55] Share one site footer across the landing page and docs; hero wordmark - SiteFooter (components/) replaces the landing-only footer and now sits under every docs guide too: links on top, the AuthZed credit bottom left, the theme toggle bottom right. It styles itself from --sf-* variables that .lp and .doc-app map, the same pattern as the toggle. The docs sidebar no longer carries a toggle. - The nav shows the logomark alone; the hero shows the full wordmark, sized by width (400px, capped at the column). The wordmark's per-theme ink swap moves out of docs.css into components/wordmark.css so the landing page gets it too. - Plainer claims: "The AI never decides what it's allowed to do. OAP checks every action before it runs." Building from source is framed as every image coming from code you can review. - Larger small text: monospace eyebrows, labels, captions and notes move up half a pixel to a pixel. Card eyebrows had been rendering at body size because `.lp-prim p` and `.lp-slab p` outranked them. - On phones, the OWASP table now hides the Known gap header along with its column, and its caption wraps instead of clipping. - OapMark moves to components/ (brand README updated). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- docs/assets/brand/README.md | 2 +- site/app/(landing)/Landing.tsx | 40 ++--- site/app/(landing)/landing.css | 140 ++++++------------ site/app/(landing)/page.tsx | 2 +- site/app/docs/docs.css | 33 ++--- site/app/docs/layout.tsx | 6 +- .../{app/(landing) => components}/OapMark.tsx | 0 site/components/SiteFooter.tsx | 37 +++++ site/components/Wordmark.tsx | 1 + site/components/site-footer.css | 70 +++++++++ site/components/wordmark.css | 16 ++ 11 files changed, 208 insertions(+), 139 deletions(-) rename site/{app/(landing) => components}/OapMark.tsx (100%) create mode 100644 site/components/SiteFooter.tsx create mode 100644 site/components/site-footer.css create mode 100644 site/components/wordmark.css diff --git a/docs/assets/brand/README.md b/docs/assets/brand/README.md index 9b2ee3a..d693bdf 100644 --- a/docs/assets/brand/README.md +++ b/docs/assets/brand/README.md @@ -34,7 +34,7 @@ the inlined copies too: | `pkg/platform/identityd/handlers_password.go` | The icon on the sign-in page | | `cmd/oap/internal/desktop/setupui/static/index.html` | The logomark in the desktop setup header, plus the icon | | `cmd/oap/internal/desktop/menubaricons/render/tunnel.go` | Not a copy: the menu-bar icons are drawn in code. `menuMarkCutout` carries the menu icon's cut-out; regenerate the PNGs with `mage desktop:icons`. | -| `site/app/(landing)/OapMark.tsx` | `OapMark`, the logomark inlined for the landing page so it inherits `currentColor` and needs no light/dark asset pair. | +| `site/components/OapMark.tsx` | `OapMark`, the logomark inlined for the landing page and site footer so it inherits `currentColor` and needs no light/dark asset pair. | The repo README and the docs + landing site (`site/`: the `Wordmark` component and the root layout icons) reference the files here directly. diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index fcabf2c..958594f 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -6,8 +6,9 @@ import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from "react"; -import { OapMark } from "./OapMark"; -import { ThemeToggle } from "@/components/ThemeToggle"; +import { OapMark } from "@/components/OapMark"; +import { SiteFooter } from "@/components/SiteFooter"; +import { Wordmark } from "@/components/Wordmark"; import { OWASP } from "./owasp"; /* Every outbound URL the page needs that does not yet exist. Kept in one object @@ -15,7 +16,6 @@ import { OWASP } from "./owasp"; * a real guide on this site and is written inline. */ const TODO = { repo: "https://example.invalid/PLACEHOLDER-repo", - discuss: "https://example.invalid/PLACEHOLDER-community", owaspFull: "https://example.invalid/PLACEHOLDER-owasp-coverage", }; @@ -453,7 +453,6 @@ export function Landing() {
- + ); } diff --git a/site/app/(landing)/landing.css b/site/app/(landing)/landing.css index 1b44e52..02d797c 100644 --- a/site/app/(landing)/landing.css +++ b/site/app/(landing)/landing.css @@ -11,8 +11,8 @@ * - Surfaces are pinned on contrast against the surface beneath, not on an * alpha modifier: on a dark ground alpha moves a color toward the * background, so `bg/50` lands ~1.06:1 away from it, which is nothing. - * - Monospace is quarantined to <=12px labels and to code. It never sets body - * copy, so it reads unambiguously as "system label". + * - Monospace is quarantined to small (<=13px) labels, notes and code. It + * never sets body copy, so it reads unambiguously as "system label". * * Structure is a ruled frame, not floating cards: the page body sits inside a * drawn rectangle and every section adds a top rule. Grid rules are produced by @@ -145,6 +145,14 @@ body:has(.lp) { --tt-fg-active: var(--lp-heading); --tt-bg-active: var(--lp-surface-2); --tt-ring: var(--lp-link); + + /* Shared footer (components/site-footer.css). */ + --sf-line: var(--lp-line); + --sf-line-soft: var(--lp-line-soft); + --sf-muted: var(--lp-muted); + --sf-link: var(--lp-link); + --sf-strong: var(--lp-fg); + --sf-pad-x: 40px; } .lp-section { @@ -154,7 +162,7 @@ body:has(.lp) { .lp-kicker { font-family: var(--lp-mono); - font-size: 10px; + font-size: 12px; line-height: 16px; letter-spacing: 0.09em; text-transform: uppercase; @@ -195,7 +203,7 @@ body:has(.lp) { .lp-note { font-family: var(--lp-mono); - font-size: 12px; + font-size: 12.5px; line-height: 1.6; color: var(--lp-muted); max-width: 78ch; @@ -223,15 +231,10 @@ body:has(.lp) { } .lp-nav-brand svg { - height: 17px; + height: 20px; width: auto; } -.lp-nav-brand span { - font-size: 14px; - letter-spacing: 0.01em; -} - .lp-nav-links { display: flex; align-items: center; @@ -309,12 +312,13 @@ body:has(.lp) { } } +/* The full wordmark names the project once, in the hero; the nav carries only + the logomark. Display is left to components/wordmark.css, which shows one + ink per theme. */ .lp-hero-mark { - height: 30px; - width: auto; - color: var(--lp-heading); - display: block; - margin-bottom: 30px; + width: min(400px, 100%); + height: auto; + margin-bottom: 36px; } .lp-hero h1 { @@ -388,7 +392,7 @@ body:has(.lp) { .lp-hero-qs li::before { content: "0" counter(lp-q); font-family: var(--lp-mono); - font-size: 11px; + font-size: 12px; color: var(--lp-state); padding-top: 3px; } @@ -421,7 +425,7 @@ body:has(.lp) { itself stays neutral. */ .lp-term-tab { font-family: var(--lp-mono); - font-size: 10.5px; + font-size: 11.5px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--lp-term-muted); @@ -448,7 +452,7 @@ body:has(.lp) { .lp-copy-btn { font-family: var(--lp-mono); - font-size: 10.5px; + font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--lp-term-muted); @@ -468,7 +472,7 @@ body:has(.lp) { margin: 0; padding: 14px 16px; font-family: var(--lp-mono); - font-size: 12.5px; + font-size: 13px; line-height: 1.85; color: var(--lp-term-fg); overflow-x: auto; @@ -520,7 +524,7 @@ body:has(.lp) { margin: 0 0 10px; } -.lp-slab p { +.lp-slab p:not(.lp-prim-eyebrow) { font-size: 15.5px; line-height: 1.6; color: var(--lp-muted); @@ -570,7 +574,7 @@ body:has(.lp) { flex-wrap: wrap; gap: 6px 16px; font-family: var(--lp-mono); - font-size: 11px; + font-size: 12px; letter-spacing: 0.02em; } @@ -619,7 +623,7 @@ body:has(.lp) { .lp-compare thead th { font-family: var(--lp-mono); - font-size: 10.5px; + font-size: 11.5px; font-weight: 400; letter-spacing: 0.08em; text-transform: uppercase; @@ -674,7 +678,7 @@ body:has(.lp) { .lp-prim-eyebrow { font-family: var(--lp-mono); - font-size: 10px; + font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--lp-state); @@ -703,7 +707,7 @@ body:has(.lp) { text-decoration-color: var(--lp-line); } -.lp-prim p { +.lp-prim p:not(.lp-prim-eyebrow) { font-size: 16px; line-height: 1.6; color: var(--lp-muted); @@ -738,7 +742,7 @@ body:has(.lp) { .lp-panel-head span { font-family: var(--lp-mono); - font-size: 10.5px; + font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--lp-muted); @@ -753,7 +757,7 @@ body:has(.lp) { width: 100%; border-collapse: collapse; font-family: var(--lp-mono); - font-size: 12px; + font-size: 12.5px; line-height: 1.55; } @@ -776,7 +780,7 @@ body:has(.lp) { .lp-fields td small { display: block; - font-size: 11px; + font-size: 12px; color: var(--lp-muted); word-break: normal; } @@ -795,7 +799,7 @@ body:has(.lp) { .lp-trace { margin: 0; font-family: var(--lp-mono); - font-size: 11.5px; + font-size: 12px; line-height: 1.95; color: var(--lp-fg); overflow-x: auto; @@ -808,7 +812,7 @@ body:has(.lp) { .lp-caption { font-family: var(--lp-mono); - font-size: 11.5px; + font-size: 12px; line-height: 1.6; color: var(--lp-muted); margin: 14px 0 0; @@ -818,7 +822,7 @@ body:has(.lp) { .lp-col-head { font-family: var(--lp-mono); - font-size: 10.5px; + font-size: 12px; letter-spacing: 0.09em; text-transform: uppercase; color: var(--lp-state); @@ -858,7 +862,7 @@ body:has(.lp) { caption-side: top; text-align: left; font-family: var(--lp-mono); - font-size: 10.5px; + font-size: 11.5px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--lp-muted); @@ -875,7 +879,7 @@ body:has(.lp) { .lp-owasp thead th { font-family: var(--lp-mono); - font-size: 10.5px; + font-size: 11.5px; font-weight: 400; letter-spacing: 0.08em; text-transform: uppercase; @@ -885,7 +889,7 @@ body:has(.lp) { .lp-owasp td.lp-id { font-family: var(--lp-mono); - font-size: 12px; + font-size: 12.5px; white-space: nowrap; width: 1%; } @@ -926,7 +930,7 @@ body:has(.lp) { .lp-cov { display: inline-block; font-family: var(--lp-mono); - font-size: 10.5px; + font-size: 11.5px; letter-spacing: 0.04em; text-transform: uppercase; padding: 2px 9px; @@ -957,7 +961,7 @@ body:has(.lp) { .lp-draft { display: inline-block; font-family: var(--lp-mono); - font-size: 10px; + font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--lp-warn); @@ -991,59 +995,6 @@ body:has(.lp) { margin: 28px 0 0; } -.lp-footer { - border-top: 1px solid var(--lp-line); - padding: 26px 40px 28px; - font-size: 13.5px; - color: var(--lp-muted); -} - -.lp-footer-top { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 18px; -} - -/* Credit on the left, the theme toggle on the right: the conventional home - for a site-wide preference most visitors never change, since the default - follows the system. */ -.lp-footer-bottom { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px 18px; - margin-top: 22px; - padding-top: 18px; - border-top: 1px solid var(--lp-line-soft); -} - -.lp-footer-credit { - margin: 0; -} - -.lp-footer a { - color: var(--lp-link); - text-decoration: none; -} - -.lp-footer-credit a { - color: var(--lp-fg); -} - -.lp-footer a:hover { - text-decoration: underline; - text-underline-offset: 3px; -} - -.lp-footer-mark { - height: 13px; - width: auto; - color: var(--lp-muted); - margin-right: auto; -} - /* ------------------------------------------------------------ responsive --- */ @media (max-width: 1000px) { @@ -1060,8 +1011,10 @@ body:has(.lp) { .lp-section, .lp-hero, .lp-close, - .lp-nav, - .lp-footer { + .lp { + --sf-pad-x: 16px; + } + .lp-nav { padding-left: 16px; padding-right: 16px; } @@ -1077,9 +1030,14 @@ body:has(.lp) { .lp-nav-links a:not(.lp-btn) { display: none; } - .lp-owasp td.lp-gap { + .lp-owasp td.lp-gap, + .lp-owasp thead th:last-child { display: none; } + .lp-owasp caption { + white-space: normal; + line-height: 1.6; + } .lp-compare th, .lp-compare td { padding: 10px 12px; diff --git a/site/app/(landing)/page.tsx b/site/app/(landing)/page.tsx index eb43505..637b56c 100644 --- a/site/app/(landing)/page.tsx +++ b/site/app/(landing)/page.tsx @@ -9,7 +9,7 @@ export const metadata: Metadata = { openGraph: { title: "Open Agent Primitives", description: - "Building blocks for constructing and running enterprise AI agents in your own cluster, with every control outside the model.", + "Building blocks for running enterprise AI agents in your own cluster. The AI never decides what it's allowed to do: OAP checks every action before it runs.", type: "website", }, }; diff --git a/site/app/docs/docs.css b/site/app/docs/docs.css index b87aee4..82bf06d 100644 --- a/site/app/docs/docs.css +++ b/site/app/docs/docs.css @@ -86,6 +86,13 @@ body:has(.doc-app) { --tt-fg-active: var(--doc-heading); --tt-bg-active: var(--doc-panel); --tt-ring: var(--doc-accent); + /* Shared footer (components/site-footer.css). */ + --sf-line: var(--doc-border); + --sf-line-soft: var(--doc-border); + --sf-muted: var(--doc-muted); + --sf-link: var(--doc-text); + --sf-strong: var(--doc-heading); + --sf-pad-x: 0px; } /* Sidebar nav */ @@ -112,21 +119,6 @@ body:has(.doc-app) { width: 168px; height: auto; } -/* "light" and "dark" name the INK, so the light-ink mark is the one for the - dark theme. Only one of the pair is ever shown, and which one is decided by - the data-theme attribute next-themes sets before paint — no React state, so - the mark is right on the first frame. */ -.wordmark--on-light { - display: none; -} -:root[data-theme='light'] .wordmark--on-dark { - display: none; -} -:root[data-theme='light'] .wordmark--on-light { - display: block; -} -/* The wordmark is 168px inside a 232px content column, so the toggle shares - the "docs" line beneath it rather than sitting alongside the mark. */ .doc-brand-meta { display: flex; align-items: center; @@ -236,8 +228,15 @@ body:has(.doc-app) { flex: 1; min-width: 0; display: flex; - justify-content: center; - padding: 56px 40px 120px; + flex-direction: column; + align-items: center; + padding: 56px 40px 0; +} +/* The shared site footer, under the article at the same measure. */ +.doc-footer { + width: 100%; + max-width: var(--doc-max); + margin-top: 120px; } .doc-article { width: 100%; diff --git a/site/app/docs/layout.tsx b/site/app/docs/layout.tsx index 6a99ce7..8dc86de 100644 --- a/site/app/docs/layout.tsx +++ b/site/app/docs/layout.tsx @@ -3,7 +3,7 @@ import { allGuides } from "@/lib/guides"; import { buildNav } from "@/lib/nav"; import { NavLink } from "@/components/NavLink"; import { Search } from "@/components/Search"; -import { ThemeToggle } from "@/components/ThemeToggle"; +import { SiteFooter } from "@/components/SiteFooter"; import { Wordmark } from "@/components/Wordmark"; import "./docs.css"; @@ -27,7 +27,6 @@ export default async function DocsLayout({
docs -
@@ -58,6 +57,9 @@ export default async function DocsLayout({
{children}
+
+ +
); diff --git a/site/app/(landing)/OapMark.tsx b/site/components/OapMark.tsx similarity index 100% rename from site/app/(landing)/OapMark.tsx rename to site/components/OapMark.tsx diff --git a/site/components/SiteFooter.tsx b/site/components/SiteFooter.tsx new file mode 100644 index 0000000..93e54e8 --- /dev/null +++ b/site/components/SiteFooter.tsx @@ -0,0 +1,37 @@ +import { OapMark } from "./OapMark"; +import { ThemeToggle } from "./ThemeToggle"; +import "./site-footer.css"; + +// Placeholder until the project's community space exists. +const COMMUNITY = "https://example.invalid/PLACEHOLDER-community"; + +/* The footer shared by the landing page and the docs. Plain throughout: + * the two sections load different stylesheets, so crossing between them is a + * full page load either way. */ +export function SiteFooter() { + return ( + + ); +} diff --git a/site/components/Wordmark.tsx b/site/components/Wordmark.tsx index 683170a..bb9471d 100644 --- a/site/components/Wordmark.tsx +++ b/site/components/Wordmark.tsx @@ -4,6 +4,7 @@ // frame without waiting for React. import lightInk from "../../docs/assets/brand/oap-wordmark-light.svg"; import darkInk from "../../docs/assets/brand/oap-wordmark-dark.svg"; +import "./wordmark.css"; export function Wordmark({ className = "" }: { className?: string }) { return ( diff --git a/site/components/site-footer.css b/site/components/site-footer.css new file mode 100644 index 0000000..810266e --- /dev/null +++ b/site/components/site-footer.css @@ -0,0 +1,70 @@ +/* The footer shared by the landing page and the docs. Like theme-toggle.css it + * styles itself from its own variables, which .lp and .doc-app each map onto + * their palette. Fallbacks are the dark landing values. */ + +.site-footer { + border-top: 1px solid var(--sf-line, #3f3644); + padding: 26px var(--sf-pad-x, 40px) 28px; + font-size: 13.5px; + color: var(--sf-muted, #9a94a0); +} + +.site-footer-top { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 18px; +} + +/* Credit on the left, the theme toggle on the right: the conventional home + for a site-wide preference most visitors never change, since the default + follows the system. */ +.site-footer-bottom { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px 18px; + margin-top: 22px; + padding-top: 18px; + border-top: 1px solid var(--sf-line-soft, #2a2231); +} + +.site-footer-credit { + margin: 0; +} + +/* Keeps the toggle in the right-hand corner even when a narrow column wraps it + onto its own line. */ +.site-footer-bottom .theme-toggle { + margin-left: auto; +} + +.site-footer a { + color: var(--sf-link, #c7c4ca); + text-decoration: none; +} + +.site-footer-credit a { + color: var(--sf-strong, #e9e7e9); +} + +.site-footer a:hover { + text-decoration: underline; + text-underline-offset: 3px; +} + +.site-footer-home { + margin-right: auto; + display: inline-flex; +} + +.site-footer a.site-footer-home:hover { + text-decoration: none; +} + +.site-footer-mark { + height: 13px; + width: auto; + color: var(--sf-muted, #9a94a0); +} diff --git a/site/components/wordmark.css b/site/components/wordmark.css new file mode 100644 index 0000000..cd183ff --- /dev/null +++ b/site/components/wordmark.css @@ -0,0 +1,16 @@ +/* "light" and "dark" name the INK, so the light-ink mark is the one for the + dark theme. Only one of the pair is ever shown, and which one is decided by + the data-theme attribute next-themes sets before paint — no React state, so + the mark is right on the first frame. + The `img` in each selector outranks a page's own single-class sizing rule + (`.doc-brand-wordmark`, `.lp-hero-mark`), which may set `display` and loads + in no guaranteed order relative to this sheet. */ +img.wordmark--on-light { + display: none; +} +:root[data-theme='light'] img.wordmark--on-dark { + display: none; +} +:root[data-theme='light'] img.wordmark--on-light { + display: block; +} From c7dbe526e4c36457367e5b6c7238b2cf7034a625 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 12:03:33 -0700 Subject: [PATCH 32/55] Set the security-area card titles apart from their bullets The area titles (18px/500) read almost the same as each bullet's bold lead-in (15.5px/500). They are now 22px with tighter tracking and a rule beneath, so each card reads heading first, then list. Card padding grows to match on desktop and tightens on phones. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- site/app/(landing)/Landing.tsx | 2 +- site/app/(landing)/landing.css | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index 958594f..4b54e8a 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -656,7 +656,7 @@ export function Landing() {
    {AREAS.map((a) => (
  1. -

    {a.title}

    +

    {a.title}

      {a.points.map(([lead, body]) => (
    • diff --git a/site/app/(landing)/landing.css b/site/app/(landing)/landing.css index 02d797c..6449fe8 100644 --- a/site/app/(landing)/landing.css +++ b/site/app/(landing)/landing.css @@ -541,6 +541,19 @@ body:has(.lp) { .lp-areas > li { display: flex; flex-direction: column; + padding: 26px 28px 24px; +} + +/* The area title has to read as the card's heading, a clear step above the + bold lead-in on each bullet: larger, tighter, and ruled off from the list. */ +.lp-slab .lp-areas-title { + font-size: 22px; + line-height: 1.2; + letter-spacing: -0.02em; + color: var(--lp-heading); + margin: 0 0 18px; + padding-bottom: 16px; + border-bottom: 1px solid var(--lp-line); } .lp-points { @@ -1014,6 +1027,9 @@ body:has(.lp) { .lp { --sf-pad-x: 16px; } + .lp-areas > li { + padding: 22px 20px 20px; + } .lp-nav { padding-left: 16px; padding-right: 16px; From 80e6b7a8311e10040708603532482c362861b1c5 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 12:10:06 -0700 Subject: [PATCH 33/55] Cut hedges and stacked reversals from the landing copy From an LLM-writing check of the rendered page: drop the "almost always" hedge from the problem heading, and rewrite four of the page's eight "X, not Y" reversals (the credential valet-key line, "not just what its task needs", "never the secret itself", "denied, never guessed"). The reversals that carry the point stay, as does the security intro. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- site/app/(landing)/Landing.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index 4b54e8a..874d42a 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -116,7 +116,7 @@ const PRIMITIVES = [ { verb: "Broker", title: "Identity & credentials", - body: "Credentials resolve at the moment of use. The agent gets a valet key, not the keyring.", + body: "Credentials resolve at the moment of use, and the agent never holds them.", href: "/docs/identity", }, { @@ -210,7 +210,7 @@ const AREAS: Area[] = [ ], [ "Secret scrubbing.", - "The model sees an opaque handle, never the secret itself.", + "The model sees an opaque handle in place of the value.", ], ], links: [ @@ -509,11 +509,11 @@ export function Landing() { {/* ---------------------------------------------------- 01 problem --- */}
      - A credential is almost always broader than the task. + Credentials reach further than the task needs.

      A token for one repository usually reaches every repository. An agent - inherits all of that access, not just what its task needs. + inherits all of it.

      A model can’t reliably tell instructions from data, so its @@ -589,8 +589,7 @@ export function Landing() {

- An argument that can’t be resolved is denied, never - guessed. + An argument that can’t be resolved is denied.

From 67d0c83f1c42dae9fdbb3c34f0cf966cce665923 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 12:11:56 -0700 Subject: [PATCH 34/55] Name the hero install tabs plainly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Desktop · macOS" and "Kubernetes · kind" read as four options split by dots. The tabs are now "macOS desktop" and "Local Kubernetes", the README's own names, set in their natural case so "macOS" survives. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- site/app/(landing)/Landing.tsx | 4 ++-- site/app/(landing)/landing.css | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index 874d42a..e6b402c 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -36,7 +36,7 @@ const INSTALL: { }[] = [ { id: "desktop", - label: "Desktop · macOS", + label: "macOS desktop", steps: [ { cmd: "mage desktop:all" }, { cmd: "open build/desktop/out/oap.app" }, @@ -45,7 +45,7 @@ const INSTALL: { }, { id: "kubernetes", - label: "Kubernetes · kind", + label: "Local Kubernetes", steps: [ { cmd: "mage build:oap" }, { cmd: "kind create cluster --name oap-dev" }, diff --git a/site/app/(landing)/landing.css b/site/app/(landing)/landing.css index 6449fe8..dbbc202 100644 --- a/site/app/(landing)/landing.css +++ b/site/app/(landing)/landing.css @@ -426,8 +426,7 @@ body:has(.lp) { .lp-term-tab { font-family: var(--lp-mono); font-size: 11.5px; - letter-spacing: 0.08em; - text-transform: uppercase; + letter-spacing: 0.02em; color: var(--lp-term-muted); background: transparent; border: 0; From 854b9850c0cc61f2fd2a2dd571af03f2ce0726fc Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 12:15:46 -0700 Subject: [PATCH 35/55] Resolve the hero questions and sharpen the problem heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Under the four trust questions, one line now closes the loop: "OAP answers each one in the platform, before the agent acts. See how →", linking to the comparison table (#compare), which answers them row by row. - The problem heading states the consequence rather than a mismatch: "Your agent can reach everything its credentials can." Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- site/app/(landing)/Landing.tsx | 8 ++++++-- site/app/(landing)/landing.css | 29 ++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index e6b402c..ab52f1c 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -488,6 +488,10 @@ export function Landing() {
  • {q}
  • ))} +

    + OAP answers each one in the platform, before the agent acts.{" "} + See how → +

    Get started @@ -509,7 +513,7 @@ export function Landing() { {/* ---------------------------------------------------- 01 problem --- */}
    - Credentials reach further than the task needs. + Your agent can reach everything its credentials can.

    A token for one repository usually reaches every repository. An agent @@ -527,7 +531,7 @@ export function Landing() {

    -
    +
    diff --git a/site/app/(landing)/landing.css b/site/app/(landing)/landing.css index dbbc202..9ee38ba 100644 --- a/site/app/(landing)/landing.css +++ b/site/app/(landing)/landing.css @@ -369,11 +369,38 @@ body:has(.lp) { .lp-hero-qs { counter-reset: lp-q; list-style: none; - margin: 0 0 30px; + margin: 0 0 14px; padding: 0; max-width: 60ch; } +/* Resolves the four questions: the answer is the comparison table in the next + section, which takes them row by row. */ +.lp-hero-qs-foot { + font-size: 15px; + line-height: 1.55; + color: var(--lp-fg); + max-width: 60ch; + margin: 0 0 30px; +} + +.lp-hero-qs-foot a { + color: var(--lp-heading); + font-weight: 500; + text-decoration: none; + border-bottom: 1px solid var(--lp-line); + white-space: nowrap; +} + +.lp-hero-qs-foot a:hover { + border-bottom-color: var(--lp-link); +} + +/* A jump to the table lands with breathing room above it. */ +#compare { + scroll-margin-top: 24px; +} + .lp-hero-qs li { counter-increment: lp-q; display: grid; From 6ae65b201c6e2a33c54808e6baf64d9205397fe2 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 12:32:36 -0700 Subject: [PATCH 36/55] Give the hero one CTA and the nav the only logo - The hero's single button is now "How it stays secure" (primary); the "Get started" button there is gone, since the nav already carries it. The line under the four questions stays, without its link. - The hero wordmark competed with the headline, so it goes; the nav logomark grows from 20px to 28px to carry the brand. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- site/app/(landing)/Landing.tsx | 12 +++--------- site/app/(landing)/landing.css | 32 +++----------------------------- 2 files changed, 6 insertions(+), 38 deletions(-) diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index ab52f1c..a3eab31 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -8,7 +8,6 @@ import { } from "react"; import { OapMark } from "@/components/OapMark"; import { SiteFooter } from "@/components/SiteFooter"; -import { Wordmark } from "@/components/Wordmark"; import { OWASP } from "./owasp"; /* Every outbound URL the page needs that does not yet exist. Kept in one object @@ -468,7 +467,6 @@ export function Landing() { {/* ------------------------------------------------------------ hero --- */}
    -

    A secure way to run enterprise AI agents.

    @@ -489,14 +487,10 @@ export function Landing() { ))}

    - OAP answers each one in the platform, before the agent acts.{" "} - See how → + OAP answers each one in the platform, before the agent acts.

    @@ -531,7 +525,7 @@ export function Landing() {

    -
    +
    diff --git a/site/app/(landing)/landing.css b/site/app/(landing)/landing.css index 9ee38ba..96fb565 100644 --- a/site/app/(landing)/landing.css +++ b/site/app/(landing)/landing.css @@ -231,7 +231,7 @@ body:has(.lp) { } .lp-nav-brand svg { - height: 20px; + height: 28px; width: auto; } @@ -312,15 +312,6 @@ body:has(.lp) { } } -/* The full wordmark names the project once, in the hero; the nav carries only - the logomark. Display is left to components/wordmark.css, which shows one - ink per theme. */ -.lp-hero-mark { - width: min(400px, 100%); - height: auto; - margin-bottom: 36px; -} - .lp-hero h1 { font-size: clamp(40px, 5.4vw, 66px); font-weight: 300; @@ -374,8 +365,8 @@ body:has(.lp) { max-width: 60ch; } -/* Resolves the four questions: the answer is the comparison table in the next - section, which takes them row by row. */ +/* Closes the four questions; the comparison table in the next section answers + them row by row. */ .lp-hero-qs-foot { font-size: 15px; line-height: 1.55; @@ -384,23 +375,6 @@ body:has(.lp) { margin: 0 0 30px; } -.lp-hero-qs-foot a { - color: var(--lp-heading); - font-weight: 500; - text-decoration: none; - border-bottom: 1px solid var(--lp-line); - white-space: nowrap; -} - -.lp-hero-qs-foot a:hover { - border-bottom-color: var(--lp-link); -} - -/* A jump to the table lands with breathing room above it. */ -#compare { - scroll-margin-top: 24px; -} - .lp-hero-qs li { counter-increment: lp-q; display: grid; From 0001ca08612aae04d73af5d2da948beaebc342ce Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 12:38:38 -0700 Subject: [PATCH 37/55] Use the full wordmark in the landing nav The nav now shows the wordmark (logomark plus "Open Agent Primitives") at 38px tall in place of the bare logomark, so the one logo on the page names the project. The brand set has only the stacked two-line wordmark; a single-line lockup would be a new asset. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- site/app/(landing)/Landing.tsx | 10 +++++++--- site/app/(landing)/landing.css | 6 ++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index a3eab31..55b39ff 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -6,7 +6,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from "react"; -import { OapMark } from "@/components/OapMark"; +import { Wordmark } from "@/components/Wordmark"; import { SiteFooter } from "@/components/SiteFooter"; import { OWASP } from "./owasp"; @@ -450,8 +450,12 @@ export function Landing() { return (
    - -
    -
    -
    - Before the call: anatomy of one check -
    -
    - - - - - - - - - - - - - - - - - - - -
    subject - user:alice@acme.example - the person who asked -
    permission - push - declared by the tool, not the model -
    resource - git_repo:acme/widget - derived from the call’s arguments -
    decision - DENIED - the call never reaches the tool -
    -

    - An argument that can’t be resolved is denied. -

    -
    -
    -
    -
    - Recorded, from a replayed session -
    -
    -
    -                {
    -                  "authz git_repo:https=3A//github=2Ecom/testorg/testreview#fetch "
    -                }
    -                denied
    -                {"\n"}
    -                {"authz git_repo:workspace#read  "}
    -                denied
    -                {"\n"}
    -                {"authz git_repo:workspace#write "}
    -                denied
    -              
    -

    - From a golden trace in the test suite. If a check ever stops - refusing, the change shows up in code review. -

    -
    -
    -
    {/* ------------------------------------------------- 02 primitives --- */} diff --git a/site/app/(landing)/landing.css b/site/app/(landing)/landing.css index d8610bb..e447da7 100644 --- a/site/app/(landing)/landing.css +++ b/site/app/(landing)/landing.css @@ -124,7 +124,6 @@ body:has(.lp) { track is forced open and the whole page scrolls sideways on a phone. Every such block already has its own `overflow-x: auto`. */ .lp-hero > *, -.lp-decide > *, .lp-slab > *, .lp-prims > * { min-width: 0; @@ -739,110 +738,6 @@ body:has(.lp) { margin: 0 0 14px; } -/* ---------------------------------------------------------- decision box --- */ - -.lp-decide { - display: grid; - grid-template-columns: 1.15fr 1fr; - gap: 1px; - background: var(--lp-line); - border: 1px solid var(--lp-line); - border-radius: var(--lp-radius); - overflow: hidden; - margin-top: 34px; -} - -.lp-decide > * { - background: var(--lp-card); - padding: 0; -} - -.lp-panel-head { - display: flex; - align-items: baseline; - gap: 10px; - padding: 12px 20px; - border-bottom: 1px solid var(--lp-line-soft); -} - -.lp-panel-head span { - font-family: var(--lp-mono); - font-size: 12px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--lp-muted); -} - -.lp-panel-body { - padding: 18px 20px 20px; -} - -/* A field table: label, value, and the provenance of the value. */ -.lp-fields { - width: 100%; - border-collapse: collapse; - font-family: var(--lp-mono); - font-size: 12.5px; - line-height: 1.55; -} - -.lp-fields th { - text-align: left; - font-weight: 400; - color: var(--lp-muted); - padding: 4px 14px 4px 0; - white-space: nowrap; - vertical-align: top; - width: 1%; -} - -.lp-fields td { - padding: 4px 0; - color: var(--lp-fg); - vertical-align: top; - word-break: break-all; -} - -.lp-fields td small { - display: block; - font-size: 12px; - color: var(--lp-muted); - word-break: normal; -} - -.lp-fields tr.lp-verdict th, -.lp-fields tr.lp-verdict td { - border-top: 1px solid var(--lp-line-soft); - padding-top: 12px; -} - -.lp-deny { - color: var(--lp-deny); - letter-spacing: 0.08em; -} - -.lp-trace { - margin: 0; - font-family: var(--lp-mono); - font-size: 12px; - line-height: 1.95; - color: var(--lp-fg); - overflow-x: auto; - white-space: pre; -} - -.lp-trace .d { - color: var(--lp-deny); -} - -.lp-caption { - font-family: var(--lp-mono); - font-size: 12px; - line-height: 1.6; - color: var(--lp-muted); - margin: 14px 0 0; -} - /* ------------------------------------------------------------ evidence --- */ .lp-col-head { @@ -933,8 +828,7 @@ body:has(.lp) { } /* Inline code in marketing prose: a quiet label, not a code block. */ -.lp-lede code, -.lp-caption code { +.lp-lede code { font-family: var(--lp-mono); font-size: 0.88em; color: var(--lp-fg); @@ -1027,9 +921,6 @@ body:has(.lp) { .lp-slab--3 { grid-template-columns: repeat(2, 1fr); } - .lp-decide { - grid-template-columns: 1fr; - } } @media (max-width: 720px) { From 7d83d84fbd1b7229fd2dd6acbd5ec5df298729d5 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 12:48:15 -0700 Subject: [PATCH 40/55] Say agents can be built from the primitives, not that all use them "Every agent is built from them" read as if each agent must use all six. The primitives lede now says any agent can be built from them, using only the ones it needs. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- site/app/(landing)/Landing.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index 778964b..554a743 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -559,8 +559,8 @@ export function Landing() { Six concerns every agent has to solve.

    - OAP ships a working implementation of each, and every agent is built - from them. + OAP ships a working implementation of each. Any agent can be built + from them, using only the ones it needs.

    {PRIMITIVES.map((p, i) => ( From cd1c67b555289cb69eb4beda846b880dd23cd966 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 12:50:46 -0700 Subject: [PATCH 41/55] Define a primitive before saying OAP implements each The primitives lede now runs definition first, then offering: "A primitive is a basic building block that agents rely on, whatever they do. OAP ships a working implementation of every one." Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- site/app/(landing)/Landing.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index 554a743..0883684 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -559,8 +559,8 @@ export function Landing() { Six concerns every agent has to solve.

    - OAP ships a working implementation of each. Any agent can be built - from them, using only the ones it needs. + A primitive is a basic building block that agents rely on, whatever + they do. OAP ships a working implementation of every one.

    {PRIMITIVES.map((p, i) => ( From c51a9fb856e51d429f808b048d5365b5155eacc1 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 12:52:24 -0700 Subject: [PATCH 42/55] Make the security-section intro more direct "The platform enforces every control. No prompt or tool output can affect enforcement." replaces the passive "enforced by the platform, not requested of the model" phrasing. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- site/app/(landing)/Landing.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index 0883684..e599237 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -584,8 +584,8 @@ export function Landing() { areas.

    - Each control is enforced by the platform, not requested of the model. - No prompt or tool output can change the answer. + The platform enforces every control. No prompt or tool output can + affect enforcement.

      {AREAS.map((a) => ( From f28b255fd6a7fd5602ae67a3251a52c35117d0b9 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 12:54:16 -0700 Subject: [PATCH 43/55] List the fuller set of channels on the landing page The "Everything else" channels card named five surfaces. It now adds scheduled runs (bento) and sub-agent conversations (the agent kind), and says transports are pluggable. "Sub-agent conversations" rather than "agent-to-agent" keeps it consistent with the OWASP ASI07 row. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- site/app/(landing)/Landing.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index e599237..69ae414 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -320,7 +320,7 @@ const EVERYTHING = [ }, { title: "Channels", - body: "Slack, browser, CLI, GitHub and signed webhooks.", + body: "Slack, the browser, the CLI and GitHub, plus signed webhooks, scheduled runs and sub-agent conversations. Transports are pluggable, so you can add your own.", }, { title: "Memory and knowledge graph", From 909055dd42f591ab925ea2595d10d67c6b9f6e5f Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 12:55:07 -0700 Subject: [PATCH 44/55] Point the landing page's GitHub and Source links at the repo Both used a placeholder; they now link to https://github.com/authzed/openagentprimitives. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- site/app/(landing)/Landing.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index 69ae414..81972ed 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -10,11 +10,12 @@ import { Wordmark } from "@/components/Wordmark"; import { SiteFooter } from "@/components/SiteFooter"; import { OWASP } from "./owasp"; +const REPO = "https://github.com/authzed/openagentprimitives"; + /* Every outbound URL the page needs that does not yet exist. Kept in one object * so filling them in is a single edit. Anything pointing at `/docs/` is * a real guide on this site and is written inline. */ const TODO = { - repo: "https://example.invalid/PLACEHOLDER-repo", owaspFull: "https://example.invalid/PLACEHOLDER-owasp-coverage", }; @@ -461,7 +462,7 @@ export function Landing() { Docs Security Agent Builder - GitHub + GitHub Get started @@ -741,7 +742,7 @@ export function Landing() { Read the docs - + Source
    From 6dc7d86219b6db79c1e8bac06c6cc66e230062d0 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 12:58:31 -0700 Subject: [PATCH 45/55] Fill the last placeholder links: Discord, and no full-assessment button - The footer's Community link (landing and every docs page) now goes to the AuthZed Discord, https://discord.gg/TUd4k5McMX, where authzed.com/discord redirects. - The OWASP section's "Full assessment" button is gone; "Read the coverage map" remains. No example.invalid URLs are left in the site. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LdnJCJHVx2mfpfoHi6pbqG --- site/app/(landing)/Landing.tsx | 10 ---------- site/components/SiteFooter.tsx | 4 ++-- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index 81972ed..d60a052 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -12,13 +12,6 @@ import { OWASP } from "./owasp"; const REPO = "https://github.com/authzed/openagentprimitives"; -/* Every outbound URL the page needs that does not yet exist. Kept in one object - * so filling them in is a single edit. Anything pointing at `/docs/` is - * a real guide on this site and is written inline. */ -const TODO = { - owaspFull: "https://example.invalid/PLACEHOLDER-owasp-coverage", -}; - /* The page's copy follows the repository README: its order, its emphasis and * its claims. When the README's story changes, change this page to match. */ @@ -708,9 +701,6 @@ export function Landing() { Read the coverage map - - Full assessment -

    How we know the gates still hold

    diff --git a/site/components/SiteFooter.tsx b/site/components/SiteFooter.tsx index 93e54e8..d58083e 100644 --- a/site/components/SiteFooter.tsx +++ b/site/components/SiteFooter.tsx @@ -2,8 +2,8 @@ import { OapMark } from "./OapMark"; import { ThemeToggle } from "./ThemeToggle"; import "./site-footer.css"; -// Placeholder until the project's community space exists. -const COMMUNITY = "https://example.invalid/PLACEHOLDER-community"; +// The AuthZed Discord, the destination authzed.com/discord redirects to. +const COMMUNITY = "https://discord.gg/TUd4k5McMX"; /* The footer shared by the landing page and the docs. Plain throughout: * the two sections load different stylesheets, so crossing between them is a From bcd8badc85676b9f0efab6ad29b632e6ab2e25b6 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 13:34:44 -0700 Subject: [PATCH 46/55] Launch fixes: accurate cards, license, OWASP credit, quickstart path - "Nothing phones home" was untrue (SpiceDB's default telemetry is left on), so that card becomes "Under your control": OAP runs inside a cluster your team operates. - The Channels card drops "sub-agent conversations"; the landing page is about channels, and the phrase contradicted the ASI07 row. - The ASI01 gap no longer mentions meta tools, which read as actions skipping authorization. - The footer says OAP is open source under Apache 2.0, linking LICENSE. - The OWASP credit now names the work, its author and its license with links, on the landing page and at the foot of the OWASP docs page. - The quickstart installs from examples/pm-agent, the path that exists. - README: Desktop is for trying OAP and demos, not production; the docs link to openap.org/docs, with local instructions kept. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 8 +++++--- site/app/(landing)/Landing.tsx | 17 ++++++++++++----- site/app/(landing)/owasp.ts | 2 +- site/components/SiteFooter.tsx | 5 ++++- site/content/docs/owasp-top10.mdx | 7 +++++++ site/content/docs/quickstart.mdx | 2 +- 6 files changed, 30 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a56276c..17d5d9a 100644 --- a/README.md +++ b/README.md @@ -192,9 +192,8 @@ On first launch, pick a model provider, enter its API key, and set a local admin password. OAP provisions the VM, configures the platform, and installs a demo agent. -Desktop is single-player: good for trying OAP, developing and demoing agents, or -running production agents one person owns and operates. Use Kubernetes when -agents need a shared environment. +Desktop is single-player: good for trying OAP and for developing and demoing +agents. Use Kubernetes for anything durable or shared. **Local Kubernetes** installs onto a `kind` cluster for development: @@ -340,6 +339,9 @@ outside it. ## Read the docs +The docs are at [openap.org/docs](https://openap.org/docs). To run them locally +instead: + ```bash cd site pnpm install diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index d60a052..fb74667 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -314,7 +314,7 @@ const EVERYTHING = [ }, { title: "Channels", - body: "Slack, the browser, the CLI and GitHub, plus signed webhooks, scheduled runs and sub-agent conversations. Transports are pluggable, so you can add your own.", + body: "Slack, the browser, the CLI and GitHub, plus signed webhooks and scheduled runs. Transports are pluggable, so you can add your own.", }, { title: "Memory and knowledge graph", @@ -325,8 +325,8 @@ const EVERYTHING = [ body: "Every core component ships built in and can be replaced with one you already run.", }, { - title: "Nothing phones home", - body: "The runtime only calls the providers, servers and hosts you configure.", + title: "Under your control", + body: "OAP runs inside a cluster you operate. Your team owns its network, access and upgrades.", }, ]; @@ -711,8 +711,15 @@ export function Landing() {

    - OWASP materials are used under CC BY-SA 4.0. Not affiliated with or - endorsed by OWASP. + Based on the{" "} + + OWASP Top 10 for Agentic Applications (2026) + {" "} + by the OWASP GenAI Security Project, licensed under{" "} + + CC BY-SA 4.0 + + . This assessment is not affiliated with or endorsed by OWASP.

    diff --git a/site/app/(landing)/owasp.ts b/site/app/(landing)/owasp.ts index ef5cd72..f9f0de5 100644 --- a/site/app/(landing)/owasp.ts +++ b/site/app/(landing)/owasp.ts @@ -14,7 +14,7 @@ export const OWASP: readonly (readonly [ "ASI01", "Agent Goal Hijack", "partial", - "No goal-lock or plan-divergence detection; meta tools bypass the gate.", + "No goal-lock or plan-divergence detection.", ], [ "ASI02", diff --git a/site/components/SiteFooter.tsx b/site/components/SiteFooter.tsx index d58083e..7f836de 100644 --- a/site/components/SiteFooter.tsx +++ b/site/components/SiteFooter.tsx @@ -4,6 +4,8 @@ import "./site-footer.css"; // The AuthZed Discord, the destination authzed.com/discord redirects to. const COMMUNITY = "https://discord.gg/TUd4k5McMX"; +const LICENSE = + "https://github.com/authzed/openagentprimitives/blob/main/LICENSE"; /* The footer shared by the landing page and the docs. Plain throughout: * the two sections load different stylesheets, so crossing between them is a @@ -28,7 +30,8 @@ export function SiteFooter() {

    Built by AuthZed, using{" "} - SpiceDB. + SpiceDB. Open source + under the Apache 2.0 license.

    diff --git a/site/content/docs/owasp-top10.mdx b/site/content/docs/owasp-top10.mdx index 57aa840..0c3a852 100644 --- a/site/content/docs/owasp-top10.mdx +++ b/site/content/docs/owasp-top10.mdx @@ -136,3 +136,10 @@ Each concept is designed around specific threats: - **[Agent definition](/docs/agent-definition)** → [ASI01](/docs/owasp-top10#asi01), [ASI08](/docs/owasp-top10#asi08), [ASI09](/docs/owasp-top10#asi09) - **[Channels](/docs/channels)** → [ASI09](/docs/owasp-top10#asi09), ASI07 (N/A) - **[Memory](/docs/memory)** → [ASI06](/docs/owasp-top10#asi06), [ASI10](/docs/owasp-top10#asi10) + +--- + +This page is based on the [OWASP Top 10 for Agentic Applications (2026)](https://genai.owasp.org) +by the OWASP GenAI Security Project, licensed under +[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/). The assessment is AuthZed's own and is not +affiliated with or endorsed by OWASP. diff --git a/site/content/docs/quickstart.mdx b/site/content/docs/quickstart.mdx index faa4959..c3645ca 100644 --- a/site/content/docs/quickstart.mdx +++ b/site/content/docs/quickstart.mdx @@ -20,7 +20,7 @@ export const meta = { the entire agent graph in one step: ```bash -oap agent install examples/oap/pm-agent # a folder bundle in this repo +oap agent install examples/pm-agent # a folder bundle in this repo oap agent install ./pm-agent.oap # a packed single-file bundle oap agent install ghcr.io/acme/pm-agent:1.2 # straight from a registry ``` From 265115431c6aa255f6eb1494bb89365557d1e6b2 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 13:34:44 -0700 Subject: [PATCH 47/55] Add search and sharing metadata for openap.org - metadataBase is https://openap.org (lib/site.ts), so canonical and Open Graph URLs resolve to the production site on every deploy. - Every page has a canonical link, og:url, and the same current description; docs pages get article-type Open Graph tags. - A build-time 1200x630 share card (app/opengraph-image.tsx), attached explicitly to pages that set their own openGraph, since that replaces the inherited image. - robots.txt and sitemap.xml (the landing page plus all 131 guides). - favicon.ico and a 180px apple-touch-icon, drawn from the brand icon on a dark tile; the SVG favicons stay for browsers that use them. - A styled 404 page with links back to the docs and home, and one title. - /docs now redirects with 308 rather than 307. Co-Authored-By: Claude Opus 5.5 (1M context) --- site/app/(landing)/page.tsx | 12 +++-- site/app/apple-icon.png | Bin 0 -> 4812 bytes site/app/docs/[slug]/page.tsx | 20 +++++++- site/app/docs/page.tsx | 4 +- site/app/favicon.ico | Bin 0 -> 4225 bytes site/app/layout.tsx | 16 ++++-- site/app/not-found.css | 94 ++++++++++++++++++++++++++++++++++ site/app/not-found.tsx | 29 +++++++++++ site/app/opengraph-image.tsx | 55 ++++++++++++++++++++ site/app/robots.ts | 9 ++++ site/app/sitemap.ts | 11 ++++ site/lib/site.ts | 19 +++++++ 12 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 site/app/apple-icon.png create mode 100644 site/app/favicon.ico create mode 100644 site/app/not-found.css create mode 100644 site/app/not-found.tsx create mode 100644 site/app/opengraph-image.tsx create mode 100644 site/app/robots.ts create mode 100644 site/app/sitemap.ts create mode 100644 site/lib/site.ts diff --git a/site/app/(landing)/page.tsx b/site/app/(landing)/page.tsx index 637b56c..d2522d7 100644 --- a/site/app/(landing)/page.tsx +++ b/site/app/(landing)/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { OG_IMAGE, SITE_DESCRIPTION, SITE_NAME } from "@/lib/site"; import { Landing } from "./Landing"; import "./landing.css"; @@ -6,12 +7,17 @@ export const metadata: Metadata = { title: { absolute: "Open Agent Primitives: a secure way to run enterprise AI agents", }, + description: SITE_DESCRIPTION, + alternates: { canonical: "/" }, openGraph: { - title: "Open Agent Primitives", - description: - "Building blocks for running enterprise AI agents in your own cluster. The AI never decides what it's allowed to do: OAP checks every action before it runs.", + title: SITE_NAME, + description: SITE_DESCRIPTION, + url: "/", + siteName: SITE_NAME, type: "website", + images: [OG_IMAGE], }, + twitter: { card: "summary_large_image", images: [OG_IMAGE.url] }, }; export default function Page() { diff --git a/site/app/apple-icon.png b/site/app/apple-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..dc18203495388f734406ec70491d8cdb4cbfc558 GIT binary patch literal 4812 zcmc&&XE+;N*w!jFT0)K56|*g=ty&UdL~D;$X@b%kwN+|wUVF8OT?FOz3N?bDwbhOp z6*G1bd)4~%`~Us^ew=fz>s-%yp6fa1T+jX7ca**^>^2=I9R&r&?Pr>5hF5F#-$6rt zH7oT~OHoiTe}1N>V(gW*neFYDJT}%9n7+~dmS5#iR{3qp^hSDj4?{`_G&qDb{<59Q zxRM#4no9WB-`}BzuX@wL4Q7z*m?UUL^6r7gVo|7KttnETIiLbc26cQ3bo`p51Y648T!p`kEv*KgZ^gzfEk z$e%5m6_Uyd%M(Un!L71lHf+SWa!2EJg9!B~96MG%h}_wX=*%AMs-PJtx1~)Wk$kdzxSt z7@4taFY?#u)mxkE1Ex9FT1sZ;_}kR*dUq@)F_2?!_x(%ILqKRq?9ZOwe7PLT#9Wufw#wz64=aN$6UCBs ze)V)YIfpfC8}AoVQmlVhcWdfs<;j_IM(7i`{BCWZ8`X#xD*KLe$TKnkl%! z`$EHgYl`oUg1xRX$KH`_I9nZ#7($zPn*UVrr4C&py+D;pyziqCtMdi3Mmhxn_iq<) zhDO}#Jj^evkdb3t>h*&6&qN%2efaQU65t)w-o9iLnU55O5`zKGy5?-xDJi|*-*Y$& zK`0&^9ITEvJxm4fh_XxddrvT`+dU=(E__L@uF z1gXIJqVXTA*Cvz$J~|73`QTh`*WJFpwH1-o=tvxdRx{B4@!^Y?eE@1a0!>M|ry;5a z212H(dU_v9I76QlX=iIYDL&0hMyGhkfe=s7c6$7=8Uilpnw5{VTFA5WbGNezH$jnt zTnr{>(yQ$1T~j=)?K=%U)+W>0-)}ZO=pa+~QNQ_F(Nec^;aePMdy^%v+=xahFi!j= zQ{Q5CH$K;~-RAMSuZTvbzM*^SvLc33eRW;U?q%Z1@gH1uLo-P5W$esoi2(1QU)Hjs zS@4Z%s7H>RKd0fyX9IJaOS2TQSEupk+*KWZswET~@ZfCsl>Y!=@_D zjB7N+LR8-N)_MQ>_-FwqAh^mll|aq#Jw3D8?w#9<>YcvssN<8(McgQ}F^d_KLAh;t zj_70)B^WH;z*kQ~byxt9$WIi~G(>ocA+-0fR!9ZX0Yf_S7yu6d3I!BBa4>T#M zDt6Kf(y~bYq6bjn+TE|Cq^CsHW^V%1p zWkOv|hBLeuX@B`$Pl6xG{6jSJXDXpdpt}Ejb^az`sGRY=gUi(U&lfZVp=F;Zwlk0H z2HK_ts_jM&)@Kv=_^-_@;ZY`4{m7DPmZ%|{WE85wKY4u6MK9yjKo|-Q9m3B|-6kJ4 zFXKkPvh$ilFk%>UHJCLJ^v}S7M-t^0F97pX+Pm{7%`@%Xw+}KSZnNEu6 zWmKf2F%36^qKDsov0~fi2#seL@Psf+7<~IUce1zQhF#?Tv%eNMmuPAHqPsq* zZrxCsBAy!@V*KIB<)wR~#D1{W z?I#QFYtI*!4LBJsNtG@<4 zjgXUh99Z@{LfSB>;MmyDKU>-`;4!)*?0}1)uoRn`ia?#Yl8&FHEqp%Vh$iDZjh;Yb zUSC-E@~YZ`IUm@TMXQ^>Ts=SNkUjnlu|OORRe7x%)S&_EGXV)OB-`{-9cavQ8?RD# zX>wGr!=?9&MjSX_EQzj2ujd;Z(-LofK8ON>C&PN8#|Jsi=Cj&pSp4?$R9CpMhgIHY zH`AD{`X0ZA=lflq)OyMC=IZwoVUv%N5Hi8R*N)F!E`xR}EBRPM#QEEMweMc;uAl(i z%z}l=GVmo_wOFqb9F-i42R_mJBFPhIaViB$O>H3(Pn>X3<6c37z>2SJ1jbVwb=Jx+>C!0O0EZBdS2dbwDQoKvR2M-&g;Uz&YUoSX&T z8Tu55!T7kdUcY``IIS)=J}Myp$i$l<6k17!F`m)MGe$3h*@h=Q-sE|$$9YybtiYV2 zeSimEdk3`@RhH~fsA>Urhxs!}av`4MdF@W}a|N~iwXp_vy33|eh;!--M-Ii?aKEDe z#eQ6WM`|4)-zvYto=9!)zpFvUE%t7D-LdZL!GSo8wdGUYj?fa_=scC6CD-^yX|)c zN+US4yoZO(aMV++Bi`wgtcdb*ZVRQD7wv3^mLeby5xp#9Oq{L$;JN7OKhafO;Yg`7 z%Cl{vNMM(qf6Z4N+j4yLAj`su?H8DSg@GB@R!_=9noU4--_$K3q8D|_RDw-^dU##? zU9t(kbw>PBUtV6zM3O}i(xqvA^biF&pbrdSf-&$d8Tm@7Z8{+c8Q=xEw7#gGGKV#D zJP=x0y!WcpaMZY0I);e}^vJewWQzi5R>`tl!un%j#9fLWTT~?ccI90QvG;X}W|^-_ zOo-!78zT^y|La^+$-&4-m! zI(+tbJ7U2D%0{#3q?i!Me zj%ohgxXDZ!7%3UinW(S*iKV&c5KnA|qnJlr7kkk-PlVK}f|N8G`f_4h(a6yC&DO&D zO3c?(NQYjByGABPR!#SG+H+;6@3-yAa9M18M8p^}ievumBNubRuy68sVXit$`VULq z&Z#HSN1^XLqL0~zck%d* zrMcQ^;q6fW38R{2Zli(CG=FbzU;b$sh6o1#A+j}!bUxhmbz$>pCb6N67Z8ND;8l;l zuS39S;bQQO4fwY$pSsdd4dh3kO{&cg)vMB6?Ce6|K%0LeQ(w!wbo_H9zk?J{$7r>| zrb8OY-;D@PiU@VP%+sNYyb1HVaF~rL8P?y$gxX7GtKzj(z&(r>N-~!u6rN=hn*(le zbZ)Sft`9HuqORI8QjVWb)}F-b*!j;{3(P6gUcZ;m0iWA^D;2qE<6n#aR)eJpg|d$% z2}0iMW`Fy$$ujy>(Cd8S*9ctH--41M#Z=zZ#P$x=Q?UHGE1oApVZ4v%am!@+bABA5 z{(}h1=KbXH&O7XYb>2degq0@OrZNYP{pEn+^1_68Do6p8I3?Mqoo_k+pC|JKd=9{W!^jd;{z3Qv+%r<@y*o6)UJR5aTz zyrs#e;U8`qpp(8_t9!rnfCa^}r(kE&?5rjRg=uI2ZQscOfxv>zXBVT$wZ$l-DP#!- zt1IuM*iUb^=sKu$DMrS5UIV3?lXz%;(HK-6Y9NuUaN0XHLwDP^*ebOr1L=GpVLMYZ z(<30501f&2>LMy}aQl!o%zE!IZrEo)oIfBK1AdUMuCY^9P1u~Z(vf|&BEUZx`-xygicDhWoAcAF9R{?V2h7`g@daX0ld=B6Pd_iu<2n1_lay-PazcKcCCEn*>?2EkMU)kX|tdB5C zX^H`KfjYhR5h^2OSWH0N6%i{iIZ*4Z_>-64c%63n@f6shXqo7h5rfayAvEZ@kOSJm z`bvzw#md&!0)c5C4mT9itor@nCZ$e_p*GVAl_-8O_)JS$*tyJ>krbhjM$D{HbL5(a z5tA7U_Z8dn3f%Lt4?PW|#Avb6_qlaHb~_6bzVeUjQ}cb`tIjrCJWLYrM@Z5T3h{uq z%XS20cdqOa`qu|r0Xf|(;F+JpjSDT~_N4<>lh_C~GD+@{atK#Vnk(Si&bv20N_d~M zJRoBRyAD3xmyXp-xAWabr!a*{MF4WNUKG0Fsx zznT$73zUG5T^xPe*f7uYJ^6WfF4(SexlfGhnXcwMM$4>uDKAkexTmLIITRV1iNDFq z%Zq(S>dVr$qe9X9%=^Vjsh!43(0G@0 zorV7hV{@&K#(8dO>~Yd5s>DV{23>9B{-R`oUY>X$oXScAZ#mKUTy~u@-MuT?F`Ajk z=ulO8dndm&8-E?-LX^CW(RmG-TL%3nQ2;lCTv2tPnAk0$4k1nHuX{%C=gA>9+UZX? z0{`@Wpkbu{s#!*v@!#iU<&9k)DwT%5ZdHF;XK|D2!tY${L% zYL~T4pnvbMe&ROZU~bkX-c#+K9NtT%19;9Feq&zy|6g1AKlPoU=VyG=Xt;I70M(N# Pm5Sn-x~>`qY8moB!YE*@ literal 0 HcmV?d00001 diff --git a/site/app/docs/[slug]/page.tsx b/site/app/docs/[slug]/page.tsx index aaf2f47..ad7ecf8 100644 --- a/site/app/docs/[slug]/page.tsx +++ b/site/app/docs/[slug]/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { guideSlugs, loadGuide } from "@/lib/guides"; +import { OG_IMAGE, SITE_NAME } from "@/lib/site"; // Every guide is prerendered; any other slug is a real 404, not a silent // fallback to the first guide. @@ -12,8 +13,23 @@ export async function generateStaticParams() { type Props = { params: Promise<{ slug: string }> }; export async function generateMetadata({ params }: Props): Promise { - const { guide } = await loadGuide((await params).slug); - return { title: guide.title, description: guide.description }; + const { slug } = await params; + const { guide } = await loadGuide(slug); + const url = `/docs/${slug}`; + return { + title: guide.title, + description: guide.description, + alternates: { canonical: url }, + openGraph: { + title: `${guide.title} · OAP docs`, + description: guide.description, + url, + siteName: SITE_NAME, + type: "article", + images: [OG_IMAGE], + }, + twitter: { card: "summary_large_image", images: [OG_IMAGE.url] }, + }; } export default async function GuidePage({ params }: Props) { diff --git a/site/app/docs/page.tsx b/site/app/docs/page.tsx index 3cf6d9c..662aac6 100644 --- a/site/app/docs/page.tsx +++ b/site/app/docs/page.tsx @@ -1,9 +1,9 @@ -import { redirect } from "next/navigation"; +import { permanentRedirect } from "next/navigation"; import { allGuides } from "@/lib/guides"; // /docs has no page of its own: it opens the lowest-order guide, as the old // hash router did for an empty hash. export default async function DocsIndex() { const [first] = await allGuides(); - redirect(`/docs/${first.slug}`); + permanentRedirect(`/docs/${first.slug}`); } diff --git a/site/app/favicon.ico b/site/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..cffe85547b7660d08c0f125359905eeffd49077f GIT binary patch literal 4225 zcmai0XEYoP(_MXuURQ}u2*K(iTJ#z$qC|-Dhj%P>pvSvfw+G&)9;73005?>s)C%JZ+0MLSzS+u z^nOI@a^9ucXU0?=he?-Zh&_ewxo1N-8*PUGTuh)m`15XLe1`y1#^{ z>5qTZ>UfBJ-r+urU{FNnc)zu16}~s4%C_+u`fM)3V39h zHee8!*6AmT$X+W@xX-YUzoOya3KtA8eR~n!_i!}uTV97Sz@j0&CBy2pJRI8AP z8}Icn-wd^MwSQvlsPNU?_h##{#-PRSFL_EK8xUIy#b*+j!ADOmFr@g?=q}HUAyUln z2C_XpI5`E(M$+)SK~7f38ItFe=s;UB=Ig}AH);Gbp^GsAuev)95}19DM*``rpn7+Q zaSpV6i)NnE9cLb6I-MbMIm9oCsYXs0Mtoo`%ZpnVKk!O|?oeI2zMq$qCw>Fb>+;|Lf)X``6Tud7a8Flgu5hoL4RktW^NW;8iAXA* z3^8BJwryj-4H#!A!2z?l_k9 zozr};WwDdgRd$nm^ssHTd$K@O#S3oB==HEzr6e)rsAtZ!8V;;FJu6qS)y!w^E0Ro6 z=W=cX-=@*J)N`(Damgs)`WEc98M?;UUn4-M$(J^%d-|a8p}|j2WV*0CMfYaO<}+06 zN8QZEGAn*v$*QA_DJOW~x&Ihd(Uf>c+wQ?850_)VX=kI?{Uh;E{f2_?gD?N8381Q| zrBL{RIPf}+yGmgM8&o5a3H!~el-n3vSYysq<|YjW1}jWyL#v>yYtSf?}LC#-zx&90Fh3C57e^yE*~-ZN7xc;6HD29DRD6htTVlI zL4LJijjV2y!YNxs+`4+-X_IOc(LfSRLm|yXUb@8rL^+C1fw&i8CE)p*TSI)3h0zl0 zz%u0bN~#qLh|pqX9|8+$-t%FBkH}IOm#w;4bFc7bP9@Zn+rM+^ye8V$Zun&&EGo)s z)0^+0s4T$$nU-tbnww+7mSEq-EM91tSXK=Kh%c1Y6DN7pJr>U5OUTaVQC%V>-i~zdwf*ak9f~gLmOW8Y^whKI=)N(n_p}+A-DDebVhD zB#R5dZIgjO=&ZO_rHwV(+sW5dn&Ie3whA&bKOFTW9KCv^)#!D38X-I5#g3xzy+va< z&3&W&)h}K+MM+v=X(t$Wc)m~_wEUsFcKzKjDooaGSd#PUAkWFLkDZRGX{B0x>&4C8 zJ1xNsz29&6Xjl`0_0Dr4{r##ty5=Ze8?a4U&1dl`KVXH6ag+vU5*EqT`0`AA2n6fo zCUCFa^Lea@xOAcCkd%?IhGf`vEWK!l4d`k$?O1N) zKuimLT`BqNrfGRaBp_`VA8Pa)==NRC?lm4JM*Doa2#6qWduO2$)D-zXh-hso=aQ5F zZ(3{+r5EMR-~f4?q<)Da7wFVe8GMm~tukMG!-C??LRf)eihbNEYLhEW+YX9R=lAo}V-#HM+Af{N=k_#&f2nVt_)MC{Dm2W4E59mWY5M;xinEIUMq+GbN|uWB!) ze=B3l?HbP&{{=r`S8(6SO71vW%k{+m$+c}W^R}6t4_*LY`_om$C?MtO&%vLenI!e3 zgR}OYy%qd2$82e5T*uqx%9rD^4-Nxb8SnUzZ=qXJN?&P?jVQ5R5?G@gzLXoqZf$2J zXcX(gYcFqaI%?LLhjlo1yqi-_XQ`><+8dipt5?X* zc*fZEj{V+}^ez9Ts?cXCq!}(2sc041xBf`fYD}9e>=bsIA>j7XKreMYb|wx{c5ajm zeX4IyBO*J=PM(47vRp@dXV(_&VEia}Hhr;XaPV~5V?BV1cZfR#zTmT3VjiR?0dC;k zT#SlKsx7LmcEldpC{Holy-QCcz*s<$@sX@1w#Kh197jP*ilhT&AQ}R3s}qST>?vYB zjAiQh5XSf?7L9~Ja6G16M^Q3&MQe@0V_uG*$JA8PZkEh78aowMmJ54-x8gFWO{LtNALM2h>k9dwFxbeYeJTgLAV54@0WUI@k>*THBlup84+K+Yj|D6 zan(_EEH7l0zyWnpluhM`;Z*uH-K;kD!yRZlkr_5AE}AmEGBn3{wfI5#I+Tqp&xyI_ z>+vpiX*t1Q8^RlzZez>^q_%K%tmX17F*NZ&xD`ab6@D=r?7x2`$4(AU;ImQeJ<-NC zVO;!pvtJ+io)VAXI#ZWRKFcNk5j84E;E+y|R) zNGNhxWv~u=!weQSbp^!5!%&|+-dt~xzk$&&ZVAz2Oi1FZ_vNH68R}i(fbgvte33Jc zrYkx}FqPZK&#_&;LBZZqP3qc6_PPy1rQl9!gtCsAaY(~*AVm~Dnu6X`JV@@#J!*}W z-BVt;d2wH*v}!g65+oTF`~)zFvg5?WP5@aiPx`NVG<#fS>xO|MV9@X?ZP}-fS#=U*uc}_hRc0e~s39>$;V_J(pu2MC?Zh?b?al6)Ij&NPU%D!3Q z{ctzos!du*b>AX0CXu)b+}k_u`GHqoY_G3VOQ4vU}KEl7K|CrFK4{HLcemC@KCLe?c40=OT#-^tR3yzA3U5OAK$YltT<}H zTK2znm+PM_nr>l}6`M?=b7}qxg!je2JTGPC5bRN34(-77tn)!sRWULe;m~|$DWY?7 zS%tGj)Kpo+cW`L)vfn2@XJcdgXn-fp7;N%uL$d;^92`tnAF>tbU+uPv2~y(woe-)z z*?s>5OQ4dykheGgyMrpxP408%FvtZo2Au!qQF|k>Im?Ibt)}Y@_hzNf8SovC+>8^;;-$ougxVQPg)Y}{b&mu6+acsUR1gw zS_*ZwJ=@BX%?ZG`lcH{ohoov^$uPHgO^nZE`t)M?54m?ITvII`5^#GxF=0xYr8u@F zXSe3Xb%+3EcVfNwR8S?OSsFio)3Z(atb$$v#<>gN0MB4IHQ3+FY4DTGN>?=hEnzED zPPpw32xhLNz%=xSu!m3n72VG69go@dA0#Q)NqoVyJv(fEc-r?WpHqQC9y-34OqMqd zcBaU;AYJNfc4IrNJ5wP3ZC!dY>e>=MVYCfe+L`*eDqveP_^H!Y&5_vDtZvzk<9u^I0Z+)rGf5(tDNNb=h*q+9N{FIqjK4=il^SS8vt=n$5v-tU!PY9OIMF&wU9!+C&Kf@nA zh?5`@T7~h1)Y>1VES&WH?3n}2RPeDeaSTyssh$MGQ(+=j+{0%&%ZlA!8+R6s%Cb8L z{?OtuDAQV1a7`k!2P76<;%D{GZ}0ACBXYo)g->&8R(kTM_~Mx09^J{%dWo#wC!C2q zY@8u{d13e8LH_BZ!@u?n3s`bHB_1YAX&tWHLA7d`qT*<0Vb~>wE>?SgPu~IYH=9ls zL;PA&fCG{;Ghjh9C^#fYjYTb2p^qUqQLF1D4hayG?!E x`+G@1)Y+9+{m&%8l4LB4TsqL?!k?VG&*L!Y$Sdms;4yrmUa2eh?f-uv{|9(w>-PWv literal 0 HcmV?d00001 diff --git a/site/app/layout.tsx b/site/app/layout.tsx index f5ee032..2421d61 100644 --- a/site/app/layout.tsx +++ b/site/app/layout.tsx @@ -2,13 +2,20 @@ import type { Metadata, Viewport } from "next"; import type { ReactNode } from "react"; import { ThemeProvider } from "next-themes"; import { Analytics } from "@/components/Analytics"; +import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/site"; import iconDarkInk from "../../docs/assets/brand/oap-icon-dark.svg"; import iconLightInk from "../../docs/assets/brand/oap-icon-light.svg"; export const metadata: Metadata = { - title: { default: "Open Agent Primitives", template: "%s · OAP docs" }, - description: - "OAP is a Kubernetes-native runtime for LLM agents. Every state-touching tool call is checked against a SpiceDB relationship graph before it runs, so the model is never asked whether it may act.", + metadataBase: new URL(SITE_URL), + title: { default: SITE_NAME, template: "%s · OAP docs" }, + description: SITE_DESCRIPTION, + openGraph: { + siteName: SITE_NAME, + type: "website", + locale: "en_US", + }, + twitter: { card: "summary_large_image" }, // The favicon sits on the browser's tab strip, not on this page, so its ink // follows the OS scheme rather than the site's toggle. icons: { @@ -24,6 +31,9 @@ export const metadata: Metadata = { media: "(prefers-color-scheme: dark)", }, ], + // Declared here because an explicit `icons` replaces the file-based + // app/apple-icon.png link rather than adding to it. + apple: [{ url: "/apple-icon.png", sizes: "180x180", type: "image/png" }], }, }; diff --git a/site/app/not-found.css b/site/app/not-found.css new file mode 100644 index 0000000..459dae0 --- /dev/null +++ b/site/app/not-found.css @@ -0,0 +1,94 @@ +/* The 404 page loads neither the landing nor the docs stylesheet, so it + carries a small palette of its own that follows the same theme attribute. */ +.nf { + --nf-bg: #0c050f; + --nf-fg: #e9e7e9; + --nf-heading: #f8f7f8; + --nf-muted: #9a94a0; + --nf-line: #3f3644; + --nf-surface: #2f2136; + max-width: 760px; + margin: 0 auto; + padding: 0 24px; + min-height: 100vh; + display: flex; + flex-direction: column; + + --sf-line: var(--nf-line); + --sf-line-soft: var(--nf-line); + --sf-muted: var(--nf-muted); + --sf-link: var(--nf-fg); + --sf-strong: var(--nf-heading); + --sf-pad-x: 0px; + --tt-line: var(--nf-line); + --tt-fg: var(--nf-muted); + --tt-fg-hover: var(--nf-fg); + --tt-fg-active: var(--nf-heading); + --tt-bg-active: var(--nf-surface); + --tt-ring: var(--nf-fg); +} + +:root[data-theme="light"] .nf { + --nf-bg: #fcfcfc; + --nf-fg: #1e1424; + --nf-heading: #170d1c; + --nf-muted: #655d69; + --nf-line: #d2cfd3; + --nf-surface: #e9e7e9; +} + +body:has(.nf) { + margin: 0; + background: var(--nf-bg, #0c050f); + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, + sans-serif; +} + +:root[data-theme="light"] body:has(.nf) { + background: #fcfcfc; +} + +.nf-main { + flex: 1; + padding: 120px 0 96px; + color: var(--nf-fg); + font-size: 17px; + line-height: 1.6; +} + +.nf-code { + font-family: "SFMono-Regular", "Menlo", "Consolas", monospace; + font-size: 12px; + letter-spacing: 0.09em; + color: var(--nf-muted); + margin: 0 0 12px; +} + +.nf-main h1 { + font-size: clamp(30px, 5vw, 44px); + font-weight: 300; + letter-spacing: -0.03em; + line-height: 1.1; + color: var(--nf-heading); + margin: 0 0 16px; +} + +.nf-main p { + margin: 0 0 12px; + max-width: 56ch; +} + +.nf-links { + display: flex; + flex-wrap: wrap; + gap: 12px 24px; + margin-top: 28px !important; +} + +.nf-links a { + color: var(--nf-heading); + font-weight: 500; + text-decoration: underline; + text-underline-offset: 4px; +} diff --git a/site/app/not-found.tsx b/site/app/not-found.tsx new file mode 100644 index 0000000..b905917 --- /dev/null +++ b/site/app/not-found.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; +import { SiteFooter } from "@/components/SiteFooter"; +import "./not-found.css"; + +export const metadata: Metadata = { + title: { absolute: "Page not found · Open Agent Primitives" }, +}; + +// Every unknown path lands here, including stale /docs/ bookmarks, so +// the page offers the two ways back in. +export default function NotFound() { + return ( +
    +
    +

    404

    +

    This page doesn’t exist.

    +

    + It may have moved when the docs were reorganized. Try the docs, or + start from the home page. +

    +

    + Browse the docs + Go to the home page +

    +
    + +
    + ); +} diff --git a/site/app/opengraph-image.tsx b/site/app/opengraph-image.tsx new file mode 100644 index 0000000..b96132c --- /dev/null +++ b/site/app/opengraph-image.tsx @@ -0,0 +1,55 @@ +import { ImageResponse } from "next/og"; +import { OG_IMAGE } from "@/lib/site"; + +// The share card for every page that does not ship its own. Rendered once at +// build time. The logomark geometry is the same as components/OapMark.tsx. +export const alt = OG_IMAGE.alt; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +// The renderer's default font sets an ordinary space far wider than the +// non-breaking one, so the headline's spaces are swapped before rendering. +const nb = (text: string) => text.replaceAll(" ", "\u00a0"); + +export default function OpengraphImage() { + return new ImageResponse( +
    + + + + + +
    +
    +
    {nb("A secure way to run")}
    +
    + {nb("enterprise AI agents.")} +
    +
    +
    + Open Agent Primitives · openap.org +
    +
    +
    , + size, + ); +} diff --git a/site/app/robots.ts b/site/app/robots.ts new file mode 100644 index 0000000..77a1b9b --- /dev/null +++ b/site/app/robots.ts @@ -0,0 +1,9 @@ +import type { MetadataRoute } from "next"; +import { SITE_URL } from "@/lib/site"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { userAgent: "*", allow: "/" }, + sitemap: `${SITE_URL}/sitemap.xml`, + }; +} diff --git a/site/app/sitemap.ts b/site/app/sitemap.ts new file mode 100644 index 0000000..eac60d7 --- /dev/null +++ b/site/app/sitemap.ts @@ -0,0 +1,11 @@ +import type { MetadataRoute } from "next"; +import { guideSlugs } from "@/lib/guides"; +import { SITE_URL } from "@/lib/site"; + +export default async function sitemap(): Promise { + const slugs = await guideSlugs(); + return [ + { url: `${SITE_URL}/`, priority: 1 }, + ...slugs.map((slug) => ({ url: `${SITE_URL}/docs/${slug}` })), + ]; +} diff --git a/site/lib/site.ts b/site/lib/site.ts new file mode 100644 index 0000000..8bad3f7 --- /dev/null +++ b/site/lib/site.ts @@ -0,0 +1,19 @@ +// The production origin. Canonical URLs, Open Graph URLs, the sitemap and +// robots.txt all resolve against it, including on preview deploys, so shared +// links and search results always point at the real site. +export const SITE_URL = "https://openap.org"; + +export const SITE_NAME = "Open Agent Primitives"; + +export const SITE_DESCRIPTION = + "Building blocks for running enterprise AI agents in your own cluster. The AI never decides what it's allowed to do: OAP checks every action before it runs."; + +// The build-time share card (app/opengraph-image.tsx). A page that sets its +// own openGraph replaces the inherited one wholesale, image included, so +// those pages list it again explicitly. +export const OG_IMAGE = { + url: "/opengraph-image", + width: 1200, + height: 630, + alt: "Open Agent Primitives: a secure way to run enterprise AI agents", +}; From b4e4f39ac6448bc1a1c64a2b1cdcb0d1adb8ade1 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 15:19:59 -0700 Subject: [PATCH 48/55] Correct the landing page's overstated claims Each change was confirmed against the code and approved item by item: - Drop the hero note that every image comes from reviewable code; the install also pulls third-party images. - "Built in and swappable" names the README's list (runner, authorization, approvals, sandboxing, credentials, memory, audit) rather than "every core component". - "Instant revocation" becomes "the next action is refused". - "Twenty-seven controls, in six areas" becomes "Controls in six areas", since the cards show a selection. - The comparison row's OAP answer to an injected instruction is "Cannot widen what it's authorized to do", which holds by default; plan gating is opt-in. README's table matches. - "Nothing is enabled until someone adds it" is scoped to tools and capabilities, since breakers and network policy are on by default. - The OWASP heading loses its Draft badge. - ASI05 describes per-session isolation instead of "deferred" work, and ASI07 is re-rated Partial: delegation is typed, authorized and bounded, and stays within one cluster. - The OWASP caption is counted from the rows (coverageSummary, tested), so a re-rating can't leave it stale. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 18 +++++++------- site/app/(landing)/Landing.tsx | 28 +++++++++------------ site/app/(landing)/landing.css | 18 -------------- site/app/(landing)/owasp.test.ts | 42 +++++++++++++++++++++++++++++++- site/app/(landing)/owasp.ts | 24 +++++++++++++++--- 5 files changed, 83 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 17d5d9a..95c3bdb 100644 --- a/README.md +++ b/README.md @@ -52,15 +52,15 @@ with many different owners. Every control in OAP therefore sits outside the model. The platform decides before the call, and the decision does not depend on the agent's cooperation. -| | Typical agent platform | OAP | -| ----------------------------------- | ---------------------------------- | ------------------------------------ | -| What an agent can reach | Whatever its credentials allow | Exactly what you granted | -| Who decides an action is allowed | The model, in the moment | The platform, before the call | -| An injected instruction mid-session | Can redirect the agent | Cannot exceed the approved plan | -| Tool credentials | Shared across tools in one sandbox | Held only by the tool that uses them | -| Restricting an MCP server | Needs a narrow upstream token | Declared by you, enforced per call | -| Revoking access | Rotate credentials, redeploy | One permission graph call | -| The audit log | Append-only, enforced by the store | Signed, chained, verifiable offline | +| | Typical agent platform | OAP | +| ----------------------------------- | ---------------------------------- | --------------------------------------- | +| What an agent can reach | Whatever its credentials allow | Exactly what you granted | +| Who decides an action is allowed | The model, in the moment | The platform, before the call | +| An injected instruction mid-session | Can redirect the agent | Cannot widen what it's authorized to do | +| Tool credentials | Shared across tools in one sandbox | Held only by the tool that uses them | +| Restricting an MCP server | Needs a narrow upstream token | Declared by you, enforced per call | +| Revoking access | Rotate credentials, redeploy | One permission graph call | +| The audit log | Append-only, enforced by the store | Signed, chained, verifiable offline | ## What makes it secure diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index fb74667..305ed05 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -8,7 +8,7 @@ import { } from "react"; import { Wordmark } from "@/components/Wordmark"; import { SiteFooter } from "@/components/SiteFooter"; -import { OWASP } from "./owasp"; +import { OWASP, coverageSummary } from "./owasp"; const REPO = "https://github.com/authzed/openagentprimitives"; @@ -69,7 +69,7 @@ const COMPARE: [string, string, string][] = [ [ "An injected instruction mid-session", "Can redirect the agent", - "Cannot exceed the approved plan", + "Cannot widen what it's authorized to do", ], [ "Tool credentials", @@ -214,7 +214,10 @@ const AREAS: Area[] = [ { title: "Least privilege by default", points: [ - ["Opt-in capabilities.", "Nothing is enabled until someone adds it."], + [ + "Opt-in capabilities.", + "No tool or capability is granted until someone adds it.", + ], [ "A sandbox per tool.", "Each tool holds only its own credentials, and the agent holds none.", @@ -237,8 +240,8 @@ const AREAS: Area[] = [ "Cluster, namespace and class defaults that agents can't opt out of.", ], [ - "Instant revocation.", - "Remove one relationship; no credential rotation or redeploy.", + "Revocation.", + "Remove one relationship and the next action is refused, with no credential rotation or redeploy.", ], [ "Verifiable audit.", @@ -322,7 +325,7 @@ const EVERYTHING = [ }, { title: "Built in and swappable", - body: "Every core component ships built in and can be replaced with one you already run.", + body: "The runner, authorization, approvals, sandboxing, credentials, memory and audit ship built in, and each can be swapped for one you already run.", }, { title: "Under your control", @@ -495,10 +498,6 @@ export function Landing() {
    -

    - You build OAP from source, so every image in your cluster comes from - code you can read and review. -

    @@ -574,8 +573,7 @@ export function Landing() { {/* ---------------------------------------------------- 03 secure --- */}
    - Twenty-seven controls, in six - areas. + Controls in six areas.

    The platform enforces every control. No prompt or tool output can @@ -659,9 +657,7 @@ export function Landing() {

    Coverage of the OWASP{" "} - - Agentic Top 10.Draft - + Agentic Top 10.

    How OAP maps to each risk in the OWASP Top 10 for Agentic @@ -670,7 +666,7 @@ export function Landing() {

    - + diff --git a/site/app/(landing)/landing.css b/site/app/(landing)/landing.css index e447da7..3ceb7c8 100644 --- a/site/app/(landing)/landing.css +++ b/site/app/(landing)/landing.css @@ -873,24 +873,6 @@ body:has(.lp) { border-color: var(--lp-line); } -/* ----------------------------------------------------------------- draft --- */ - -/* Marks a section whose content is still being written, so a reader is never - misled about which parts are settled. */ -.lp-draft { - display: inline-block; - font-family: var(--lp-mono); - font-size: 11.5px; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--lp-warn); - border: 1px dashed color-mix(in srgb, var(--lp-warn) 45%, transparent); - border-radius: 4px; - padding: 1px 8px; - margin-left: 10px; - vertical-align: 3px; -} - /* --------------------------------------------------------------- closing --- */ .lp-close { diff --git a/site/app/(landing)/owasp.test.ts b/site/app/(landing)/owasp.test.ts index a56bb59..f162b7a 100644 --- a/site/app/(landing)/owasp.test.ts +++ b/site/app/(landing)/owasp.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { anchorIds } from "@/lib/links"; -import { OWASP } from "./owasp"; +import { OWASP, coverageSummary } from "./owasp"; // The landing table builds each row's link as /docs/owasp-top10#, which // the static link check cannot follow. Pin it here instead. @@ -19,3 +19,43 @@ describe("landing OWASP links", () => { expect(ids.has(id.toLowerCase())).toBe(true); }); }); + +describe("coverageSummary", () => { + it("counts the landing table's rows", () => { + const counts = { substantial: 0, partial: 0, na: 0 }; + for (const [, , level] of OWASP) counts[level]++; + expect(coverageSummary(OWASP)).toBe( + [ + counts.substantial && `${counts.substantial} substantial`, + counts.partial && `${counts.partial} partial`, + counts.na && `${counts.na} architectural N/A`, + ] + .filter(Boolean) + .join(" · "), + ); + }); + + it.each([ + [ + "every level present, in fixed order", + [ + ["A", "a", "partial", ""], + ["B", "b", "na", ""], + ["C", "c", "substantial", ""], + ["D", "d", "partial", ""], + ], + "1 substantial · 2 partial · 1 architectural N/A", + ], + [ + "a level with no rows is left out", + [ + ["A", "a", "substantial", ""], + ["B", "b", "partial", ""], + ], + "1 substantial · 1 partial", + ], + ["no rows at all yields an empty caption", [], ""], + ] as const)("%s", (_name, rows, want) => + expect(coverageSummary(rows)).toBe(want), + ); +}); diff --git a/site/app/(landing)/owasp.ts b/site/app/(landing)/owasp.ts index f9f0de5..219a05e 100644 --- a/site/app/(landing)/owasp.ts +++ b/site/app/(landing)/owasp.ts @@ -38,7 +38,7 @@ export const OWASP: readonly (readonly [ "ASI05", "Unexpected Code Execution", "substantial", - "Per-call isolation deferred: calls in one session share UID, /proc, /tmp, /work.", + "Isolation is per session: tool calls within one session share a UID, /proc, /tmp and /work.", ], [ "ASI06", @@ -49,8 +49,8 @@ export const OWASP: readonly (readonly [ [ "ASI07", "Insecure Inter-Agent Communication", - "na", - "One agent per session; channels are human to agent, not agent to agent.", + "partial", + "Delegation stays within one cluster; there is no protocol for agents outside it.", ], [ "ASI08", @@ -71,3 +71,21 @@ export const OWASP: readonly (readonly [ "The tool manifest is enforced but unsigned; tamper-evidence is not tamper-proofing.", ], ]; + +const SUMMARY_LABEL: Record = { + substantial: "substantial", + partial: "partial", + na: "architectural N/A", +}; + +/** The table caption, counted from the rows so it cannot drift from them. */ +export function coverageSummary( + rows: readonly (readonly [string, string, CoverageLevel, string])[], +): string { + const order: CoverageLevel[] = ["substantial", "partial", "na"]; + return order + .map((level) => [level, rows.filter((r) => r[2] === level).length] as const) + .filter(([, n]) => n > 0) + .map(([level, n]) => `${n} ${SUMMARY_LABEL[level]}`) + .join(" · "); +} From af268a88e0c03fcbb2ad5e07331de7324b058c53 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 15:19:59 -0700 Subject: [PATCH 49/55] Reconcile the docs with the code and the primary message Approved item by item, each checked against the code: - Rate limits and data-volume budgets are opt-in; only circuit breakers are on by default (safe-tools, OWASP ASI02). - The prompt-injection classifier ships; the OWASP page and the coverage HTML no longer say it doesn't. - Prompt delimiters are described as an aid to the model, not the boundary; the boundary is that tool output cannot reach a privileged action without the platform's checks (agent-loop, agent-definition, OWASP ASI01). - `oap install` examples pass --builder-starters, with a note that it or --without-builder is required. - Only GKE provisions artifact storage; EKS and AKS take --artifact-store-url. - The docs entry page leads with the primary message and defines a primitive. - One rule for who checks run against: the user an agent acts for, or a userless session's own principal (the session or a declared service) with no standing grants. - Sandboxes get micro-VM isolation when their class sets a runtime class, not by default. - install-desktop carries the build commands; ASI05/ASI06 gaps are stated plainly; the OWASP primitive names and Authorization link are fixed. - ASI07 is re-rated Partial on the docs page, in channels, and in the coverage HTML. - `oap channel` help no longer calls webhooks and schedules "future"; the CLI reference is regenerated. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/oap/internal/channelcmd/root.go | 2 +- docs/owasp-agentic-top10-coverage.html | 71 +++++++++++----------- site/content/docs/agent-builder.mdx | 4 +- site/content/docs/agent-definition.mdx | 5 +- site/content/docs/agent-loop.mdx | 22 ++++--- site/content/docs/channels.mdx | 5 +- site/content/docs/cli-reference.mdx | 2 +- site/content/docs/health-upgrades.mdx | 8 +++ site/content/docs/identity.mdx | 5 +- site/content/docs/install-cluster.mdx | 16 +++-- site/content/docs/install-desktop.mdx | 7 +++ site/content/docs/install-overview.mdx | 5 +- site/content/docs/memory-authorization.mdx | 2 +- site/content/docs/oap-channel.mdx | 4 +- site/content/docs/owasp-top10.mdx | 59 ++++++++++-------- site/content/docs/safe-tools.mdx | 4 +- site/content/docs/sandboxed-execution.mdx | 4 +- site/content/docs/what-is-oap.mdx | 6 +- 18 files changed, 134 insertions(+), 97 deletions(-) diff --git a/cmd/oap/internal/channelcmd/root.go b/cmd/oap/internal/channelcmd/root.go index d982ff6..293d1ef 100644 --- a/cmd/oap/internal/channelcmd/root.go +++ b/cmd/oap/internal/channelcmd/root.go @@ -12,7 +12,7 @@ func NewCmd(g *apcmd.Globals) *cobra.Command { cmd := &cobra.Command{ Use: "channel", Aliases: []string{"channels", "ch"}, - Short: "Manage Channel CRs (Slack, future webhook/cron, ...).", + Short: "Manage Channel CRs (Slack, browser, GitHub webhooks, schedules, ...).", } cmd.AddCommand( newChannelListCmd(g), diff --git a/docs/owasp-agentic-top10-coverage.html b/docs/owasp-agentic-top10-coverage.html index 7b3199c..ef4ba2a 100644 --- a/docs/owasp-agentic-top10-coverage.html +++ b/docs/owasp-agentic-top10-coverage.html @@ -91,7 +91,7 @@ } .distro > div { display: flex; align-items: center; justify-content: center; color: #0c050f; } .distro .s-full { background: var(--cov-full); flex: 4; } - .distro .s-partial { background: var(--cov-partial); flex: 5; } + .distro .s-partial { background: var(--cov-partial); flex: 6; } .distro .s-minimal { background: var(--cov-minimal); flex: 1; } .distro .s-na { background: var(--cov-na); flex: 1; } .distro-note { color: var(--fg-faint); font-size: 12px; } @@ -229,17 +229,16 @@

    At a glance

    ap's defenses concentrate at the action boundary (tools, identity, authorization, human approval) and the execution boundary (sandboxed, non-root, no-shell, default-on per-tool - circuit breakers / rate limits, and default-on runner + sandbox egress NetworkPolicies). They thin out at + circuit breakers plus opt-in rate limits, and default-on runner + sandbox egress NetworkPolicies). They thin out at the input / content-integrity boundary (memory poisoning, indirect injection of retrieved content); supply-chain provenance now has content-hash pinning across dependency kinds but - still lacks signing / attestation. Multi-agent risks (ASI07/ASI08 - fan-out) are mostly off the table because ap runs one agent per session. + still lacks signing / attestation. Multi-agent risk (ASI07) is bounded by a closed, reviewed subagent + roster and typed, separately authorized parent↔child messages, but stays within one cluster.

    @@ -299,12 +298,12 @@

    Memory & Context Poisoning

    -
    -
    ASI07Architectural N/A
    +
    +
    ASI07Partial

    Insecure Inter-Agent Comms

    Tampered / spoofed / replayed messages between cooperating agents (A2A, MCP, message bus).

    -
    -
    single-agent
    +
    +
    channelsagent-def
    @@ -351,7 +350,7 @@

    Rogue Agents

    ASI04 — Agentic Supply Chain ASI05 — Unexpected Code Execution ASI06 — Memory & Context Poisoning - ASI07 — Insecure Inter-Agent Comms + ASI07 — Insecure Inter-Agent Comms ASI08 — Cascading Failures ASI09 — Human-Agent Trust Exploitation ASI10 — Rogue Agents @@ -374,10 +373,11 @@

    What ap does

    • Every tool result is wrapped in an unpredictable per-result nonce (<untrusted-tool-output nonce="…">) the output can't forge — and tool results are the only non-user ingress path: retrieval, web search, and even memory/KG recall arrive as wrapped tool output, and wrappers persist in the transcript across turns.pkg/agent/runner/loop.go:59–98, 1561
    • System prompt rule: treat everything inside a matched pair strictly as data to observe, never instructions — even "ignore previous instructions" style text. This is delimiter-based spotlighting with unforgeable markers, and prompt-level by design: a general-purpose agent must reason over the content itself, so quarantining it behind a sub-LLM or paraphrasing it would strip or alter exactly the meaning the task needs. The rule's first line is model compliance; the enforcement backstop is structural at the action boundary — dual validation, approval gates, the isolated summarizer — not input isolation.pkg/agent/runner/prompt.go:206
    • +
    • Tool output cannot widen what the agent may do: every action a tool result might prompt is checked outside the model, at the same pre-dispatch choke point as any other call. The delimiter and system-prompt items above help the model, but are not the boundary.pkg/agent/runner/loop_dispatch.go
    • High-impact / external actions are gated by human approval, so a hijacked goal can't silently execute irreversible side effects.pkg/authz/hooks/toolcallauthz.go
    • The approver's "What" is generated by an isolated summarizer LLM that never sees the (possibly-poisoned) primary chat — a hijacked agent can't talk its way past the gate (see ASI09).
    • Closed-world tools + least privilege bound the blast radius of any redirected goal.
    • -
    • A pluggable content-inspection seam now guards the tool-I/O ingress path — the only non-user ingress (see the nonce-wrap item above). Registry-keyed contentguard Inspector plugins examine the serialized tool args (PreToolCall) and the tool result (PostToolCall) and return pass / block / approve, mapped onto the existing pipeline (Block withholds the whole result from the model; Approve raises a human gate). Default-off, referenced from tiered settings by ID as a ceiling (lower tiers add, never weaken), and fail-closed: an inspector that errors blocks, an unregistered ID / unparseable config makes the settings object Invalid. The shipped example, url-allowlist, enforces an ordered domain-glob / regex / CEL URL policy (per-rule allow/deny/approve) over URLs found in tool I/O — blocking exfiltration / injection-carrier URLs in tool output before they reach the model.pkg/authz/contentguard/; pkg/authz/contentguard/kinds/urlallowlist/; pkg/authz/contentguard/hook.go; pkg/apis/v1alpha1/settings_common_types.go (ContentInspectors)
    • +
    • A pluggable content-inspection seam now guards the tool-I/O ingress path — the only non-user ingress (see the nonce-wrap item above). Registry-keyed contentguard Inspector plugins examine the serialized tool args (PreToolCall) and the tool result (PostToolCall) and return pass / block / approve, mapped onto the existing pipeline (Block withholds the whole result from the model; Approve raises a human gate). Default-off, referenced from tiered settings by ID as a ceiling (lower tiers add, never weaken), and fail-closed: an inspector that errors blocks, an unregistered ID / unparseable config makes the settings object Invalid. Two inspectors ship. prompt-injection runs a classifier over inspected text in a per-session pod with egress denied unconditionally, and blocks content above a threshold. url-allowlist enforces an ordered domain-glob / regex / CEL URL policy (per-rule allow/deny/approve) over URLs found in tool I/O — blocking exfiltration / injection-carrier URLs in tool output before they reach the model.pkg/authz/contentguard/; pkg/authz/contentguard/kinds/promptinjection/; pkg/authz/contentguard/kinds/urlallowlist/; pkg/authz/contentguard/hook.go; pkg/apis/v1alpha1/settings_common_types.go (ContentInspectors)
    @@ -385,7 +385,7 @@

    What's missing

    • No goal-lock / "intent capsule." The declared goal isn't bound into a signed envelope per cycle, so subtle goal drift isn't detected.
    • No plan-divergence detection. The plans state tracks step status but never alerts when the agent deviates from its declared plan.pkg/agent/session/state/plans/
    • -
    • The content-inspection seam exists, but no prompt-injection / prompt-carrier detector ships in it yet. The contentguard framework + the url-allowlist example (see left) cover URL-policy exfil/injection-carrier guarding, but there is no built-in CDR (content disarm & reconstruction) or injection-payload classifier inspector — that detector is the next inspector kind, not a new framework. Coverage is also tool-I/O-scoped: meta tools (respond_to_user et al.) bypass the gate pipeline, and memory/KG writes aren't inspected (ASI06).
    • +
    • Inspection covers tool I/O, not the agent's own replies. The prompt-injection and url-allowlist inspectors (see left) examine tool args and results; there is no built-in CDR (content disarm & reconstruction) inspector, and memory/KG writes aren't inspected (ASI06).
    @@ -410,7 +410,7 @@

    What ap does

  • Secret scrubbing + out-of-band secretout: declared secrets in tool output are diverted to a session store and replaced with an opaque handle the LLM never reads.pkg/tools/redact/, pkg/agent/secretout/
  • SSRF-guarded HTTP for MCP; turn / token / duration budgets bound runaway loops.pkg/x/safehttp/, pkg/agent/runner/budget.go
  • Capability drift is enforced per the effective pinning mode: a block rule withholds drifted tools at session start, approve escalates their calls to human approval (an empty rule mode defaults to approve), and drift is recorded in observedPins + audit pin facts.cmd/runner/pindrift.go:10–24; pkg/platform/settings/pinning.go:11–15
  • -
  • Per-tool rate limits are enforced at dispatch and default-on: a toolguard Guard hook runs at PreToolCall (before the SpiceDB check) and denies calls that exceed maxCallsPerTurn or maxCalls + window; even with no policy declared, a builtin rule applies. This directly bounds the OWASP "ping in a loop to exfiltrate via DNS" pattern (ASI02 scenario 7).pkg/apis/v1alpha1/toolguard_types.go:87–101; pkg/authz/toolguard/state.go:217–239; pkg/authz/toolguard/hook_guard.go:45–102; pkg/agent/runner/pipeline_wiring.go:794–814
  • +
  • Per-tool rate limits are enforced at dispatch, opt-in: a toolguard Guard hook runs at PreToolCall (before the SpiceDB check) and denies calls that exceed maxCallsPerTurn or maxCalls + window. The builtin rule that applies with no policy declared turns the circuit breaker on and leaves rate limits off, so a limit binds once a ToolGuardPolicy tier sets one. This directly bounds the OWASP "ping in a loop to exfiltrate via DNS" pattern (ASI02 scenario 7).pkg/apis/v1alpha1/toolguard_types.go:87–101; pkg/authz/toolguard/policy.go:11; pkg/authz/toolguard/state.go:217–239; pkg/authz/toolguard/hook_guard.go:45–102; pkg/agent/runner/pipeline_wiring.go:794–814
  • Per-call data-volume budgets are enforced in both directions. A toolguard DataLimit caps the serialized tool-args size at PreToolCall (egress — over-limit denies before the tool runs) and the tool-result size at PostToolCall (ingress — over-limit withholds the result, swapping in an IsError the model never reads), so a single call can't move an unbounded payload past its cap. The ingress cap applies to error results too — a tool can't mark an oversized exfil payload IsError to slip it through — and the limit is available per-tool and as a tiered, strictest-wins ceiling.pkg/apis/v1alpha1/toolguard_types.go:105–132,164–172; pkg/authz/toolguard/hook_guard.go:58–62; pkg/authz/toolguard/hook_record.go; pkg/authz/toolguard/events.go
  • @@ -418,7 +418,7 @@

    What ap does

    What's missing

    • Egress enforcement is partial. Runner and sandbox pods now get default-on NetworkPolicies (ingress-deny + egress allowlist; sandbox network mode none or a missing class is a fail-closed full egress deny), but enforcement is L3/L4 only — label selectors, CIDRs, and ports. Allowlist-mode sandboxes get coarse TCP 443/80 to any address; the recorded effectiveAllowedHosts hostnames are not enforced without a DNS-aware CNI.pkg/controllers/agentsession/netpol.go:167–180,323
    • -
    • The data-volume budget is per-call, not cumulative, and opt-in (default-off, unlike the default-on rate/breaker rules). A per-call byte cap (see left) bounds any single payload, but a tool making many calls each under its egress cap can still move large total volume across a turn — there is no aggregate per-turn / per-session egress budget — and no byte limit binds at all unless a rule or ceiling configures one.pkg/apis/v1alpha1/toolguard_types.go:105–112
    • +
    • The data-volume budget is per-call, not cumulative, and opt-in (default-off, like rate limits and unlike the default-on breaker). A per-call byte cap (see left) bounds any single payload, but a tool making many calls each under its egress cap can still move large total volume across a turn — there is no aggregate per-turn / per-session egress budget — and no byte limit binds at all unless a rule or ceiling configures one.pkg/apis/v1alpha1/toolguard_types.go:105–112
    • Typosquat protection only via toolkit-revision + manifest-hash pinning, not signed endpoints; drift enforcement is observe-only for kinds no pinning rule covers.
    @@ -557,28 +557,28 @@

    What's missing

    -
    -

    ASI07 Insecure Inter-Agent Communication Architectural N/A

    +
    +

    ASI07 Insecure Inter-Agent Communication Partial

    Multi-agent systems are exposed to intercepted, spoofed, or replayed messages between cooperating agents - (A2A, MCP, message bus). ap runs one agent per session; channels are - human↔agent, not agent↔agent — so this class is largely out of scope today. + (A2A, MCP, message bus). In ap, agents talk to each other only through delegation: a parent + session hands work to a subagent session, and the two exchange a small set of typed messages.

    -

    Why it's out of scope (and where the seam is)

    +

    What ap does

      -
    • Each AgentSession is a single runner pod with one LLM loop; there is no agent-to-agent delegation or A2A registry.
    • -
    • NATS carries human↔agent envelopes and operator↔runner control only — not cross-runner agent messaging.README.md (architecture)
    • -
    • Per-session pods, per-session Secrets, and scoped RBAC keep one session's traffic off another's.
    • +
    • Delegation reaches only a closed, reviewed subagent roster, validated as a DAG (no cycles); entries can be pinned to an exact bundle digest.
    • +
    • The delegation tree is bounded: maxDelegatedAgents caps its size, and each roster member has a mode ceiling. Each subagent is its own scoped, budgeted, audited AgentSession.
    • +
    • Parent and child exchange only three typed, separately authorized directions (ask_parent, return_result, reply_to_subagent), delivered as inspected tool results. Free-form agent-to-agent messages were deliberately removed.pkg/apis/v1alpha1/subagentrequest_types.go
    • +
    • The sender is authenticated by its own per-session bus subject, and the destination must match a real Channel joining the two sessions.pkg/channels/channelkinds/agent/sender.go
    • +
    • Passing data past its audience parks for the data owner's approval, so delegation can't launder a disclosure boundary.
    -

    What to watch if multi-agent lands

    +

    What's missing

      -
    • The transport that does exist isn't fully locked down: the channelsd NATS JWT subject scope is broad (ap.> + _INBOX.>), though runner per-session JWTs are already narrowly scoped.
    • -
    • No mutual-auth / signed-message / anti-replay machinery exists to build on — it would be green-field if A2A is added.
    • -
    • No attested agent registry or agent-card verification.
    • +
    • Delegation stays within one cluster; there is no protocol for agents outside it.
    @@ -701,7 +701,7 @@

    Where the gaps cluster

    - + @@ -719,7 +719,7 @@

    Where the gaps cluster

    - + @@ -731,11 +731,10 @@

    Where the gaps cluster

    classic agent-authorization risks — tool misuse, privilege abuse, unsafe execution, and human-approval manipulation (ASI02/03/05/09). The work ahead is mostly at two frontiers the design hasn't reached yet: content integrity (treating untrusted data and memory as adversarial, not just access-controlled) — - where a pluggable contentguard inspection seam over tool I/O is now the first foothold, awaiting a - prompt-injection/CDR detector and memory-write validation — and supply-chain provenance - (verifying where tools and descriptors come from). Multi-agent risks - (ASI07/08) stay parked until/unless ap grows agent-to-agent topology — at which point inter-agent - auth becomes green-field work, not a retrofit. + where a pluggable contentguard inspection seam over tool I/O, with a prompt-injection classifier and a + URL allowlist, is now the first foothold, awaiting CDR and memory-write validation — and supply-chain provenance + (verifying where tools and descriptors come from). Inter-agent communication (ASI07) is bounded to delegation + inside one cluster; there is no protocol for agents outside it. @@ -747,8 +746,8 @@

    Mapped to the six primitives

    - - + +
    4 substantial · 5 partial · 1 architectural N/A{coverageSummary(OWASP)}
    Item
    Input / content integrityA pluggable content-inspection seam (contentguard) now guards the tool-I/O ingress path with a url-allowlist example — but no prompt-injection/CDR detector inspector ships yet, meta tools bypass it, and memory/KG writes are still uninspected (no content validation on memory writes; bootstrap-poisoning via self-ingestion; no provenance scores).A pluggable content-inspection seam (contentguard) now guards the tool-I/O ingress path with a prompt-injection classifier and a url-allowlist inspector — but inspection covers tool I/O, not the agent's own replies, no CDR inspector ships, and memory/KG writes are still uninspected (no content validation on memory writes; assistant turns ingested into the knowledge graph without separate validation; no provenance scores). ASI01 · ASI06 pkg/authz/contentguard/; facade.go:108–127, kgingestion/hooks.go
    Intra-session isolationTier-1 sandbox isolation deferred — ToolCalls in a session share UID / /proc / /work. (Per-tool rate limits / circuit breakers are now enforced and default-on via pkg/toolguard in the dispatch pipeline.)Tier-1 sandbox isolation deferred — ToolCalls in a session share UID / /proc / /work. (Per-tool circuit breakers are now enforced and default-on via pkg/authz/toolguard in the dispatch pipeline; rate limits are enforced there too, but opt-in.) ASI05 · ASI08 Open
    Safe toolsASI02, ASI04, ASI05, ASI01Strong on misuse/RCE; weak on supply-chain provenance.
    Identity & credentialsASI03Strong; TOCTOU + sidecar-freeze are the edges.
    AuthorizationASI02, ASI03, ASI06, ASI10Strong per-action checks; no behavioral attestation.
    Agent definitionASI01, ASI08, ASI09Approval + budgets + isolation strong; no plan-divergence.
    Channels & continuityASI09, (ASI07)Human approval strong; agent↔agent absent by design.
    Agent definitionASI01, ASI07, ASI08, ASI09Approval + budgets + isolation strong; no plan-divergence.
    Channels & continuityASI09, ASI07Human approval strong; agent↔agent limited to typed, authorized delegation messages.
    Memory & knowledgeASI06, ASI10Access-control strong; content-integrity thin.
    diff --git a/site/content/docs/agent-builder.mdx b/site/content/docs/agent-builder.mdx index d15586d..d8f8cff 100644 --- a/site/content/docs/agent-builder.mdx +++ b/site/content/docs/agent-builder.mdx @@ -21,8 +21,8 @@ export const meta = { ## What it is, and what it is not -The builder is itself an ordinary agent — an `AgentClass` named `agent-builder`, installed once per cluster by -`oap install`. It runs under the same guardrails as everything else here: a plan it must declare, stages you +The builder is itself an ordinary agent — an `AgentClass` named `agent-builder`, installed by `oap install` when you +name who may start it. It runs under the same guardrails as everything else here: a plan it must declare, stages you approve, tools it calls through the platform, an audit trail. What makes it different is where it works and what it produces. diff --git a/site/content/docs/agent-definition.mdx b/site/content/docs/agent-definition.mdx index dbba456..9d48ef7 100644 --- a/site/content/docs/agent-definition.mdx +++ b/site/content/docs/agent-definition.mdx @@ -40,8 +40,9 @@ thing that acts. The class as a reviewable, budgeted, isolated unit is OAP's answer to several agentic threats:
      -
    • ASI01 — Agent Goal Hijack: tool output is treated as data, never - instructions, and high-impact actions are gated.
    • +
    • ASI01 — Agent Goal Hijack: tool output cannot widen what the agent + may do — every resulting action is checked outside the model, and high-impact actions can be gated. + Delimiting tool output as data is a secondary aid.
    • ASI08 — Cascading Failures: per-session isolation, budgets, and default-on circuit breakers contain blast radius.
    • ASI09 — Human-Agent Trust Exploitation: the approval design that diff --git a/site/content/docs/agent-loop.mdx b/site/content/docs/agent-loop.mdx index 38a1bbb..23ed47e 100644 --- a/site/content/docs/agent-loop.mdx +++ b/site/content/docs/agent-loop.mdx @@ -22,16 +22,18 @@ proposes zero or more tool calls. Each proposed call is **dispatched through a c survives them does it reach the sandbox or MCP server. The results come back as content, the model continues, and the turn ends when the agent produces a reply or hits a budget or the watchdog. -## Tool output is data, never instructions - -A tool result is treated as *content the agent read*, not as commands it must obey. Text that comes back from -a web page, a file, or an API — however imperative it sounds — can't cause the agent to take an action without -that action going through the same gates as any other tool call. Mechanically, every result is wrapped in -nonce-tagged delimiters before it's shown to the model, and the system prompt tells the model to treat -anything inside those tags strictly as data — and to *report*, not obey, any instructions it finds there -("ignore previous instructions", "now call tool X", "the user approved…"). This is the structural answer to -prompt injection: there is no path from "a tool returned some text" to "a privileged action ran" that skips -the checks. +## Tool output cannot authorize anything + +A tool result is *content the agent read*, not a source of authority. Text that comes back from a web page, a +file, or an API — however imperative it sounds — can't cause the agent to take an action without that action +going through the same gates as any other tool call. That is the boundary against prompt injection: no text a +tool returns can reach a privileged action without the platform's checks. + +The model also gets help recognizing injected text. Every result is wrapped in nonce-tagged delimiters before +it's shown to the model, and the system prompt tells the model to treat anything inside those tags strictly as +data — and to *report*, not obey, any instructions it finds there ("ignore previous instructions", "now call +tool X", "the user approved…"). The delimiters and that rule help the model, but are not the boundary: a model +that follows an injected instruction anyway still can't make a denied call run. ## Where the safety checks sit diff --git a/site/content/docs/channels.mdx b/site/content/docs/channels.mdx index f67d8a1..0387785 100644 --- a/site/content/docs/channels.mdx +++ b/site/content/docs/channels.mdx @@ -40,7 +40,8 @@ approve is an authorization question, not "whoever can type."
    • ASI09 — Human-Agent Trust Exploitation (substantial): the in-channel approval design — explicit confirmation on risky actions, an injection-isolated summarizer, and the approver derived from the session, not the sender.
    • -
    • ASI07 — Insecure Inter-Agent Communication (N/A): OAP's channels are - human↔agent, not agent↔agent, so there is no inter-agent bus to secure.
    • +
    • ASI07 — Insecure Inter-Agent Communication (partial): agent-to-agent + messages travel only between delegating sessions, as typed, authorized directions over a Channel joining + the two, with the sender authenticated by its own session subject.
    diff --git a/site/content/docs/cli-reference.mdx b/site/content/docs/cli-reference.mdx index 099cebc..842c58f 100644 --- a/site/content/docs/cli-reference.mdx +++ b/site/content/docs/cli-reference.mdx @@ -19,7 +19,7 @@ from the CLI, so it matches the binary you have. Pick a family for its subcomman | [`oap artifact`](/docs/oap-artifact) | Inspect versioned artifacts and their revisions | | [`oap audit`](/docs/oap-audit) | Verify the tamper-evident audit log of a session | | [`oap build`](/docs/oap-build) | Build (and load) Docker images | -| [`oap channel`](/docs/oap-channel) | Manage Channel CRs (Slack, future webhook/cron, ...). | +| [`oap channel`](/docs/oap-channel) | Manage Channel CRs (Slack, browser, GitHub webhooks, schedules, ...). | | [`oap check`](/docs/oap-check) | verify namespace, CRDs, services, and every registered component's health | | [`oap class`](/docs/oap-class) | Inspect SpiceboxClass resources (sandbox image + capability descriptors) | | [`oap clean`](/docs/oap-clean) | Remove everything oap installed (CRs → operator → CRDs → namespace) | diff --git a/site/content/docs/health-upgrades.mdx b/site/content/docs/health-upgrades.mdx index ab5b3f3..bb644a0 100644 --- a/site/content/docs/health-upgrades.mdx +++ b/site/content/docs/health-upgrades.mdx @@ -31,6 +31,14 @@ apply is idempotent — an unchanged component is left alone, and only what actu re-running it is safe. The resolved [cluster kind](/docs/install-cluster) is stamped onto the components at install, which is why upgrading a pre-cluster-kind install means re-running `oap install` so the kind is set explicitly. +```bash +oap install --builder-starters user:$(oap identity canonical-id you@example.com) +``` + +`oap install` needs either `--builder-starters` (who may start Agent Builder) or `--without-builder`; see +[Agent Builder](/docs/agent-builder-install). The starter list replaces what was there, so pass the full list on every +upgrade. + The platform is deliberately fail-closed about its own configuration — a missing artifact store, an unrecognized cluster kind, or an unset required backend crash-loops the component rather than starting in a diff --git a/site/content/docs/identity.mdx b/site/content/docs/identity.mdx index ce1f3fe..8802b1c 100644 --- a/site/content/docs/identity.mdx +++ b/site/content/docs/identity.mdx @@ -48,7 +48,8 @@ what everything at runtime reads.
    • ASI03 — Identity & Privilege Abuse (substantial): per-agent identity with no shared credential catalog, on-behalf-of with credential narrowing, a just-in-time token broker, - and the structural rule that agents are not authorization subjects — only users are, which blocks - the confused-deputy problem.
    • + and the structural rule that an agent never acts on authority of its own: checks run against the + user it acts for, or, for a session with no user, a principal of its own (the session itself, or a service it declares) with no standing grants. That + blocks the confused-deputy problem.
    diff --git a/site/content/docs/install-cluster.mdx b/site/content/docs/install-cluster.mdx index 6719925..a8bee3a 100644 --- a/site/content/docs/install-cluster.mdx +++ b/site/content/docs/install-cluster.mdx @@ -21,11 +21,15 @@ the cluster's node providers, falling back to a sensible default — the local a and never auto-detected, so you can't accidentally land a dev profile on production infrastructure. ```bash -oap install --local # local dev cluster (kind / Docker Desktop) -oap install --cluster-kind gke # a managed GKE cluster -oap install # detect from the cluster, default if unknown +STARTERS="user:$(oap identity canonical-id you@example.com)" +oap install --local --builder-starters "$STARTERS" # local dev cluster (kind / Docker Desktop) +oap install --cluster-kind gke --builder-starters "$STARTERS" # a managed GKE cluster +oap install --builder-starters "$STARTERS" # detect from the cluster, default if unknown ``` +`oap install` needs either `--builder-starters` (who may start Agent Builder) or `--without-builder`; see +[Agent Builder](/docs/agent-builder-install). + ## The profiles | Cluster kind | Memory | SpiceDB datastore | Images | For | @@ -35,9 +39,9 @@ oap install # detect from the cluster, default if unknow | `default` | Postgres | Postgres (durable) | registry | on-prem / bare-metal / kind | | `gke` · `eks` · `aks` | Postgres | Postgres | registry | managed cloud, durable | -The managed-cloud kinds provision cloud object storage for artifacts and require an external hostname (so the -platform is reachable and TLS terminates correctly); the local and desktop kinds keep everything on a local -volume. The install validates the kind against the cluster *before* it changes anything, so a wrong kind fails +GKE can provision a bucket for artifacts; EKS and AKS take one you supply with `--artifact-store-url`. The +managed-cloud kinds require an external hostname (so the platform is reachable and TLS terminates correctly); +the local and desktop kinds keep everything on a local volume. The install validates the kind against the cluster *before* it changes anything, so a wrong kind fails fast rather than half-installing. diff --git a/site/content/docs/install-desktop.mdx b/site/content/docs/install-desktop.mdx index 3b48714..9cddbe0 100644 --- a/site/content/docs/install-desktop.mdx +++ b/site/content/docs/install-desktop.mdx @@ -17,6 +17,13 @@ export const meta = { ## First run, step by step +Build the app, then open it: + +```bash +mage desktop:all +open build/desktop/out/oap.app +``` + 1. **Open the app.** Launch `oap.app`. The first time, right-click → **Open** (it's ad-hoc signed for local use). A menubar icon appears, animating a "setting up" state. 2. **Configure.** A native setup window opens with a short form: diff --git a/site/content/docs/install-overview.mdx b/site/content/docs/install-overview.mdx index 43a80a1..a2567a0 100644 --- a/site/content/docs/install-overview.mdx +++ b/site/content/docs/install-overview.mdx @@ -23,11 +23,14 @@ export const meta = { ```bash oap init # build + install + check (first run) -oap install # platform only, into the configured cluster +oap install --builder-starters user:$(oap identity canonical-id you@example.com) # platform only oap check # verify every component is healthy oap check --watch # keep checking every couple of seconds ``` +`oap install` needs either `--builder-starters` (who may start Agent Builder) or `--without-builder`; see +[Agent Builder](/docs/agent-builder-install). + ## Prerequisites - A reachable **Kubernetes cluster** — a local one (kind, Docker Desktop) for development, or a managed cluster diff --git a/site/content/docs/memory-authorization.mdx b/site/content/docs/memory-authorization.mdx index f6fdd85..ab872d4 100644 --- a/site/content/docs/memory-authorization.mdx +++ b/site/content/docs/memory-authorization.mdx @@ -11,7 +11,7 @@ export const meta = {
    Memory isn't a shared bucket the agent can rummage through. Every read and write is authorized per - subject — the acting user or agent — through the same SpiceDB graph as tools, behind a door that + subject — the acting user — through the same SpiceDB graph as tools, behind a door that fails closed.
    diff --git a/site/content/docs/oap-channel.mdx b/site/content/docs/oap-channel.mdx index 07d4b5c..570f26f 100644 --- a/site/content/docs/oap-channel.mdx +++ b/site/content/docs/oap-channel.mdx @@ -3,12 +3,12 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2750, - description: 'Manage Channel CRs (Slack, future webhook/cron, ...).', + description: 'Manage Channel CRs (Slack, browser, GitHub webhooks, schedules, ...).', } # oap channel -Manage Channel CRs (Slack, future webhook/cron, ...). +Manage Channel CRs (Slack, browser, GitHub webhooks, schedules, ...). ## `oap channel apply -f ` diff --git a/site/content/docs/owasp-top10.mdx b/site/content/docs/owasp-top10.mdx index 0c3a852..8223cfb 100644 --- a/site/content/docs/owasp-top10.mdx +++ b/site/content/docs/owasp-top10.mdx @@ -16,23 +16,24 @@ export const meta = { is a qualitative judgement, and honest about what's missing.
    -Across the ten: **4 substantially addressed · 5 partial · 1 architecturally not applicable.** +Across the ten: **4 substantially addressed · 6 partial.** Each item links back to the concept that addresses it, and each concept's overview links here. "Substantial" - means the threat is a design center of OAP; "Partial" means real defenses with named gaps; "Architectural - N/A" means the threat is out of scope for OAP's single-agent-per-session shape. + means the threat is a design center of OAP; "Partial" means real defenses with named gaps.

    ASI01 — Agent Goal Hijack

    -**What OAP does.** Tool results are the *only* non-user ingress path, and every one is wrapped in an -unpredictable per-result nonce; the system prompt's spotlighting rule treats delimited content as data to -observe, never instructions. High-impact and *external* actions are human-approval-gated, and a pluggable -content-guard seam inspects tool I/O (a URL-allowlist inspector ships as an example). — see -[Safe tools](/docs/safe-tools) and [Agent definition](/docs/agent-definition). +**What OAP does.** Tool output cannot widen what the agent may do: every resulting action is checked outside +the model, and high-impact and *external* actions can be human-approval-gated. Tool results are the *only* +non-user ingress path; as a secondary aid, each is wrapped in an unpredictable per-result nonce and the system +prompt's spotlighting rule tells the model to treat delimited content as data, never instructions. A pluggable +content-guard seam inspects tool I/O, and two inspectors ship: a prompt-injection classifier (run in a +zero-egress pod) and a URL allowlist. — see [Safe tools](/docs/safe-tools), +[Content guards & egress](/docs/content-guards-egress), and [Agent definition](/docs/agent-definition). -**Gaps.** No goal-lock or plan-divergence detection; no prompt-injection classifier ships as an inspector yet. +**Gaps.** No goal-lock or plan-divergence detection; inspection covers tool I/O, not the agent's own replies.

    ASI02 — Tool Misuse & Exploitation

    @@ -40,7 +41,8 @@ content-guard seam inspects tool I/O (a URL-allowlist inspector ships as an exam execution time — behind deny-by-default subcommand/field allowlists and CEL, and they run as **argv arrays, never through a shell**. Each tool's `stateImpact` (readonly / readwrite / external) drives the authorization check and whether a human is asked; every action is checked per-action against SpiceDB. Secrets are scrubbed -from output, and default-on per-tool rate limits and per-call data-volume budgets bound runaway use. — see +from output; default-on circuit breakers, plus opt-in per-tool rate limits and per-call data-volume budgets, +bound runaway use. — see [Safe tools](/docs/safe-tools). **Gaps.** Egress control is L3/L4 only; the volume budget is per-call, not cumulative. @@ -50,8 +52,9 @@ from output, and default-on per-tool rate limits and per-call data-volume budget **What OAP does.** Each agent has its own `AgentIdentity` — there is no shared credential catalog. Acting on a user's behalf goes through `UserIdentity → SessionUserIdentity` with the credential set narrowed to what the session's agent asked for, and a token broker resolves credentials just-in-time (a valet key, fail-closed). -Critically, **agents are not SpiceDB subjects — only users are**, which structurally blocks the confused-deputy -problem; grants are bound to an arguments-hash HMAC caveat, and credential revocation reaches in-flight +Critically, **an agent never acts on authority of its own**: checks run against the user it acts for, or, for a +session with no user, a principal of its own (the session itself, or a service it declares) with no standing grants — which structurally blocks the +confused-deputy problem; grants are bound to an arguments-hash HMAC caveat, and credential revocation reaches in-flight sessions. — see [Identity](/docs/identity). **Gaps.** Sidecar credentials are frozen at pod-create (no mid-execution re-auth); revocation delivery is @@ -72,7 +75,8 @@ seccomp `RuntimeDefault`, no service-account token. Tools execute as **argv arra shell, never `eval`** — against a closed set of tools, in per-session sandbox pods. — see [Safe tools](/docs/safe-tools). -**Gaps.** Egress is L3/L4 only; stronger per-call intra-session isolation is deferred. +**Gaps.** Egress is L3/L4 only. Isolation is per session: tool calls within one session share a UID, /proc, +/tmp and /work.

    ASI06 — Memory & Context Poisoning

    @@ -82,16 +86,21 @@ entries are tagged untrusted, and the transcript, authz decisions, and audit kin Ed25519-signed, tamper-evident log** verifiable offline with `oap audit verify`. — see [Memory](/docs/memory) and [Authorization](/docs/authorization). -**Gaps.** No content validation on writes; a bootstrap-poisoning path exists via self-ingestion of assistant -turns. +**Gaps.** No content validation on memory writes. Assistant turns are ingested into the knowledge graph without +separate validation, so a poisoned turn can influence later recall. -

    ASI07 — Insecure Inter-Agent Communication

    +

    ASI07 — Insecure Inter-Agent Communication

    -**Why it's out of scope.** OAP runs **one agent per session**; its channels are human↔agent, not agent↔agent, -so there is no inter-agent message bus to secure. — see [Channels](/docs/channels). +**What OAP does.** Delegation reaches only a **closed, reviewed subagent roster**, validated as a DAG, with +entries that can be pinned to a bundle digest. The delegation tree is bounded (`maxDelegatedAgents`, and a +per-member mode ceiling), and each subagent is its own scoped, budgeted, audited AgentSession. Parent and child +exchange only three typed, separately authorized directions — `ask_parent`, `return_result`, and +`reply_to_subagent` — delivered as inspected tool results; free-form agent-to-agent messages were deliberately +removed. The sender is authenticated by its own per-session bus subject, and the destination must match a real +Channel joining the two sessions. Passing data past its audience parks for the data owner's approval. — see +[Subagents & delegation](/docs/subagents-delegation) and [Channels](/docs/channels). -**If multi-agent lands.** The seams to harden would be the channelsd NATS subject scope and the absence of -mutual authentication / anti-replay between agents. +**Gaps.** Delegation stays within one cluster; there is no protocol for agents outside it.

    ASI08 — Cascading Failures

    @@ -131,11 +140,11 @@ tamper-*evidence* is not tamper-*proofing*; there is no self-replication guard. Each concept is designed around specific threats: - **[Safe tools](/docs/safe-tools)** → [ASI02](/docs/owasp-top10#asi02), [ASI04](/docs/owasp-top10#asi04), [ASI05](/docs/owasp-top10#asi05), [ASI01](/docs/owasp-top10#asi01) -- **[Identity](/docs/identity)** → [ASI03](/docs/owasp-top10#asi03) -- **[Authorization](/docs/multiplayer-sessions)** → [ASI02](/docs/owasp-top10#asi02), [ASI03](/docs/owasp-top10#asi03), [ASI06](/docs/owasp-top10#asi06), [ASI10](/docs/owasp-top10#asi10) -- **[Agent definition](/docs/agent-definition)** → [ASI01](/docs/owasp-top10#asi01), [ASI08](/docs/owasp-top10#asi08), [ASI09](/docs/owasp-top10#asi09) -- **[Channels](/docs/channels)** → [ASI09](/docs/owasp-top10#asi09), ASI07 (N/A) -- **[Memory](/docs/memory)** → [ASI06](/docs/owasp-top10#asi06), [ASI10](/docs/owasp-top10#asi10) +- **[Identity & credentials](/docs/identity)** → [ASI03](/docs/owasp-top10#asi03) +- **[Authorization](/docs/authorization)** → [ASI02](/docs/owasp-top10#asi02), [ASI03](/docs/owasp-top10#asi03), [ASI06](/docs/owasp-top10#asi06), [ASI10](/docs/owasp-top10#asi10) +- **[Agent definition](/docs/agent-definition)** → [ASI01](/docs/owasp-top10#asi01), [ASI07](/docs/owasp-top10#asi07), [ASI08](/docs/owasp-top10#asi08), [ASI09](/docs/owasp-top10#asi09) +- **[Channels & continuity](/docs/channels)** → [ASI07](/docs/owasp-top10#asi07), [ASI09](/docs/owasp-top10#asi09) +- **[Memory & knowledge](/docs/memory)** → [ASI06](/docs/owasp-top10#asi06), [ASI10](/docs/owasp-top10#asi10) --- diff --git a/site/content/docs/safe-tools.mdx b/site/content/docs/safe-tools.mdx index 0e1f627..47a5f16 100644 --- a/site/content/docs/safe-tools.mdx +++ b/site/content/docs/safe-tools.mdx @@ -27,8 +27,8 @@ never through a shell, never `eval`** — so there's no string to inject into. Every tool declares its `stateImpact` — `readonly`, `readwrite`, or `external` — and that drives both the authorization check and whether a human is asked before it runs. The sandbox pods themselves are hardened -(non-root, read-only root filesystem, no ambient service-account token), and default-on rate limits, -circuit breakers, and per-call data-volume budgets keep a misbehaving tool from running away. +(non-root, read-only root filesystem, no ambient service-account token), and default-on circuit breakers, +plus optional rate limits and per-call data-volume budgets, keep a misbehaving tool from running away. ## Go deeper diff --git a/site/content/docs/sandboxed-execution.mdx b/site/content/docs/sandboxed-execution.mdx index 068e89c..c8193ba 100644 --- a/site/content/docs/sandboxed-execution.mdx +++ b/site/content/docs/sandboxed-execution.mdx @@ -21,8 +21,8 @@ Sandbox (and runner) pods are locked down by default: they run **non-root**, wit filesystem**, **all Linux capabilities dropped**, privilege escalation disabled, and the default seccomp profile applied. Crucially, the sandbox has **no ambient Kubernetes token** — the service-account token isn't mounted — so a tool can't turn around and call the cluster API with the pod's identity. Working directories are -small in-memory scratch space, and by default the sandbox runs under VM-grade isolation rather than sharing the -host kernel. +small in-memory scratch space. Sandboxes run as hardened pods; setting a runtime class such as `kata-fc` on the +sandbox class gives micro-VM isolation where the cluster provides it. ## Argv, never a shell diff --git a/site/content/docs/what-is-oap.mdx b/site/content/docs/what-is-oap.mdx index f43b8ab..dd3d9bf 100644 --- a/site/content/docs/what-is-oap.mdx +++ b/site/content/docs/what-is-oap.mdx @@ -4,13 +4,15 @@ export const meta = { group: 'Get started', order: 10, description: - 'Open Agent Primitives — a Kubernetes-native runtime for LLM agents, built so operating an agent safely is the default.', + 'Open Agent Primitives — a secure way to run enterprise AI agents, composed from basic building blocks and run in your own Kubernetes cluster on the models you choose.', } # What is OAP?
    - Open Agent Primitives (OAP) is a Kubernetes-native runtime for LLM agents. Defining an agent + Open Agent Primitives (OAP) is a secure way to run enterprise AI agents. A primitive is a basic + building block that agents rely on, whatever they do; OAP ships a working implementation of each, and agents + are composed from them. It runs in your own Kubernetes cluster on the models you choose. Defining an agent is easy — a prompt, some tools, a loop. Operating one safely is the hard part, and that's what OAP is for: an agent's identity, authorization, tools, memory, and channels are first-class, governed things rather than an afterthought. From 2ec976cb99ef90f74eeae2b4920332a0b66bd1cf Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 15:19:59 -0700 Subject: [PATCH 50/55] Make the docs navigable on phones Under 900px the sidebar used to be hidden entirely, taking the guide list, search and home link with it. It now becomes a sticky top bar with the wordmark, the search field and a Menu button; Menu opens the guide list as an overlay beneath the bar. The menu closes on navigation and on Escape (returning focus to the button), and search results drop down across the full width. DocNav holds only the open state, so the page still has a single search box. Desktop is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- site/app/docs/docs.css | 96 ++++++++++++++++++++++++++++++++++++++ site/app/docs/layout.tsx | 38 ++++++++------- site/components/DocNav.tsx | 57 ++++++++++++++++++++++ 3 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 site/components/DocNav.tsx diff --git a/site/app/docs/docs.css b/site/app/docs/docs.css index 82bf06d..b58b197 100644 --- a/site/app/docs/docs.css +++ b/site/app/docs/docs.css @@ -489,10 +489,106 @@ body:has(.doc-app) { margin-bottom: 0; } +/* The Menu button only exists for the narrow layout below. */ +.doc-nav-toggle { + display: none; +} + +/* Under 900px the sidebar becomes a sticky top bar (brand, search, Menu), and + the guide list opens as an overlay filling the viewport beneath it. Search + results drop down over the page rather than growing the bar. */ @media (max-width: 900px) { + .doc-app { + flex-direction: column; + } .doc-nav { + --doc-bar-h: 56px; + width: 100%; + height: var(--doc-bar-h); + padding: 0 16px; + border-right: 0; + border-bottom: 1px solid var(--doc-border); + overflow: visible; + z-index: 20; + } + .doc-nav-bar { + height: 100%; + display: flex; + align-items: center; + gap: 12px; + } + .doc-brand-row { + margin: 0; + flex-shrink: 0; + } + .doc-brand-wordmark { + width: 118px; + } + .doc-brand-meta { display: none; } + /* Static, so the results below anchor to the whole bar rather than to + this narrow field. */ + .doc-search { + position: static; + flex: 1; + min-width: 0; + margin: 0; + } + /* Phones have no keyboard shortcut to hint at, and the field needs the room. */ + .doc-search-kbd { + display: none; + } + .doc-search-field .doc-search-input { + padding-right: 10px; + } + .doc-search-results, + .doc-search > .doc-search-note { + position: absolute; + top: calc(100% + 6px); + left: 12px; + right: 12px; + margin: 0; + max-height: 70vh; + overflow-y: auto; + padding: 6px 10px; + background: var(--doc-nav); + border: 1px solid var(--doc-border); + border-radius: 6px; + box-shadow: 0 8px 24px var(--doc-shadow); + } + .doc-nav-toggle { + display: inline-flex; + align-items: center; + flex-shrink: 0; + padding: 6px 12px; + border: 1px solid var(--doc-border); + border-radius: 6px; + background: var(--doc-bg); + color: var(--doc-text); + font: inherit; + font-size: 14px; + cursor: pointer; + } + .doc-nav-toggle:focus-visible { + outline: 2px solid var(--doc-accent); + outline-offset: 1px; + } + .doc-nav-list { + display: none; + } + .doc-nav[data-open="true"] .doc-nav-list { + display: block; + position: absolute; + top: 100%; + left: 0; + right: 0; + height: calc(100dvh - var(--doc-bar-h)); + overflow-y: auto; + padding: 8px 16px 32px; + background: var(--doc-nav); + border-top: 1px solid var(--doc-border); + } .doc-series { grid-template-columns: 1fr; } diff --git a/site/app/docs/layout.tsx b/site/app/docs/layout.tsx index 8dc86de..4bbf39d 100644 --- a/site/app/docs/layout.tsx +++ b/site/app/docs/layout.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { allGuides } from "@/lib/guides"; import { buildNav } from "@/lib/nav"; +import { DocNav } from "@/components/DocNav"; import { NavLink } from "@/components/NavLink"; import { Search } from "@/components/Search"; import { SiteFooter } from "@/components/SiteFooter"; @@ -15,21 +16,26 @@ export default async function DocsLayout({ const nav = buildNav(await allGuides()); return (
    - +
    {children} diff --git a/site/components/DocNav.tsx b/site/components/DocNav.tsx new file mode 100644 index 0000000..9f78715 --- /dev/null +++ b/site/components/DocNav.tsx @@ -0,0 +1,57 @@ +"use client"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { usePathname } from "next/navigation"; + +/* The docs sidebar. On a wide screen it is the full-height column; under + * 900px it collapses to a top bar (brand, search, a Menu button) and the guide + * list opens as an overlay beneath it. Only the open/closed state lives here; + * the brand, search and guide list are rendered by the server layout and + * passed in, so there is still exactly one search box on the page. */ +export function DocNav({ + header, + children, +}: { + header: ReactNode; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + const toggle = useRef(null); + const pathname = usePathname(); + + // Choosing a guide navigates; the menu should not stay over the new page. + useEffect(() => { + setOpen(false); + }, [pathname]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + setOpen(false); + toggle.current?.focus(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open]); + + return ( + + ); +} From 4979f32f46d891399d8824379de0377da13e011d Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Tue, 29 Sep 2026 15:36:44 -0700 Subject: [PATCH 51/55] Fix the launch accessibility gaps - Links inside running text are underlined: docs articles, the footer credit and landing notes. Their colour alone was only ~2.4-2.8:1 against the text around them, under the 3:1 a colour-only link needs (WCAG 1.4.1). Text contrast itself already passed AA in both themes. - The image lightbox behaves as the modal it declares: focus moves to its close button, Tab can't leave it, focus returns to the thumbnail on close, and it is named by its caption. - The landing page's content sits in a
    landmark. - The landing phone nav keeps "Docs" beside "Get started", with the wordmark and spacing trimmed so both fit inside the side padding. Co-Authored-By: Claude Opus 5.5 (1M context) --- site/app/(landing)/Landing.tsx | 524 ++++++++++++++++---------------- site/app/(landing)/landing.css | 25 +- site/app/docs/docs.css | 17 +- site/components/media.tsx | 19 ++ site/components/site-footer.css | 5 + 5 files changed, 327 insertions(+), 263 deletions(-) diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index 305ed05..1d1b28a 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -455,7 +455,9 @@ export function Landing() {
    - Docs + + Docs + Security Agent Builder GitHub @@ -466,280 +468,282 @@ export function Landing() { {/* ------------------------------------------------------------ hero --- */} -
    -
    -

    - A secure way to run enterprise AI agents. -

    -

    - Building blocks for running enterprise agents in your own cluster, - on the models you choose.{" "} +

    +
    +
    +

    + A secure way to run enterprise AI agents. +

    +

    + Building blocks for running enterprise agents in your own cluster, + on the models you choose.{" "} + + The AI never decides what it’s allowed to do. OAP checks + every action before it runs. + +

    +

    + Trusting an agent means answering four questions: +

    +
      + {QUESTIONS.map((q) => ( +
    1. {q}
    2. + ))} +
    +

    + OAP answers each one in the platform, before the agent acts. +

    + +
    +
    + +
    +
    + + {/* ---------------------------------------------------- 01 problem --- */} +
    + + Your agent can reach everything its credentials can. + +

    + A token for one repository usually reaches every repository. An + agent inherits all of it. +

    +

    + A model can’t reliably tell instructions from data, so its + judgment can’t be the boundary. A better prompt is still just + an instruction. +

    +

    - The AI never decides what it’s allowed to do. OAP checks - every action before it runs. + So OAP enforces every rule itself, before each action runs, where + no prompt can change it.

    -

    - Trusting an agent means answering four questions: + +

    + + + + + + + + + + {COMPARE.map(([q, typical, oap]) => ( + + + + + + ))} + +
    + Question + Typical agent platformOAP
    {q}{typical}{oap}
    +
    +
    + + {/* ------------------------------------------------- 02 primitives --- */} +
    + + Six concerns every agent has to solve. + +

    + A primitive is a basic building block that agents rely on, whatever + they do. OAP ships a working implementation of every one. +

    +
    + {PRIMITIVES.map((p, i) => ( +
    +

    + #{i + 1} {p.verb} +

    +

    + {p.title} +

    +

    {p.body}

    +
    + ))} +
    +
    + + {/* ---------------------------------------------------- 03 secure --- */} +
    + + Controls in six areas. + +

    + The platform enforces every control. No prompt or tool output can + affect enforcement.

    -
      - {QUESTIONS.map((q) => ( -
    1. {q}
    2. +
        + {AREAS.map((a) => ( +
      1. +

        {a.title}

        +
          + {a.points.map(([lead, body]) => ( +
        • + {lead} {body} +
        • + ))} +
        +

        + {a.links.map(([label, href]) => ( + + {label} + + ))} +

        +
      2. ))}
      -

      - OAP answers each one in the platform, before the agent acts. +

      + Plan gating and leakage tracking are opt-in per agent class. Network + policy is on by default.

      -
    + + {/* --------------------------------------------- 04 agent builder --- */} +
    + + Build an agent by talking to an agent. + +

    + Agent Builder is an OAP agent that creates other agents. Describe + what you need in plain language, then test it live. +

    +
    + {BUILDER.map((b) => ( +
    +

    {b.step}

    +

    {b.title}

    +

    {b.body}

    +
    + ))} +
    +
    -
    -
    - -
    -
    +

    + Off by default. You turn it on by naming who may use it, and it + follows every control above. +

    +
    - {/* ---------------------------------------------------- 01 problem --- */} -
    - - Your agent can reach everything its credentials can. - -

    - A token for one repository usually reaches every repository. An agent - inherits all of it. -

    -

    - A model can’t reliably tell instructions from data, so its - judgment can’t be the boundary. A better prompt is still just an - instruction. -

    -

    - - So OAP enforces every rule itself, before each action runs, where no - prompt can change it. - -

    + {/* -------------------------------------------- 05 everything else --- */} +
    + + The rest of the platform, included. + +

    + The capabilities you’d expect from any agent platform. +

    +
    + {EVERYTHING.map((e) => ( +
    +

    {e.title}

    +

    {e.body}

    +
    + ))} +
    +
    -
    - - - - - - - - - - {COMPARE.map(([q, typical, oap]) => ( - - - - + {/* ------------------------------------------------------- 06 owasp --- */} +
    + + Coverage of the OWASP{" "} + Agentic Top 10. + +

    + How OAP maps to each risk in the OWASP Top 10 for Agentic + Applications, with the remaining gaps listed alongside. This is a + self-assessment, not a certification. +

    +
    +
    - Question - Typical agent platformOAP
    {q}{typical}{oap}
    + + + + + + + - ))} - -
    {coverageSummary(OWASP)}
    ItemRiskCoverageKnown gap
    -
    -
    - - {/* ------------------------------------------------- 02 primitives --- */} -
    - - Six concerns every agent has to solve. - -

    - A primitive is a basic building block that agents rely on, whatever - they do. OAP ships a working implementation of every one. -

    -
    - {PRIMITIVES.map((p, i) => ( -
    -

    - #{i + 1} {p.verb} -

    -

    - {p.title} -

    -

    {p.body}

    -
    - ))} -
    -
    - - {/* ---------------------------------------------------- 03 secure --- */} -
    - - Controls in six areas. - -

    - The platform enforces every control. No prompt or tool output can - affect enforcement. -

    -
      - {AREAS.map((a) => ( -
    1. -

      {a.title}

      -
        - {a.points.map(([lead, body]) => ( -
      • - {lead} {body} -
      • - ))} -
      -

      - {a.links.map(([label, href]) => ( - - {label} - + + + {OWASP.map(([id, title, level, gap]) => ( + + + {id} + + {title} + + + {COV_LABEL[level]} + + + {gap} + ))} -

      -
    2. - ))} -
    -

    - Plan gating and leakage tracking are opt-in per agent class. Network - policy is on by default. -

    -
    - - {/* --------------------------------------------- 04 agent builder --- */} -
    - - Build an agent by talking to an agent. - -

    - Agent Builder is an OAP agent that creates other agents. Describe what - you need in plain language, then test it live. -

    -
    - {BUILDER.map((b) => ( -
    -

    {b.step}

    -

    {b.title}

    -

    {b.body}

    -
    - ))} -
    - -

    - Off by default. You turn it on by naming who may use it, and it - follows every control above. -

    -
    - - {/* -------------------------------------------- 05 everything else --- */} -
    - - The rest of the platform, included. - -

    - The capabilities you’d expect from any agent platform. -

    -
    - {EVERYTHING.map((e) => ( -
    -

    {e.title}

    -

    {e.body}

    -
    - ))} -
    -
    - - {/* ------------------------------------------------------- 06 owasp --- */} -
    - - Coverage of the OWASP{" "} - Agentic Top 10. - -

    - How OAP maps to each risk in the OWASP Top 10 for Agentic - Applications, with the remaining gaps listed alongside. This is a - self-assessment, not a certification. -

    -
    - - - - - - - - - - - - {OWASP.map(([id, title, level, gap]) => ( - - - - - - - ))} - -
    {coverageSummary(OWASP)}
    ItemRiskCoverageKnown gap
    - {id} - {title} - - {COV_LABEL[level]} - - {gap}
    -
    - -
    -

    How we know the gates still hold

    -

    - Every merge runs whole-session scenarios, golden authorization - traces and replays of real sessions. A permission check that stops - working fails the build. + + +

    + +
    +

    How we know the gates still hold

    +

    + Every merge runs whole-session scenarios, golden authorization + traces and replays of real sessions. A permission check that stops + working fails the build. +

    +
    +

    + Based on the{" "} + + OWASP Top 10 for Agentic Applications (2026) + {" "} + by the OWASP GenAI Security Project, licensed under{" "} + + CC BY-SA 4.0 + + . This assessment is not affiliated with or endorsed by OWASP.

    - -

    - Based on the{" "} - - OWASP Top 10 for Agentic Applications (2026) - {" "} - by the OWASP GenAI Security Project, licensed under{" "} - - CC BY-SA 4.0 - - . This assessment is not affiliated with or endorsed by OWASP. -

    -
    +
    - {/* ----------------------------------------------------------- close --- */} -
    -

    Defense in depth

    -

    Every layer assumes the others may fail.

    -

    - To misuse an agent, an attacker has to get past an approved plan, a - locked slot, a tool spec, a check on every call, and a sandbox that - never held the credential. -

    - -
    + {/* ----------------------------------------------------------- close --- */} +
    +

    Defense in depth

    +

    Every layer assumes the others may fail.

    +

    + To misuse an agent, an attacker has to get past an approved plan, a + locked slot, a tool spec, a check on every call, and a sandbox that + never held the credential. +

    + +
    + diff --git a/site/app/(landing)/landing.css b/site/app/(landing)/landing.css index 3ceb7c8..6b41031 100644 --- a/site/app/(landing)/landing.css +++ b/site/app/(landing)/landing.css @@ -207,6 +207,18 @@ body:has(.lp) { font-weight: 500; } +/* A link inside a note's running text is underlined, not colour-only. */ +.lp-note a { + color: var(--lp-fg); + text-decoration: underline; + text-decoration-color: var(--lp-line); + text-underline-offset: 3px; +} + +.lp-note a:hover { + text-decoration-color: currentColor; +} + .lp-note { font-family: var(--lp-mono); font-size: 12.5px; @@ -928,9 +940,20 @@ body:has(.lp) { .lp-slab--3 { grid-template-columns: 1fr; } - .lp-nav-links a:not(.lp-btn) { + /* Phones keep only Docs beside the button; the rest are on the page. */ + .lp-nav-links a:not(.lp-btn):not(.lp-nav-keep) { display: none; } + .lp-nav-links { + gap: 12px; + } + .lp-nav-wordmark { + height: 29px; + } + .lp-nav .lp-btn { + padding-left: 14px; + padding-right: 14px; + } .lp-owasp td.lp-gap, .lp-owasp thead th:last-child { display: none; diff --git a/site/app/docs/docs.css b/site/app/docs/docs.css index b58b197..a12e772 100644 --- a/site/app/docs/docs.css +++ b/site/app/docs/docs.css @@ -137,6 +137,15 @@ body:has(.doc-app) { color: var(--doc-accent); } .doc-search { margin-bottom: 20px; } +/* Visually hidden, still read aloud: the search's status line. */ +.doc-sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} .doc-search-input { width: 100%; padding: 7px 10px; @@ -267,10 +276,14 @@ body:has(.doc-app) { } .doc-article a { color: var(--doc-accent); - text-decoration: none; + /* Underlined, not colour alone: the accent is only ~2.4:1 against body + text, under the 3:1 a colour-only link needs (WCAG 1.4.1). */ + text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 45%, transparent); + text-underline-offset: 3px; } .doc-article a:hover { - text-decoration: underline; + text-decoration-color: currentColor; } .doc-article ul, .doc-article ol { diff --git a/site/components/media.tsx b/site/components/media.tsx index 1e93498..c70b3ba 100644 --- a/site/components/media.tsx +++ b/site/components/media.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, + useRef, useState, type KeyboardEvent as ReactKeyboardEvent, } from "react"; @@ -26,6 +27,9 @@ function activateOnKey( // A full-screen overlay showing an image at its natural size. Click the backdrop // or press Escape to dismiss; the image itself swallows the click so it stays open. +// It is a modal dialog, so it behaves like one for the keyboard: focus moves to +// its close button on open, Tab cannot leave it (the button is its only control), +// and focus returns to whatever opened it on close. function Lightbox({ src, caption, @@ -35,9 +39,21 @@ function Lightbox({ caption?: string; onClose: () => void; }) { + const close = useRef(null); + + useEffect(() => { + const opener = document.activeElement as HTMLElement | null; + close.current?.focus(); + return () => opener?.focus(); + }, []); + useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); + if (e.key === "Tab") { + e.preventDefault(); + close.current?.focus(); + } }; window.addEventListener("keydown", onKey); const prev = document.body.style.overflow; @@ -52,9 +68,12 @@ function Lightbox({ className="doc-lightbox" role="dialog" aria-modal="true" + aria-label={caption ?? "Image"} onClick={onClose} >