diff --git a/.dockerignore b/.dockerignore index 9b559bc75..6af42f9a4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,15 +9,24 @@ .idea .claude -# Markdown that isn't a runtime asset +# Docs +docs README.md -CONTRIBUTING.md -SECURITY.md -CODE_OF_CONDUCT.md -THIRD_PARTY_NOTICES.md -AGENTS.md +*.md +# Re-include plugin manifests — bundled into the image via Dockerfile and +# read at boot to build the plugin catalog. +!docs/harness-platform +!docs/harness-platform/examples +!docs/harness-platform/examples/*.yaml +# Re-include agent-integration boilerplate — Plugin-Builder (B.1+) +# kopiert die Files zur Codegen-Zeit; Dockerfile mountet das Verzeichnis +# unter /app/boilerplate/agent-integration. Nur das eine Subverzeichnis +# allowen, der Rest von docs/ bleibt excluded. +!docs/harness-platform/boilerplate +!docs/harness-platform/boilerplate/agent-integration +!docs/harness-platform/boilerplate/agent-integration/** -# Dev frontend (separate deploy target with its own Dockerfile under web-dev/) +# Dev frontend (separate deploy target) web-dev # Middleware local state / build artefacts @@ -26,6 +35,7 @@ middleware/dist middleware/.memory middleware/.env middleware/.env.* +middleware/fly-memory-pull # Workspace-Pakete: Source bleibt drin (Builder-Stage compiliert frisch), # aber lokale dist/ + tsbuildinfo werden im Container neu gebaut. Defense # gegen stale-build-artefacts shipping. @@ -33,19 +43,22 @@ middleware/packages/*/dist middleware/packages/*/tsconfig.tsbuildinfo middleware/packages/*/node_modules middleware/scripts -middleware/test -middleware/*.log # Re-include the build-asset copier — it runs as part of `npm run build` # inside the Docker builder stage. !middleware/scripts/copy-build-assets.mjs # Re-include the Node-version guard — preinstall hook from # middleware/package.json that runs before `npm ci` in both stages. !middleware/scripts/check-node-version.mjs +middleware/test +middleware/*.log -# Optional skills directory zips (when present) +# Legacy Managed-Agent zip bundles in skills/ — we only need the SKILL.md trees skills/*.zip skills/.DS_Store skills/**/.DS_Store +# Standalone configs that aren't used by the runtime +agent-config*.yaml + # OS junk **/.DS_Store diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7eac4a9d9..806059587 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,7 +9,7 @@ ## Test plan ## Risk / blast radius diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51e052e74..e1f0b190b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,12 +41,19 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' cache-dependency-path: middleware/package-lock.json - name: npm ci (middleware + workspaces) - run: npm ci --no-audit --no-fund + run: npm ci --include=optional --no-audit --no-fund + + # sharp's native binary is platform-specific. package-lock.json was + # generated on macOS (darwin-arm64) and has no `node_modules/@img/ + # sharp-linux-x64` entry, so `npm ci` even with --include=optional + # skips it. Force-install the linux-x64 variant for the CI runner. + - name: Install sharp linux-x64 native binary + run: npm install --no-save --no-audit --no-fund --os=linux --cpu=x64 sharp - name: Build workspace packages (compiled dist/ for cross-package imports) run: npm run build @@ -60,6 +67,11 @@ jobs: - name: Test (node --test via tsx) run: npm run test + # Smoke scripts (middleware/scripts/smoke-*.ts) live outside this + # public repo by design — they hit byte5-internal endpoints. The + # privacy-shield v2 smoke that previously ran here has the same + # coverage as the in-suite privacyOutputValidator tests. + # ------------------------------------------------------------------ # Web-dev: Next.js admin UI — vitest + lint + typecheck # ------------------------------------------------------------------ @@ -75,12 +87,12 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' cache-dependency-path: web-ui/package-lock.json - name: npm ci - run: npm ci --no-audit --no-fund + run: npm ci --include=optional --no-audit --no-fund - name: Lint run: npm run lint @@ -182,12 +194,12 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' cache-dependency-path: ${{ matrix.workspace }}/package-lock.json - name: npm ci - run: npm ci --no-audit --no-fund + run: npm ci --include=optional --no-audit --no-fund - name: npm audit (--audit-level=high) run: npm audit --audit-level=high diff --git a/.gitignore b/.gitignore index df9533ef2..245f980f6 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,49 @@ tmp/ # OSS-Core export scope (OB-30) and must never be checked in alongside the # harness platform sources. See marketing-site/docs/00-isolation.md. marketing-site/ + +# ─── byte5-specific agent + deployment configs (private) ────────────────── +# These describe a specific byte5 deployment (Fly app, Neon project, kroki +# sidecars, customer-specific agent configs) and must never land in public +# Omadia. Generic equivalents ship with the OSS docs (see docs/). +agent-config.yaml +agent-config-*.yaml +fly.toml +compose.yml +infra/ +kroki/ +ollama/ +middleware/DEPLOY.md +middleware/data/ +middleware/.uploaded-packages/ + +# ─── Internal-only docs (session handoffs, plans, customer evaluations) ─── +# Session-context that served its purpose during development and would +# pollute the public docs tree. The harness-platform/ architecture docs +# themselves ARE public — only HANDOFF-* session snapshots are excluded. +docs/plans/ +docs/day-one-learnings-*.md +docs/dev-frontend-handoff.md +docs/middleware-agent-handoff.md +docs/northdata-agent-plan.md +docs/softgarden-agent-evaluation.md +docs/harness-platform/HANDOFF-*.md + +# ─── Smoke scripts (internal validation harness, not part of public API) ── +# Boot-test scripts that hit live byte5 Fly endpoints / Neon project. The +# OSS audience doesn't have access to those targets; equivalent guidance +# lives in the public test/ directories per package. +middleware/scripts/smoke-*.ts + +# ─── Private byte5 plugins (channels + integrations) ────────────────────── +# byte5-specific channel adapters (Teams, Telegram) and SaaS integrations +# (Confluence, MS365, Odoo). Canonical source lives in the separate private +# repo ~/sources/omadia-byte5-plugins (workspace: packages/channel-*, +# packages/integration-*). The copies here are working-dev mirrors and +# must never reach the public Omadia repo. Reconciliation between the two +# locations is a separate cleanup (see HANDOFF when scheduled). +middleware/packages/harness-channel-teams/ +middleware/packages/harness-channel-telegram/ +middleware/packages/harness-integration-confluence/ +middleware/packages/harness-integration-microsoft365/ +middleware/packages/harness-integration-odoo/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index f43540014..4d90f1f9c 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -17,7 +17,7 @@ discussions, code reviews, public events held under the project name). Instances of unacceptable behaviour can be reported privately to: -> `conduct@byte5.de` +> `info@omadia.ai` Reports are reviewed by the project maintainers and handled with discretion. We follow the **enforcement guidelines** chapter of the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2f6b4c71..b4fc6f1ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,19 +31,19 @@ Bootstrap: ```bash git clone https://github.com/byte5ai/omadia.git cd omadia -cp middleware/.env.example middleware/.env # set ANTHROPIC_API_KEY -docker compose up -d minio kroki ollama # sidecars (skip middleware/web-ui — those run via npm) +cp infra/.env.example infra/.env # set ANTHROPIC_API_KEY +docker compose -f infra/docker-compose.yml --env-file infra/.env up -d postgres # Middleware (Express + plugin runtime + builder) cd middleware nvm use npm install -npm run dev # starts on :8080 +npm run dev # starts on :3979 # Admin UI (Next.js 15) cd ../web-ui npm install -npm run dev # starts on :3000 +npm run dev # starts on :3300 ``` The middleware re-builds and re-types every workspace package on `npm run diff --git a/LICENSE b/LICENSE index 9cd266437..d8d702ea6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 byte5.ai +Copyright (c) 2026 byte5 GmbH Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 2f66d025c..0c522a1ab 100644 --- a/README.md +++ b/README.md @@ -16,80 +16,43 @@ model. You bring your own LLM API key, run the stack on a single machine > is supported but the upgrade path is hand-rolled today; an automated > migration runner is on the v1.0 roadmap. -> **Heads-up — `main` was force-pushed on 2026-05-12** to purge a documentation -> file that contained internal identifiers. The `v0.1.0` tag is unchanged, but -> if you cloned this repository before that date your next `git pull` will fail -> with `non-fast-forward` / `Updates were rejected`. To recover, discard the -> stale local history and reset to the rewritten remote: -> -> ```bash -> git fetch origin -> git reset --hard origin/main -> ``` -> -> If you have local commits on top of the old `main`, cherry-pick them onto the -> new base instead (`git log` on the old SHA is still reachable locally for ~90 -> days via the reflog). - -## Quickstart +## Quickstart (~60 seconds after the first image pull) ```bash git clone https://github.com/byte5ai/omadia.git cd omadia -# 1. Provide an Anthropic API key. The middleware will not boot without one. -# Every other env var has a working default for the docker-compose stack. -cp middleware/.env.example middleware/.env -$EDITOR middleware/.env # set ANTHROPIC_API_KEY=sk-ant-... +# 1. Provide an Anthropic API key. Other env vars have sane local defaults. +cp infra/.env.example infra/.env +$EDITOR infra/.env # set ANTHROPIC_API_KEY=... -# 2. Bring up the full stack. First build pulls ~3 GB of images -# (postgres+pgvector, kroki, minio, ollama, presidio sidecar build). -docker compose up -d --build +# 2. Bring up the stack (postgres + middleware + admin UI). +docker compose -f infra/docker-compose.yml --env-file infra/.env up -d -# 3. Watch it come up — middleware needs ~60-90s on first boot for KG -# migrations + plugin activations + ollama model pulls. -docker compose logs -f middleware - -# 4. Open the management UI and complete the first-admin wizard. -open http://localhost:3333 # /setup walks you through +# 3. Open the management UI and complete the first-admin wizard. +open http://localhost:3300 # /setup walks you through ``` The first user-creation flow lands on `/setup`. Once an administrator exists, `/setup` self-locks (returns `410 Gone`) and the regular `/login` page takes over. -### Re-running - -`docker compose up -d` brings the stack back up; volumes (postgres data, -vault, memory, uploaded plugins) survive. To start completely fresh: +### Optional Compose profiles ```bash -docker compose down -v && docker compose up -d --build -``` +# Mermaid / PlantUML / Vega rendering for the diagrams plugin +docker compose -f infra/docker-compose.yml --profile diagrams up -d -> **Heads up — browser localStorage**: chats are cached in the browser -> (offline-friendly). If you've ever used another Omadia instance on the -> same `http://localhost:3333` (e.g. a previous deployment), those cached -> chats will surface in this fresh install too. Browser DevTools → -> Application → Local Storage → `http://localhost:3333` → "Clear All" gives -> you a clean slate. (A first-install detection that does this -> automatically is on the v0.2 roadmap.) - -### Service map - -| Service | Host port | Purpose | -|---|---|---| -| `web-ui` | `3000` | Admin UI (Next.js) | -| `middleware` | `8080` | Kernel API + plugin runtime | -| `postgres` | `5432` | Postgres + pgvector — knowledge graph / routines / verifier persistence (default user/password/db: `omadia`) | -| `kroki` | `8765` | Diagram rendering (Mermaid, PlantUML, Vega, …) | -| `minio` | `9000` / `9001` | S3-compatible object storage (console: `minioadmin` / `minioadmin`) | -| `ollama` | `11434` | In-tenant embeddings + small NER model (`nomic-embed-text` + `llama3.2:3b`) | -| `presidio` | `5001` | Python NER sidecar for the privacy detector plugin (FastAPI, `~1.5 GB` first build) | - -Stop the stack with `docker compose down`; add `-v` to also wipe the -persistent volumes (`middleware-data`, `postgres-data`, `minio-data`, -`ollama-data`). +# In-tenant embeddings via Ollama (no external API required) +docker compose -f infra/docker-compose.yml --profile embeddings up -d + +# Presidio NER sidecar for the privacy-proxy detector plugin +docker compose -f infra/docker-compose.yml --profile privacy-presidio up -d + +# All optional profiles in one command +docker compose -f infra/docker-compose.yml \ + --profile diagrams --profile embeddings --profile privacy-presidio up -d +``` ## What's in the box @@ -133,9 +96,9 @@ persistent volumes (`middleware-data`, `postgres-data`, `minio-data`, (Postgres + pgvector) (Ollama / API) (AES-256-GCM file) ``` -More detailed walk-throughs of the plugin loading sequence, capability -registry, and the multi-provider authentication layer will be published -alongside the v0.2 release. +A more detailed walk-through of the plugin loading sequence, capability +registry, and the multi-provider authentication layer lives under +[`docs/`](docs/). ## Plugin development @@ -156,11 +119,19 @@ the differentiating logic, and verifying with the smoke runner before install. ## Deployment - **Local / single-tenant** — `docker compose up`, see Quickstart above -- **Bring-your-own** — the runtime is a stock Node service plus the - sidecars in `docker-compose.yaml` (Kroki, MinIO, Ollama). Any host - capable of running Docker works (Kubernetes, ECS, Fly.io, plain VM). - Postgres is optional — without `DATABASE_URL` the kernel uses the - in-memory knowledge graph. +- **Fly.io** — single-app deployment, multi-region supported. The compose + stack and the Fly image are baked from the same `Dockerfile`. +- **Bring-your-own** — the runtime is a stock Node + Postgres app; any host + capable of running both works (Kubernetes, ECS, plain VM). + +> **Required production secret.** The shipped image runs with +> `NODE_ENV=production`, which makes `VAULT_KEY` mandatory at boot — without +> it the middleware refuses to start (this is intentional; the dev fallback +> writes the master key into the data volume, which is not safe at rest). +> Generate one with `openssl rand -base64 32` and wire it as a platform +> secret (Fly: `fly secrets set VAULT_KEY=…`) before the first deploy. The +> local Compose stack pins `NODE_ENV=development` so the dev fallback stays +> available for `docker compose up` without configuration. > **Required production secret.** The shipped image runs with > `NODE_ENV=production`, which makes `VAULT_KEY` mandatory at boot — without @@ -188,7 +159,7 @@ Active development tracks: ## License -[MIT](LICENSE) — Copyright (c) 2026 byte5.ai +[MIT](LICENSE) — Copyright (c) 2026 byte5 GmbH Third-party dependency licenses and notices are documented in [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md). The dependency tree is diff --git a/SECURITY.md b/SECURITY.md index fc3538929..f463ab8a5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,7 +8,7 @@ weaponise the disclosure before a fix lands. Instead, report privately via either channel: -- **Email** — `security@byte5.de` (preferred). PGP key on request. +- **Email** — `info@omadia.ai` (preferred). PGP key on request. - **GitHub Security Advisory** — open a [private advisory](https://github.com/byte5ai/omadia/security/advisories/new) on this repository. @@ -53,12 +53,12 @@ Out of scope: Operator secrets — `ANTHROPIC_API_KEY`, `MICROSOFT_APP_*`, OAuth client secrets, `VAULT_KEY`, plugin-specific API keys — must **never** be checked into the repository. The development workflow exclusively uses -`middleware/.env.example` as a template; the populated `middleware/.env` is +`infra/.env.example` as a template; the populated `infra/.env` is `.gitignore`d. If you discover a secret in the git history, please: -1. Notify `security@byte5.de` immediately so the secret can be rotated. +1. Notify `info@omadia.ai` immediately so the secret can be rotated. 2. Open a private advisory if the secret has been pushed to the public remote. diff --git a/logo-concepts/omadia-logo-concept.svg b/logo-concepts/omadia-logo-concept.svg new file mode 100644 index 000000000..144d91baa --- /dev/null +++ b/logo-concepts/omadia-logo-concept.svg @@ -0,0 +1,34 @@ + + Omadia logo concept + A clean Omadia wordmark with an agentic operating-system mark in byte5 blue, dark blue, and magenta. + + + + + + + + + + + + + + + + + Omadia + AN AGENTIC OS + + + + + + + + + + + + + diff --git a/middleware/.gitignore b/middleware/.gitignore index 58653455d..a13f4d91c 100644 --- a/middleware/.gitignore +++ b/middleware/.gitignore @@ -5,6 +5,11 @@ dist/ .env.*.local .memory/ data/ +# Privacy-Shield v2 (Slice S-3): the per-package data/ folders ship +# bundled JSON the plugin reads at activate time — those MUST be +# committed. +!packages/*/data/ +!packages/*/data/** .uploaded-packages/ *.log .DS_Store diff --git a/middleware/assets/boilerplate/agent-integration/manifest.yaml b/middleware/assets/boilerplate/agent-integration/manifest.yaml index af4112515..e0853e11f 100644 --- a/middleware/assets/boilerplate/agent-integration/manifest.yaml +++ b/middleware/assets/boilerplate/agent-integration/manifest.yaml @@ -17,7 +17,7 @@ identity: description: "{{AGENT_DESCRIPTION_DE}}" authors: - name: "byte5 GmbH" - email: "dev@byte5.de" + email: "info@omadia.ai" license: "Proprietary" icon: "./assets/icon.png" categories: @@ -133,6 +133,12 @@ permissions: integrations: [] +# Scheduled background jobs. Codegen rewrites this block from spec.jobs[]; +# leave it as `[]` for plugins with no scheduled work. The kernel auto- +# registers each entry before activate() returns; ctx.jobs.register(...) in +# activate-body adds programmatic jobs on top. +jobs: [] + ui: settings_schema: null commands: [] diff --git a/middleware/assets/boilerplate/agent-integration/package.json b/middleware/assets/boilerplate/agent-integration/package.json index 0c4e1fd41..016df7af9 100644 --- a/middleware/assets/boilerplate/agent-integration/package.json +++ b/middleware/assets/boilerplate/agent-integration/package.json @@ -6,7 +6,9 @@ "main": "dist/index.js", "description": "{{AGENT_DESCRIPTION_DE}}", "peerDependencies": { - "zod": "^3.23.8" + "zod": "^3.23.8", + "express": "^5.1.0", + "@omadia/plugin-ui-helpers": "*" }, "engines": { "node": ">=20" diff --git a/middleware/assets/boilerplate/agent-integration/plugin.ts b/middleware/assets/boilerplate/agent-integration/plugin.ts index 505fa556a..eb6035281 100644 --- a/middleware/assets/boilerplate/agent-integration/plugin.ts +++ b/middleware/assets/boilerplate/agent-integration/plugin.ts @@ -9,6 +9,12 @@ import type { PluginContext } from './types.js'; // external_reads entries. Empty by default. // #endregion +// #region builder:ui-routes-imports +// Auto-generated by codegen from spec.ui_routes — do not edit by hand. +// Codegen splices Router imports + per-route UiRouter factory imports +// here when the spec carries ui_routes entries. Empty by default. +// #endregion + export const AGENT_ID = '{{AGENT_ID}}' as const; /** @@ -60,10 +66,29 @@ export async function activate(ctx: PluginContext): Promise { // when the spec has no external reads. // #endregion + const __uiRouteDisposers: Array<() => void> = []; + // #region builder:ui-routes-init + // Auto-generated from spec.ui_routes — do not edit by hand. Codegen + // synthesises per-route Express routers, `ctx.routes.register(...)` + // mounts, and `ctx.uiRoutes.register(...)` descriptor wiring here. + // The disposer-array above collects every register() return value so + // close() can tear down everything cleanly. Empty by default. + // #endregion + return { toolkit, async close() { ctx.log('deactivating'); + // B.12 — tear down every ui_route mount + descriptor. The loop is + // always present (no-op when ui_routes is empty); the disposers are + // pushed by the codegen-managed ui-routes-init region above. + for (const dispose of __uiRouteDisposers) { + try { + dispose(); + } catch (err) { + ctx.log('ui-route dispose failed', err); + } + } await toolkit.close(); }, }; diff --git a/middleware/assets/boilerplate/agent-integration/template.yaml b/middleware/assets/boilerplate/agent-integration/template.yaml index 7dc15dc29..7fe3300fe 100644 --- a/middleware/assets/boilerplate/agent-integration/template.yaml +++ b/middleware/assets/boilerplate/agent-integration/template.yaml @@ -40,6 +40,11 @@ slots: target_file: skills/{{AGENT_SLUG}}-expert.md required: true description: System prompt body (frontmatter is generated automatically) + # Allow splitting large skill markdowns across up to 4 additional + # partials (skill-prompt-1 … skill-prompt-4). Each fill_slot call stays + # under the Anthropic tool-call argument-size limit (~30 KB); the + # runtime concatenates all partials with `\n\n---\n\n` separators. + max_partials: 4 - key: admin-ui-body target_file: assets/admin-ui/index.html required: false @@ -66,6 +71,24 @@ slots: pushed onto the toolkit. Empty when spec.external_reads is empty. To wire cross-integration data, add entries to spec.external_reads via patch_spec, NOT by hand-coding the activate-body slot. + - key: ui-routes-imports + target_file: plugin.ts + required: false + description: | + Auto-managed by codegen (B.12) — DO NOT fill via fill_slot. Codegen + splices Express Router + per-route UiRouter factory imports here + from spec.ui_routes[]. Empty by default. To add a Dashboard-Tab, + append a UiRoute to spec.ui_routes via patch_spec. + - key: ui-routes-init + target_file: plugin.ts + required: false + description: | + Auto-managed by codegen (B.12) — DO NOT fill via fill_slot. Codegen + synthesises per-route Express router instantiation, + `ctx.routes.register(...)` mounts, and `ctx.uiRoutes.register(...)` + descriptor wiring from spec.ui_routes[]. Each register-return-value + is pushed onto the `__uiRouteDisposers` array so close() tears + everything down. Empty by default. placeholders: AGENT_ID: id diff --git a/middleware/assets/boilerplate/agent-integration/tsconfig.json b/middleware/assets/boilerplate/agent-integration/tsconfig.json index ae9eae7bf..155c97113 100644 --- a/middleware/assets/boilerplate/agent-integration/tsconfig.json +++ b/middleware/assets/boilerplate/agent-integration/tsconfig.json @@ -25,7 +25,10 @@ "./toolkit.ts", "./client.ts", "./types.ts", - "./index.ts" + "./index.ts", + "./routes/**/*.ts", + "./routes/**/*.tsx", + "./components/**/*.tsx" ], "exclude": ["node_modules", "dist", "skills", "assets", "scripts", "out"] } diff --git a/middleware/assets/boilerplate/agent-integration/types.ts b/middleware/assets/boilerplate/agent-integration/types.ts index 9a15b2466..7ccbc785b 100644 --- a/middleware/assets/boilerplate/agent-integration/types.ts +++ b/middleware/assets/boilerplate/agent-integration/types.ts @@ -40,9 +40,286 @@ export interface PluginContext { readonly routes: { register(prefix: string, router: unknown): () => void; }; + /** B.12 — Plugin-served UI surface registry. Plugins call + * `ctx.uiRoutes.register({routeId, path, title})` to publish a + * clickable Dashboard-Tab (Teams Tab, Hub card, web link). The HTTP + * route itself is registered separately via `ctx.routes.register('/p/...', router)`; + * the descriptor just makes the surface discoverable in the Hub. + * Returns a dispose handle the plugin MUST call from `close()`. */ + readonly uiRoutes: { + register(descriptor: UiRouteDescriptorInput): () => void; + }; + /** HTTP client with manifest-enforced outbound allow-listing and + * per-plugin rate limiting (60 requests/min). Present iff the manifest + * declares `permissions.network.outbound` with at least one host. Calls + * to undeclared hosts throw `HttpForbiddenError`; rate-limit breaches + * throw `HttpRateLimitError`. + * + * Prefer `ctx.http.fetch(url, init)` over the global `fetch` so the + * plugin stays future-proof — a hardening pass may block global fetch + * entirely for plugins. */ + readonly http?: HttpAccessor; + + /** Single-turn delegation to another agent registered in the host. + * Present iff the manifest declares `permissions.subAgents.calls` with + * at least one target agentId. Calls to non-whitelisted targets throw + * `SubAgentPermissionDeniedError`. Self-recursion (target === own + * agentId) throws `SubAgentRecursionError`. Per tool-handler invocation, + * a budget caps total calls (default 5). */ + readonly subAgent?: SubAgentAccessor; + + /** Namespaced knowledge-graph accessor. Present iff the manifest + * declares `permissions.graph.entity_systems` with at least one + * namespace string AND a `knowledgeGraph` provider is installed (e.g. + * `@omadia/knowledge-graph-inmemory` or `-neon`). + * + * `ingestEntities` / `ingestFacts` validate the `system` field against + * the namespace whitelist — typo-protection for `'odoo'` vs `'odooo'`. + * Read methods (`searchTurns`, `findEntityCapturedTurns`, etc.) pass + * through unchanged. */ + readonly knowledgeGraph?: KnowledgeGraphAccessor; + + /** Host-LLM accessor. Present iff the manifest declares + * `permissions.llm.models_allowed` with at least one entry AND a + * `'llm'` provider is registered (host has `ANTHROPIC_API_KEY`). + * + * Plugins use this for natural-language tasks (entity extraction, + * summarisation, rephrasing) without managing API keys themselves — + * the host pays. Model whitelist + per-invocation call-budget + + * per-call max-tokens-clamp are enforced by the manifest. */ + readonly llm?: LlmAccessor; + + /** Per-plugin memory store, scoped to `/memories/agents//`. + * All paths are RELATIVE — `notes.md` resolves to + * `/memories/agents//notes.md` under the hood. Plugins cannot + * read or write other plugins' memory (structural isolation). + * + * Present when the manifest declares `permissions.memory.reads` OR + * `permissions.memory.writes` with at least one entry. The Builder + * boilerplate's `manifest.yaml` ships those entries pre-populated + * (`agent:{{AGENT_ID}}:*`), so this accessor is present at runtime for + * every Builder-emitted plugin — but always check `if (ctx.memory)` + * defensively so an operator-stripped manifest doesn't crash activate. */ + readonly memory?: MemoryAccessor; + + /** Register cron- or interval-scheduled background jobs. The kernel runs + * each job in isolation (per-job AbortController + timeoutMs) and stops + * every job belonging to a plugin on deactivate. Jobs declared in the + * manifest's `jobs:` block are auto-registered before `activate()` — + * programmatic registrations via this accessor are additive. + * + * Always present; no permission gate. Use `register({ name, schedule: + * { cron: '0 8 * * MON' } | { intervalMs: 60_000 } }, handler)`. */ + readonly jobs: JobsAccessor; + /** Theme D: true only when the kernel activated this plugin for a * smoke probe. False during normal `activate()`. Plugins MAY branch * on this to return mock data — most plugins ignore it. */ readonly smokeMode: boolean; log(...args: unknown[]): void; } + +/** + * Per-plugin memory store (mirror of `MemoryAccessor` from + * `@omadia/plugin-api`). All paths are relative — the kernel pins the + * accessor to the plugin's `/memories/agents//` subtree. + */ +export interface MemoryAccessor { + readFile(relPath: string): Promise; + writeFile(relPath: string, content: string): Promise; + /** Create, fail-if-exists. Use when two concurrent writers must not race. */ + createFile(relPath: string, content: string): Promise; + delete(relPath: string): Promise; + list(relPath: string): Promise; + exists(relPath: string): Promise; +} + +export interface MemoryEntryInfo { + /** Path relative to the plugin's scope — same shape callers pass in. */ + readonly relPath: string; + readonly isDirectory: boolean; + readonly sizeBytes: number; +} + +/** + * Schedule for a cron- or interval-driven background job. Pass to + * `ctx.jobs.register({ name, schedule, ... }, handler)`. Cron uses croner + * syntax (5- or 6-field, supports `*`, `,`, `-`, `/`, `L`, `MON`-`SUN`). + */ +export type JobSchedule = + | { readonly cron: string } + | { readonly intervalMs: number }; + +export interface JobSpec { + /** Unique within the plugin — singleton-lock key. */ + readonly name: string; + readonly schedule: JobSchedule; + /** Per-run timeout. Default 30_000ms. */ + readonly timeoutMs?: number; + /** What to do if a tick fires while the previous run is still in flight. + * `'skip'` (default) drops the late tick; `'queue'` enqueues exactly one. */ + readonly overlap?: 'skip' | 'queue'; +} + +/** Handler invoked on each scheduled tick. The supplied AbortSignal is + * aborted on plugin deactivate or when `timeoutMs` elapses — respect it + * by passing it to `fetch(...)` or checking `signal.aborted` between + * work units. Throwing is logged but does NOT cancel future ticks. */ +export type JobHandler = (signal: AbortSignal) => Promise; + +export interface JobsAccessor { + /** Register a job. Returns a dispose handle the plugin's `close()` MUST + * invoke — failing to dispose leaks the cron timer. Duplicate `name` + * within the same plugin throws. */ + register(spec: JobSpec, handler: JobHandler): () => void; +} + +export interface UiRouteDescriptorInput { + /** Stable id within the plugin (e.g. `'dashboard'`, `'inbox'`). + * Combined with pluginId to form the catalogue key. */ + readonly routeId: string; + /** Path relative to the plugin's `/p/` mount (must start + * with `/`, e.g. `/dashboard`). */ + readonly path: string; + /** Human-readable label shown in Hubs, dropdowns, and Tab titles. */ + readonly title: string; + /** Optional one-line summary surfaced as a tooltip / card subtitle. */ + readonly description?: string; + /** Optional ordering hint — lower comes first. Defaults to 100. */ + readonly order?: number; +} + +// --------------------------------------------------------------------------- +// HTTP accessor (Phase B platform-parity) +// --------------------------------------------------------------------------- + +/** Outbound-allowlisted fetch. Same shape as global `fetch`; unknown hosts + * throw `HttpForbiddenError`, rate-limit breaches throw `HttpRateLimitError`. */ +export interface HttpAccessor { + fetch(url: string, init?: RequestInit): Promise; +} + +// --------------------------------------------------------------------------- +// Sub-agent delegation (Phase B platform-parity) +// --------------------------------------------------------------------------- + +export interface SubAgentAccessor { + /** Ask a registered agent a single question. Returns the final answer. + * Throws `SubAgentPermissionDeniedError` when target not in + * `permissions.subAgents.calls` whitelist; + * `SubAgentRecursionError` on self-call; + * `SubAgentBudgetExceededError` when per-tool-handler budget exhausted. */ + ask(targetAgentId: string, question: string): Promise; + + /** True iff the target is currently registered in the host (no permission + * filter — use for introspection of what's installed). */ + has(targetAgentId: string): boolean; + + /** All reachable target agentIds (no permission filter). */ + list(): readonly string[]; +} + +// --------------------------------------------------------------------------- +// Host-LLM accessor (Phase B platform-parity) +// --------------------------------------------------------------------------- + +export interface LlmCompleteRequest { + /** Anthropic model id — MUST match `permissions.llm.models_allowed`. */ + readonly model: string; + /** Optional system prompt forwarded verbatim. */ + readonly system?: string; + /** Conversation messages. Plain strings only in v1. */ + readonly messages: ReadonlyArray<{ + readonly role: 'user' | 'assistant'; + readonly content: string; + }>; + /** Silently clamped to `permissions.llm.max_tokens_per_call` when + * the manifest sets a smaller cap. */ + readonly maxTokens?: number; + readonly temperature?: number; +} + +export interface LlmCompleteResult { + readonly text: string; + readonly model: string; + readonly inputTokens: number; + readonly outputTokens: number; + readonly stopReason: + | 'end_turn' + | 'max_tokens' + | 'stop_sequence' + | 'tool_use'; +} + +export interface LlmAccessor { + complete(req: LlmCompleteRequest): Promise; + /** Snapshot of the whitelist for plugin-side introspection. */ + readonly modelsAllowed: readonly string[]; +} + +// --------------------------------------------------------------------------- +// Knowledge-graph accessor (Phase B platform-parity) +// --------------------------------------------------------------------------- + +/** Minimal entity-ingest shape — only the fields required by the kernel's + * validator. `system` MUST be in `permissions.graph.entity_systems` or + * ingestEntities throws `KgEntityNamespaceError`. Extras are free-form. */ +export interface EntityIngest { + readonly system: string; + readonly model: string; + readonly id: string; + readonly displayName: string; + readonly extras?: Readonly>; +} + +export interface EntityIngestResult { + readonly inserted: number; + readonly updated: number; + readonly skipped: number; +} + +/** Atomic subject-predicate-object fact. `mentionedEntityIds` may reference + * entities owned by other systems (the KG tolerates dangling refs). */ +export interface FactIngest { + readonly subject: string; + readonly predicate: string; + readonly object: string; + readonly confidence?: number; + readonly mentionedEntityIds?: readonly string[]; + readonly extras?: Readonly>; +} + +export interface FactIngestResult { + readonly inserted: number; +} + +export interface KnowledgeGraphAccessor { + /** Persist entities. `system` of each entry validated against the + * manifest's `entity_systems` whitelist. */ + ingestEntities(entities: EntityIngest[]): Promise; + + /** Persist atomic facts. No namespace check on the predicate strings. */ + ingestFacts(facts: FactIngest[]): Promise; + + /** Read-only: full-text Turn search. Returns implementation-specific hits + * — the boilerplate keeps the row type opaque (`unknown`) to avoid + * pulling in the whole KG type surface from `@omadia/plugin-api`. + * Plugins that need the structured shape: `import type { TurnSearchHit } + * from '@omadia/plugin-api'` and add the package as a peerDep. */ + searchTurns(opts: Readonly>): Promise; + + /** Read-only: turns mentioning a given entity. */ + findEntityCapturedTurns( + opts: Readonly>, + ): Promise; + + /** Read-only: graph neighbours of a node. */ + getNeighbors(nodeId: string): Promise; + + /** Coarse counts for UI/sanity checks. */ + stats(): Promise>>; + + /** Namespace whitelist passed at construction — useful for choosing a + * default `system` when there's only one. */ + readonly entitySystems: readonly string[]; +} diff --git a/middleware/assets/boilerplate/agent-pure-llm/manifest.yaml b/middleware/assets/boilerplate/agent-pure-llm/manifest.yaml index 1fc57b67d..ea6030853 100644 --- a/middleware/assets/boilerplate/agent-pure-llm/manifest.yaml +++ b/middleware/assets/boilerplate/agent-pure-llm/manifest.yaml @@ -22,7 +22,7 @@ identity: description: "{{AGENT_DESCRIPTION_DE}}" authors: - name: "byte5 GmbH" - email: "dev@byte5.de" + email: "info@omadia.ai" license: "Proprietary" icon: "./assets/icon.png" categories: diff --git a/middleware/package-lock.json b/middleware/package-lock.json index b83ce6e05..40e546be3 100644 --- a/middleware/package-lock.json +++ b/middleware/package-lock.json @@ -32,6 +32,8 @@ "jose": "^6.2.2", "multer": "^2.1.1", "pg": "^8.13.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "sharp": "^0.33.5", "typescript-eslint": "^8.58.2", "undici": "^7.25.0", @@ -1356,6 +1358,10 @@ "resolved": "packages/harness-plugin-quality-guard", "link": true }, + "node_modules/@omadia/plugin-ui-helpers": { + "resolved": "packages/harness-ui-helpers", + "link": true + }, "node_modules/@omadia/plugin-web-search": { "resolved": "packages/harness-plugin-web-search", "link": true @@ -2101,6 +2107,13 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.15.0", "license": "MIT" @@ -2109,6 +2122,27 @@ "version": "1.2.7", "license": "MIT" }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, "node_modules/@types/send": { "version": "1.2.1", "license": "MIT", @@ -3056,6 +3090,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/dayjs": { "version": "1.11.20", "license": "MIT" @@ -4143,6 +4184,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "license": "MIT", @@ -4286,6 +4333,18 @@ "version": "4.1.1", "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/make-error": { "version": "1.3.6", "dev": true, @@ -4964,6 +5023,31 @@ "node": ">=0.10.0" } }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "license": "MIT", @@ -5052,6 +5136,15 @@ "version": "2.1.2", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/semver": { "version": "7.7.4", "license": "ISC", @@ -5765,13 +5858,14 @@ }, "packages/agent-reference-maximum": { "name": "@omadia/agent-reference-maximum", - "version": "0.1.0", + "version": "0.3.2", "license": "MIT", "engines": { "node": ">=20" }, "peerDependencies": { "@omadia/plugin-api": "*", + "@omadia/plugin-ui-helpers": "*", "express": "^5.1.0", "zod": "^3.23.8" } @@ -6032,6 +6126,34 @@ "zod": "^3.23.8" } }, + "packages/harness-ui-helpers": { + "name": "@omadia/plugin-ui-helpers", + "version": "0.2.0", + "license": "MIT", + "devDependencies": { + "@types/express": "^5.0.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "express": "^5.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "packages/harness-verifier": { "name": "@omadia/verifier", "version": "0.1.0", diff --git a/middleware/package.json b/middleware/package.json index 9a42459a6..a464b3df6 100644 --- a/middleware/package.json +++ b/middleware/package.json @@ -9,20 +9,21 @@ ], "scripts": { "preinstall": "node scripts/check-node-version.mjs", - "build": "npm run build -w @omadia/plugin-api && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/embeddings && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/orchestrator && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/plugin-privacy-detector-ollama && npm run build -w @omadia/plugin-privacy-detector-presidio && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && tsc && node scripts/copy-build-assets.mjs", + "build": "npm run build -w @omadia/plugin-api && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/embeddings && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/orchestrator && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/plugin-privacy-detector-ollama && npm run build -w @omadia/plugin-privacy-detector-presidio && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && tsc && node scripts/copy-build-assets.mjs", "start": "node dist/index.js", - "dev": "node scripts/ensure-native-abi.mjs && npm run build -w @omadia/plugin-api && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/embeddings && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/orchestrator && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/plugin-privacy-detector-ollama && npm run build -w @omadia/plugin-privacy-detector-presidio && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && tsx watch --ignore './.memory/**' --ignore './.uploaded-packages/**' --ignore './data/**' --ignore './dist/**' --ignore './packages/*/dist/**' --ignore './seed/**' src/index.ts", + "dev": "node scripts/ensure-native-abi.mjs && npm run build -w @omadia/plugin-api && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/embeddings && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/orchestrator && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/plugin-privacy-detector-ollama && npm run build -w @omadia/plugin-privacy-detector-presidio && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && tsx watch --ignore './.memory/**' --ignore './.uploaded-packages/**' --ignore './data/**' --ignore './dist/**' --ignore './packages/*/dist/**' --ignore './seed/**' src/index.ts", "dev:clean": "node scripts/dev-clean.mjs && npm run dev", "ensure-native-abi": "node scripts/ensure-native-abi.mjs", - "lint": "eslint src/ packages/plugin-api/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-embeddings/src/ packages/harness-knowledge-graph-inmemory/src/ packages/harness-knowledge-graph-neon/src/ packages/harness-orchestrator-extras/src/ packages/harness-verifier/src/ packages/harness-orchestrator/src/ packages/harness-plugin-web-search/src/ packages/harness-plugin-quality-guard/src/ packages/harness-plugin-privacy-guard/src/ packages/harness-plugin-privacy-detector-ollama/src/ packages/harness-plugin-privacy-detector-presidio/src/", - "lint:fix": "eslint src/ packages/plugin-api/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-embeddings/src/ packages/harness-knowledge-graph-inmemory/src/ packages/harness-knowledge-graph-neon/src/ packages/harness-orchestrator-extras/src/ packages/harness-verifier/src/ packages/harness-orchestrator/src/ packages/harness-plugin-web-search/src/ packages/harness-plugin-quality-guard/src/ packages/harness-plugin-privacy-guard/src/ packages/harness-plugin-privacy-detector-ollama/src/ packages/harness-plugin-privacy-detector-presidio/src/ --fix", - "typecheck": "npm run typecheck -w @omadia/plugin-api && npm run typecheck -w @omadia/channel-sdk && npm run typecheck -w @omadia/diagrams && npm run typecheck -w @omadia/memory && npm run typecheck -w @omadia/embeddings && npm run typecheck -w @omadia/knowledge-graph-inmemory && npm run typecheck -w @omadia/knowledge-graph-neon && npm run typecheck -w @omadia/orchestrator-extras && npm run typecheck -w @omadia/verifier && npm run typecheck -w @omadia/orchestrator && npm run typecheck -w @omadia/plugin-web-search && npm run typecheck -w @omadia/plugin-quality-guard && npm run typecheck -w @omadia/plugin-privacy-guard && npm run typecheck -w @omadia/plugin-privacy-detector-ollama && npm run typecheck -w @omadia/plugin-privacy-detector-presidio && npm run typecheck -w @omadia/agent-seo-analyst && npm run typecheck -w @omadia/agent-reference-maximum && tsc --noEmit", + "lint": "eslint src/ packages/plugin-api/src/ packages/harness-ui-helpers/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-embeddings/src/ packages/harness-knowledge-graph-inmemory/src/ packages/harness-knowledge-graph-neon/src/ packages/harness-orchestrator-extras/src/ packages/harness-verifier/src/ packages/harness-orchestrator/src/ packages/harness-plugin-web-search/src/ packages/harness-plugin-quality-guard/src/ packages/harness-plugin-privacy-guard/src/ packages/harness-plugin-privacy-detector-ollama/src/ packages/harness-plugin-privacy-detector-presidio/src/", + "lint:fix": "eslint src/ packages/plugin-api/src/ packages/harness-ui-helpers/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-embeddings/src/ packages/harness-knowledge-graph-inmemory/src/ packages/harness-knowledge-graph-neon/src/ packages/harness-orchestrator-extras/src/ packages/harness-verifier/src/ packages/harness-orchestrator/src/ packages/harness-plugin-web-search/src/ packages/harness-plugin-quality-guard/src/ packages/harness-plugin-privacy-guard/src/ packages/harness-plugin-privacy-detector-ollama/src/ packages/harness-plugin-privacy-detector-presidio/src/ --fix", + "typecheck": "npm run typecheck -w @omadia/plugin-api && npm run typecheck -w @omadia/plugin-ui-helpers && npm run typecheck -w @omadia/channel-sdk && npm run typecheck -w @omadia/diagrams && npm run typecheck -w @omadia/memory && npm run typecheck -w @omadia/embeddings && npm run typecheck -w @omadia/knowledge-graph-inmemory && npm run typecheck -w @omadia/knowledge-graph-neon && npm run typecheck -w @omadia/orchestrator-extras && npm run typecheck -w @omadia/verifier && npm run typecheck -w @omadia/orchestrator && npm run typecheck -w @omadia/plugin-web-search && npm run typecheck -w @omadia/plugin-quality-guard && npm run typecheck -w @omadia/plugin-privacy-guard && npm run typecheck -w @omadia/plugin-privacy-detector-ollama && npm run typecheck -w @omadia/plugin-privacy-detector-presidio && npm run typecheck -w @omadia/agent-seo-analyst && npm run typecheck -w @omadia/agent-reference-maximum && tsc --noEmit", "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "smoke:entity-refs": "tsx scripts/smoke-entity-refs.ts", "smoke:agent-reference": "tsx scripts/smoke-agent-reference.ts", "check:integration-md": "node scripts/check-integration-md.mjs", "smoke:diagrams": "tsx scripts/smoke-diagrams.ts", + "smoke:privacy-v2": "tsx scripts/smoke-privacy-v2.ts", "smoke:package-roundtrip": "tsx scripts/smoke-package-roundtrip.ts", "setup:tigris-lifecycle": "tsx scripts/setup-tigris-lifecycle.ts", "pretest": "node scripts/check-node-version.mjs", @@ -53,6 +54,8 @@ "jose": "^6.2.2", "multer": "^2.1.1", "pg": "^8.13.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "sharp": "^0.33.5", "typescript-eslint": "^8.58.2", "undici": "^7.25.0", diff --git a/middleware/packages/agent-reference-maximum/INTEGRATION.md b/middleware/packages/agent-reference-maximum/INTEGRATION.md index 4598df730..aef093b90 100644 --- a/middleware/packages/agent-reference-maximum/INTEGRATION.md +++ b/middleware/packages/agent-reference-maximum/INTEGRATION.md @@ -242,7 +242,7 @@ return JSON.stringify({ rationale: 'Mehrere Treffer; bitte exakte Notiz auswählen.', options: [ { label: 'John Doe', value: 'note:n1' }, - { label: 'Jane Doe', value: 'note:n2' }, + { label: 'John Müller', value: 'note:n2' }, ], }, }); diff --git a/middleware/packages/agent-reference-maximum/manifest.yaml b/middleware/packages/agent-reference-maximum/manifest.yaml index 47e3e1eef..8a908085d 100644 --- a/middleware/packages/agent-reference-maximum/manifest.yaml +++ b/middleware/packages/agent-reference-maximum/manifest.yaml @@ -5,12 +5,12 @@ identity: kind: "agent" domain: "reference" name: "Reference (Builder Pattern Source)" - version: "0.1.0" + version: "0.3.2" description: "Builder-Reference-Plugin: lauffähige, credential-lose Codebase, die alle heute verfügbaren Plugin-API-Patterns in einer Stelle demonstriert. Pattern-Quelle für den BuilderAgent. Sekundärnutzen: Personal-Knowledge-Companion." authors: - name: "byte5 GmbH" - email: "dev@byte5.de" - url: "https://byte5.de" + email: "info@omadia.ai" + url: "https://omadia.ai" license: "Proprietary" categories: - "reference" diff --git a/middleware/packages/agent-reference-maximum/package.json b/middleware/packages/agent-reference-maximum/package.json index 0a82b3597..c2959998c 100644 --- a/middleware/packages/agent-reference-maximum/package.json +++ b/middleware/packages/agent-reference-maximum/package.json @@ -1,6 +1,6 @@ { "name": "@omadia/agent-reference-maximum", - "version": "0.1.0", + "version": "0.3.2", "private": true, "type": "module", "main": "dist/index.js", @@ -12,6 +12,7 @@ }, "peerDependencies": { "@omadia/plugin-api": "*", + "@omadia/plugin-ui-helpers": "*", "express": "^5.1.0", "zod": "^3.23.8" }, diff --git a/middleware/packages/agent-reference-maximum/plugin.ts b/middleware/packages/agent-reference-maximum/plugin.ts index ff861f979..23a9f0a1b 100644 --- a/middleware/packages/agent-reference-maximum/plugin.ts +++ b/middleware/packages/agent-reference-maximum/plugin.ts @@ -1,6 +1,7 @@ import { weeklyDigestJob } from './jobs/weeklyDigest.js'; import { createNotesStore } from './notesStore.js'; import { createHealthRouter } from './routes/healthRouter.js'; +import { createUiRouter } from './routes/uiRouter.js'; import { createToolkit, type Toolkit } from './toolkit.js'; import type { PluginContext } from './types.js'; @@ -41,14 +42,47 @@ export async function activate(ctx: PluginContext): Promise { llm: ctx.llm, }); + // Wrap the addNote handler so a successful note-add also fires a + // cross-channel notification through ctx.notifications. Fire-and-forget: + // notification delivery must never block (or fail) the tool result. + // The wrapped handler still returns the original toolkit string so the + // orchestrator sees no change in shape or content. + const addNoteHandler = async (raw: unknown): Promise => { + const result = await toolkit.handlers.addNote(raw); + const bodyPreview = + typeof raw === 'object' && + raw !== null && + 'body' in raw && + typeof (raw as { body: unknown }).body === 'string' + ? ((raw as { body: string }).body.length > 140 + ? `${(raw as { body: string }).body.slice(0, 140)}…` + : (raw as { body: string }).body) + : 'note'; + ctx.notifications + .send({ + title: 'Note added', + body: bodyPreview, + deepLink: '/p/agent-reference-maximum/dashboard', + }) + .catch((err) => { + ctx.log( + 'notify failed:', + err instanceof Error ? err.message : String(err), + ); + }); + return result; + }; + const disposeAddNote = ctx.tools.register( toolkit.specs.addNote, - toolkit.handlers.addNote, + addNoteHandler, { promptDoc: 'Schreibt eine kurze Notiz in den Plugin-Memory-Scope. Nutzt die ' + 'Smart-Card-Attachment-Pattern, um die gespeicherte Notiz inline ' + - 'als note-card im Channel zu rendern.', + 'als note-card im Channel zu rendern. Sendet zusätzlich eine ' + + 'Notification über ctx.notifications.send — demonstriert das ' + + 'Cross-Channel-Notification-Pattern.', attachmentSink: () => toolkit.takeAddNoteAttachments(), }, ); @@ -98,6 +132,17 @@ export async function activate(ctx: PluginContext): Promise { const healthRouter = createHealthRouter({ notes }); const disposeRoute = ctx.routes.register('/agents/reference', healthRouter); + const uiRouter = createUiRouter({ notes }); + const disposeUi = ctx.routes.register('/p/agent-reference-maximum', uiRouter); + const disposeUiDescriptor = ctx.uiRoutes.register({ + routeId: 'dashboard', + path: '/dashboard', + title: 'Reference Agent — Dashboard', + description: + 'Pattern-Source-Plugin: live notes counter + recent-notes list. Self-refreshes every 30 s.', + order: 50, + }); + const disposeJob = ctx.jobs.register( { name: 'weekly-digest-programmatic', @@ -125,6 +170,8 @@ export async function activate(ctx: PluginContext): Promise { disposeSmartExtract(); disposeQueryNotes(); disposeRoute(); + disposeUi(); + disposeUiDescriptor(); disposeJob(); disposeService(); }, diff --git a/middleware/packages/agent-reference-maximum/routes/uiRouter.ts b/middleware/packages/agent-reference-maximum/routes/uiRouter.ts new file mode 100644 index 000000000..742dbcdab --- /dev/null +++ b/middleware/packages/agent-reference-maximum/routes/uiRouter.ts @@ -0,0 +1,75 @@ +import { Router } from 'express'; +import { html, htmlDoc, renderRoute, safe } from '@omadia/plugin-ui-helpers'; + +import type { NotesStore } from '../notesStore.js'; + +export interface UiRouterOptions { + readonly notes: NotesStore; +} + +/** + * PoC plugin-served UI route. Renders an HTML page directly from middleware + * Express; web-ui rewrites `/p/agent-reference-maximum/*` to land here. + * + * No React, no client JS — sketch phase. Tailwind via CDN. iframe-safe + * headers come from renderRoute() automatically. + */ +export function createUiRouter(opts: UiRouterOptions): Router { + const router = Router(); + + router.get( + '/dashboard', + renderRoute(async () => { + const notes = await opts.notes.list(); + const noteItems = notes.slice(0, 10).map( + (n) => html` +
  • +
    ${n.id}
    +
    ${n.body}
    +
  • + `, + ); + return htmlDoc({ + title: 'Reference Agent — Dashboard', + // Self-filling: re-fetch every 30s so a Teams Tab pinned to + // this URL surfaces new notes (added via the bot in chat) + // without the user having to refresh manually. + refreshSeconds: 30, + body: html` +
    +
    +

    + Reference Agent +

    +

    + Plugin-served UI Surface — PoC 2026-05-15 +

    +
    + +
    +
    +
    Notes
    +
    ${notes.length}
    +
    +
    +
    Source
    +
    + agent-reference-maximum +
    +
    +
    + +
    +

    Recent notes

    + ${notes.length === 0 + ? safe('

    No notes yet.

    ') + : html`
      ${noteItems}
    `} +
    +
    + `, + }); + }), + ); + + return router; +} diff --git a/middleware/packages/agent-seo-analyst/README.md b/middleware/packages/agent-seo-analyst/README.md index 2ab599cdd..48adbefd5 100644 --- a/middleware/packages/agent-seo-analyst/README.md +++ b/middleware/packages/agent-seo-analyst/README.md @@ -1,85 +1,75 @@ # SEO Analyst Agent -Public-website SEO analyser. Reference implementation for the plugin -package format and the ZIP-upload flow. +Analysiert byte5-eigene Webseiten aus SEO-Sicht. Erster "echter" Agent im neuen Package-Format — dient gleichzeitig als **Referenz-Implementation für den Zip-Upload-Flow**. -## Why this agent as a reference +## Warum dieser Agent zuerst? -- **No secrets.** Works on publicly reachable URLs — no OAuth, no API tokens. -- **Zero peer-deps.** Uses only `zod` (already in the host) + native `fetch` - + a regex-based HTML extractor. No `cheerio`, no headless browser. -- **Deterministic.** Same HTML input → same report + score. -- **Small but realistic.** Three real tools, structured outputs, an issue - list with severity, score with rubric. +- **Keine Secrets.** Arbeitet auf öffentlich erreichbaren Seiten, kein OAuth, kein API-Token. +- **Zero peer-deps.** Nutzt nur `zod` (schon im Host) + native `fetch` + Regex-HTML-Extraktor. Kein `cheerio`, kein Headless-Browser. +- **Deterministisch.** Gleicher HTML-Input → gleicher Report + Score. +- **Klein, aber realistisch.** Drei echte Tools, strukturierte Outputs, Issue-Liste mit Severity, Score mit Rubrik. ## Tools -| Tool | Purpose | +| Tool | Zweck | |---|---| -| `analyze_page(url)` | On-page report for a single URL: meta, headings, links, images, JSON-LD, issues, score. | -| `check_technical_seo(base_url?)` | robots.txt, sitemaps, HTTPS, security headers. | -| `audit_site(start_url?, max_pages?, max_depth?)` | BFS crawl within the same host, aggregates on-page issues across pages. | +| `analyze_page(url)` | On-Page-Report für eine einzelne URL: Meta, Headings, Links, Bilder, JSON-LD, Issues, Score. | +| `check_technical_seo(base_url?)` | robots.txt, Sitemaps, HTTPS, Security-Header. | +| `audit_site(start_url?, max_pages?, max_depth?)` | BFS-Crawl innerhalb derselben Host, aggregiert On-Page-Issues über alle Seiten. | -Unset `base_url` / `start_url` → falls back to `target_base_url` from the -install setup. +Unset `base_url` / `start_url` → Fallback auf `target_base_url` aus dem Install-Setup (Default `https://omadia.ai`). -## Setup fields +## Setup-Felder -All declared in the manifest under `setup.fields` — no secrets: +Alle im Manifest unter `setup.fields` — keine Secrets: -- `target_base_url` (required) — root URL the agent analyses -- `user_agent` (optional) — bot identifier sent on each fetch +- `target_base_url` (required, default `https://omadia.ai`) +- `user_agent` (optional, default `byte5-seo-bot/0.1 …`) - `crawl_max_pages` (optional, default 25, hard cap 100) - `crawl_max_depth` (optional, default 3, hard cap 5) - `request_timeout_ms` (optional, default 15000) -## Directory layout +## Verzeichnis-Layout ``` middleware/packages/agent-seo-analyst/ -├── manifest.yaml +├── manifest.yaml ─────────────► docs/harness-platform/examples/agent-seo-analyst.manifest.yaml ├── package.json ├── plugin.ts # activate(ctx) → AgentHandle ├── toolkit.ts # ToolDescriptor[] + createToolkit() -├── fetcher.ts # native fetch + regex HTML extractor -├── types.ts # report types -├── index.ts # public exports +├── fetcher.ts # native fetch + regex HTML-Extractor +├── types.ts # Report-Typen +├── index.ts # öffentliche Exports ├── analyzers/ -│ ├── onPage.ts # meta/headings/links/images/JSON-LD → issues -│ ├── technical.ts # robots.txt + sitemap.xml + headers -│ ├── crawler.ts # BFS site audit -│ └── scoring.ts # score rubric (page + technical + site) +│ ├── onPage.ts # Meta/Headings/Links/Images/JSON-LD → Issues +│ ├── technical.ts # robots.txt + sitemap.xml + Header +│ ├── crawler.ts # BFS site-audit +│ └── scoring.ts # Score-Rubrik (Page + Technical + Site) └── skills/ - ├── seo-expert.md # role + analysis framing for the LLM - └── scoring-rubric.md # how the score is derived (explainability) + ├── seo-expert.md # Rolle + Analyse-Rahmen für den LLM + └── scoring-rubric.md # Score-Herleitung zum Erklären ``` -## Gotchas +## Stolperfallen -- **The regex HTML extractor is intentionally minimal.** Selector / DOM - traversal would need `cheerio` or `linkedom` — explicitly omitted so the - package introduces no new peer-dep. SEO-relevant tags (``, - ``, headings, anchors, images, JSON-LD scripts) work fine. -- **No JavaScript rendering.** SPAs that render content client-side are - invisible to this agent. For those, a Playwright variant is required. -- **The self-test is a GET on `target_base_url` with a short timeout.** - If it fails, the agent does not activate. -- **The crawl budget is hard-capped** (100 pages / depth 5). No accidental - full-domain crawl is possible. +- **Regex-HTML-Extractor** ist bewusst minimal. Für Selektoren / DOM-Traversierung wäre `cheerio` oder `linkedom` nötig → bewusst weggelassen, damit das Package keine neue peerDep einschleppt. Für SEO-relevante Tags (`<meta>`, `<title>`, Headings, Anchors, Images, JSON-LD-Scripts) reicht es. +- **Kein JavaScript-Rendering.** SPAs, die erst client-side Content rendern, zeigen für diesen Agent kein Inhalt. Für solche Seiten braucht es eine Playwright-Variante (Phase 2). +- **Self-Test** ist ein GET auf `target_base_url` mit kurzem Timeout. Schlägt fehl → Agent aktiviert nicht. +- **Crawl-Budget** ist hart begrenzt (100 Seiten / Tiefe 5). Kein versehentlicher Vollcrawl der Domain möglich. -## ZIP build +## Zip-Build ```bash node middleware/scripts/build-seo-analyst-zip.mjs # → out/seo-analyst-0.1.0.zip -# → out/seo-analyst-package/ (staging, for inspection) +# → out/seo-analyst-package/ (Staging, zur Inspektion) ``` -What's inside: +Was drin ist: ``` seo-analyst-0.1.0.zip -├── manifest.yaml +├── manifest.yaml # aus dem Package-Root (nicht mehr docs/examples/) ├── package.json ├── README.md ├── dist/ @@ -94,7 +84,7 @@ seo-analyst-0.1.0.zip └── scoring-rubric.md ``` -The build script uses the package-local `tsconfig.json` (no cross-references -into the middleware tree — the ZIP is standalone). `PluginContext` is -duplicated structurally in `types.ts` so the agent doesn't need to import -from `middleware/src/platform`. +Das Build-Script nutzt die package-lokale `tsconfig.json` (keine Querverweise ins +middleware-Tree — das Zip ist standalone). `PluginContext` ist in `types.ts` +strukturell dupliziert, damit der Agent ohne Import aus `middleware/src/platform` +auskommt. diff --git a/middleware/packages/agent-seo-analyst/manifest.yaml b/middleware/packages/agent-seo-analyst/manifest.yaml index fa4198883..ec2c59491 100644 --- a/middleware/packages/agent-seo-analyst/manifest.yaml +++ b/middleware/packages/agent-seo-analyst/manifest.yaml @@ -5,12 +5,12 @@ identity: domain: "seo" name: "SEO Analyst" version: "0.1.0" - description: "Analyses public websites from an SEO perspective: on-page, technical, structured data, crawl audits." + description: "Analysiert byte5-Webseiten aus SEO-Perspektive: On-Page, Technical, Structured Data, Crawl-Audits." authors: - name: "byte5 GmbH" - email: "dev@byte5.de" - url: "https://byte5.de" - license: "MIT" + email: "info@omadia.ai" + url: "https://omadia.ai" + license: "Proprietary" icon: "./assets/icon.svg" categories: - "analytics" @@ -46,18 +46,18 @@ setup: fields: - key: "target_base_url" type: "url" - label: "Target domain" - help: "Root URL of the website to analyse, e.g. https://example.com" + label: "Ziel-Domain" + help: "Root-URL der zu analysierenden Webseite, z.B. https://omadia.ai" required: true - default: "https://example.com" + default: "https://omadia.ai" pattern: "^https?://.+" - key: "user_agent" type: "string" label: "User-Agent" - help: "Bot identifier sent on each fetch. Checked against robots.txt." + help: "Bezeichner für Fetches. Wird in robots.txt geprüft." required: false - default: "omadia-seo-bot/0.1" + default: "byte5-seo-bot/0.1 (+https://omadia.ai)" - key: "crawl_max_pages" type: "integer" @@ -172,23 +172,25 @@ capabilities: playbook: when_to_use: | - For SEO questions about the website configured under `target_base_url`: - "How does the About page hold up SEO-wise?", "Which H1 issues does the - landing page have?", "Do we have structured data for the Organization - schema?". The agent only operates on publicly reachable pages — no auth, - no secrets. + Für SEO-Fragen rund um byte5-eigene Webseiten: "Wie steht die About-Seite SEO-technisch da?", + "Welche H1-Probleme hat unsere Startseite?", "Haben wir Structured-Data für Organization?". + Der Agent arbeitet ausschließlich auf öffentlich erreichbaren Seiten — keine Auth, keine Secrets. + + combines_with: + - agent: "de.byte5.agent.confluence" + why: "SEO-Befunde gegen interne Content-Guidelines aus Confluence abgleichen." not_for: - - "Keyword research via external SEO tools (Ahrefs, SEMrush)" - - "Lighthouse performance audits (would need a headless browser)" - - "Non-public / auth-gated pages" - - "Content changes on the CMS (this agent is read-only)" + - "Keyword-Research mit externen SEO-Tools (Ahrefs, SEMrush)" + - "Lighthouse-Performance-Audits (braucht Headless-Browser, Phase 2)" + - "Nicht-öffentliche / auth-gated Seiten" + - "Content-Änderungen am CMS (read-only)" example_prompts: - - "Analyse https://example.com/about and show me the most important SEO issues." - - "Check the technical SEO configuration of the configured domain." - - "Crawl the configured domain at depth 2 and list the top issues across pages." - - "Do we have JSON-LD on the home page?" + - "Analysiere https://omadia.ai/leistungen und zeig mir die wichtigsten SEO-Issues." + - "Check mal die technische SEO-Konfiguration der byte5-Domain." + - "Crawl omadia.ai bis Tiefe 2 und gib mir die Top-Fehler über alle Seiten." + - "Haben wir JSON-LD auf der Startseite?" entities: produces: @@ -211,9 +213,8 @@ permissions: network: outbound: - # The host derived from `target_base_url` at install time is allowed - # automatically by the runtime — no need to enumerate it here. - - "*" + - "omadia.ai" + - "*.omadia.ai" filesystem: scratch: false diff --git a/middleware/packages/agent-seo-analyst/package.json b/middleware/packages/agent-seo-analyst/package.json index 2f115cb1b..e17380fcd 100644 --- a/middleware/packages/agent-seo-analyst/package.json +++ b/middleware/packages/agent-seo-analyst/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "main": "dist/index.js", - "description": "SEO analyst agent for public websites (on-page, technical, crawl audit). Reference plugin package.", + "description": "SEO-Analyst-Agent für byte5-eigene Webseiten (On-Page, Technical, Crawl-Audit).", "license": "MIT", "scripts": { "build": "tsc", diff --git a/middleware/packages/agent-seo-analyst/plugin.ts b/middleware/packages/agent-seo-analyst/plugin.ts index ced8dcfea..177c2c3b4 100644 --- a/middleware/packages/agent-seo-analyst/plugin.ts +++ b/middleware/packages/agent-seo-analyst/plugin.ts @@ -4,8 +4,8 @@ import type { PluginContext } from './types.js'; export const AGENT_ID = '@omadia/agent-seo-analyst' as const; -const DEFAULT_BASE_URL = 'https://example.com'; -const DEFAULT_USER_AGENT = 'omadia-seo-bot/0.1'; +const DEFAULT_BASE_URL = 'https://omadia.ai'; +const DEFAULT_USER_AGENT = 'byte5-seo-bot/0.1 (+https://omadia.ai)'; const DEFAULT_MAX_PAGES = 25; const DEFAULT_MAX_DEPTH = 3; const DEFAULT_TIMEOUT_MS = 15_000; diff --git a/middleware/packages/agent-seo-analyst/skills/seo-expert.md b/middleware/packages/agent-seo-analyst/skills/seo-expert.md index e6c762535..8ca99f091 100644 --- a/middleware/packages/agent-seo-analyst/skills/seo-expert.md +++ b/middleware/packages/agent-seo-analyst/skills/seo-expert.md @@ -5,7 +5,7 @@ kind: prompt_partial # Rolle: SEO-Analyst -Du bist ein pragmatischer SEO-Analyst für die unter `target_base_url` im Setup konfigurierte Website. Du arbeitest ausschließlich mit den strukturierten Reports, die die Tools `analyze_page`, `check_technical_seo` und `audit_site` liefern — du **rätst nicht** und **erfindest keine Befunde**. +Du bist ein pragmatischer SEO-Analyst für die byte5-eigenen Webseiten. Du arbeitest ausschließlich mit den strukturierten Reports, die die Tools `analyze_page`, `check_technical_seo` und `audit_site` liefern — du **rätst nicht** und **erfindest keine Befunde**. ## Analyse-Rahmen diff --git a/middleware/packages/harness-channel-sdk/src/egressWalker.ts b/middleware/packages/harness-channel-sdk/src/egressWalker.ts new file mode 100644 index 000000000..01c68bb5d --- /dev/null +++ b/middleware/packages/harness-channel-sdk/src/egressWalker.ts @@ -0,0 +1,260 @@ +/** + * Privacy-Shield v2 — Slice S-6 — ChatTurnResult ↔ egress-filter glue. + * + * The privacy-guard service speaks in opaque `{ id, text }` slots so + * it stays channel-agnostic. This module knows the shape of the + * kernel's `ChatTurnResult` and serialises it into the flat slot + * vocabulary, then re-merges the egress filter's replacements back + * into the structural payload. + * + * Slot IDs are deterministic so a `replacements` map shipped from + * the filter can be applied without preserving order. Each slot + * corresponds to a single user-facing text region: + * + * - `answer` → result.answer + * - `interactive.question` → choice / slots / topic question + * - `interactive.rationale` → choice rationale + * - `interactive.option.<i>.label` → choice/topic option labels + * - `interactive.topic.<i>.hint` → topic-hint strings + * - `interactive.slot.<i>.label` → slot-picker row labels + * - `interactive.subjectHint` → slot-picker subjectHint + * - `interactive.routine.<i>.name` → routine-list row name + * - `interactive.routine.<i>.prompt` → routine-list row prompt + * - `attachment.<i>.altText` → attachment alt-text + * - `followUp.<i>.label` → follow-up button label + * - `followUp.<i>.prompt` → follow-up button prompt + * + * Empty / undefined slots are skipped; the host never asks the filter + * to scan an empty string. `applyEgressReplacements` is tolerant of + * partial replacement maps — slots without a replacement keep their + * original value. + */ + +import type { ChatTurnResult } from './chatAgent.js'; + +export interface EgressSlot { + readonly id: string; + readonly text: string; +} + +/** + * Serialise every user-facing text slot in a `ChatTurnResult` into a + * flat list the privacy-guard egress filter can scan. The walk is + * stable — the same result always produces the same slot order, so + * tests can assert on it. + */ +export function collectEgressSlots(result: ChatTurnResult): EgressSlot[] { + const out: EgressSlot[] = []; + pushNonEmpty(out, 'answer', result.answer); + + if (result.pendingUserChoice) { + pushNonEmpty(out, 'interactive.question', result.pendingUserChoice.question); + pushNonEmpty( + out, + 'interactive.rationale', + result.pendingUserChoice.rationale, + ); + result.pendingUserChoice.options.forEach((o, i) => { + pushNonEmpty(out, `interactive.option.${String(i)}.label`, o.label); + }); + } else if (result.pendingSlotCard) { + pushNonEmpty(out, 'interactive.question', result.pendingSlotCard.question); + pushNonEmpty( + out, + 'interactive.subjectHint', + result.pendingSlotCard.subjectHint, + ); + result.pendingSlotCard.slots.forEach((s, i) => { + pushNonEmpty(out, `interactive.slot.${String(i)}.label`, s.label); + }); + } else if (result.pendingRoutineList) { + result.pendingRoutineList.routines.forEach((r, i) => { + pushNonEmpty(out, `interactive.routine.${String(i)}.name`, r.name); + pushNonEmpty(out, `interactive.routine.${String(i)}.prompt`, r.prompt); + }); + } + + if (result.attachments) { + result.attachments.forEach((a, i) => { + pushNonEmpty(out, `attachment.${String(i)}.altText`, a.altText); + }); + } + + if (result.followUpOptions) { + result.followUpOptions.forEach((f, i) => { + pushNonEmpty(out, `followUp.${String(i)}.label`, f.label); + pushNonEmpty(out, `followUp.${String(i)}.prompt`, f.prompt); + }); + } + + return out; +} + +function pushNonEmpty(out: EgressSlot[], id: string, text: string | undefined): void { + if (text === undefined || text.length === 0) return; + out.push({ id, text }); +} + +/** + * Apply a map of `{ id → replacement-text }` back onto a + * `ChatTurnResult`. Slots whose id is not in `replacements` (or whose + * replacement equals the original) keep their structural identity — + * no copy is made, references are reused so downstream identity + * checks stay cheap. Slots whose replacement differs are written + * back into a structurally shallow-copied result. + * + * The function never mutates `result`; it returns a new object only + * if at least one slot changed. + */ +export function applyEgressReplacements( + result: ChatTurnResult, + replacements: ReadonlyMap<string, string>, +): ChatTurnResult { + if (replacements.size === 0) return result; + let changed = false; + const next: ChatTurnResult = { ...result }; + + const rebound = rebind(replacements, 'answer', result.answer); + if (rebound !== result.answer) { + next.answer = rebound; + changed = true; + } + + if (result.pendingUserChoice) { + const q = rebind( + replacements, + 'interactive.question', + result.pendingUserChoice.question, + ); + const r = rebind( + replacements, + 'interactive.rationale', + result.pendingUserChoice.rationale, + ); + const options = result.pendingUserChoice.options.map((o, i) => { + const lbl = rebind(replacements, `interactive.option.${String(i)}.label`, o.label); + return lbl === o.label ? o : { ...o, label: lbl }; + }); + if ( + q !== result.pendingUserChoice.question || + r !== result.pendingUserChoice.rationale || + options.some((o, i) => o !== result.pendingUserChoice?.options[i]) + ) { + next.pendingUserChoice = { + ...result.pendingUserChoice, + question: q, + ...(r !== undefined ? { rationale: r } : {}), + options, + }; + changed = true; + } + } else if (result.pendingSlotCard) { + const q = rebind( + replacements, + 'interactive.question', + result.pendingSlotCard.question, + ); + const h = rebind( + replacements, + 'interactive.subjectHint', + result.pendingSlotCard.subjectHint, + ); + const slots = result.pendingSlotCard.slots.map((s, i) => { + const lbl = rebind(replacements, `interactive.slot.${String(i)}.label`, s.label); + return lbl === s.label ? s : { ...s, label: lbl }; + }); + if ( + q !== result.pendingSlotCard.question || + h !== result.pendingSlotCard.subjectHint || + slots.some((s, i) => s !== result.pendingSlotCard?.slots[i]) + ) { + next.pendingSlotCard = { + ...result.pendingSlotCard, + question: q, + ...(h !== undefined ? { subjectHint: h } : {}), + slots, + }; + changed = true; + } + } else if (result.pendingRoutineList) { + const routines = result.pendingRoutineList.routines.map((r, i) => { + const name = rebind(replacements, `interactive.routine.${String(i)}.name`, r.name); + const prompt = rebind( + replacements, + `interactive.routine.${String(i)}.prompt`, + r.prompt, + ); + if (name === r.name && prompt === r.prompt) return r; + return { ...r, name, prompt }; + }); + if (routines.some((r, i) => r !== result.pendingRoutineList?.routines[i])) { + next.pendingRoutineList = { + ...result.pendingRoutineList, + routines, + }; + changed = true; + } + } + + if (result.attachments) { + const attachments = result.attachments.map((a, i) => { + const alt = rebind(replacements, `attachment.${String(i)}.altText`, a.altText); + return alt === a.altText ? a : { ...a, altText: alt }; + }); + if (attachments.some((a, i) => a !== result.attachments?.[i])) { + next.attachments = attachments; + changed = true; + } + } + + if (result.followUpOptions) { + const followUps = result.followUpOptions.map((f, i) => { + const lbl = rebind(replacements, `followUp.${String(i)}.label`, f.label); + const pmt = rebind(replacements, `followUp.${String(i)}.prompt`, f.prompt); + if (lbl === f.label && pmt === f.prompt) return f; + return { ...f, label: lbl, prompt: pmt }; + }); + if (followUps.some((f, i) => f !== result.followUpOptions?.[i])) { + next.followUpOptions = followUps; + changed = true; + } + } + + return changed ? next : result; +} + +function rebind<T extends string | undefined>( + replacements: ReadonlyMap<string, string>, + id: string, + current: T, +): T { + if (current === undefined || current.length === 0) return current; + const replacement = replacements.get(id); + if (replacement === undefined || replacement === current) return current; + return replacement as T; +} + +/** + * Build a new `ChatTurnResult` whose `answer` is the configured + * block-placeholder and whose interactive / follow-up / attachments + * are all stripped. The host calls this when the egress filter + * returns `routing: 'blocked'` so the channel never sees the + * potentially-PII-bearing payload. + * + * Verifier badge, oauth flag, capture disclosure, privacy receipt and + * the kernel-side observability fields (runTrace, toolCalls, …) are + * preserved — they are non-content metadata the audit pipeline needs. + */ +export function buildBlockedResult( + result: ChatTurnResult, + placeholderText: string, +): ChatTurnResult { + const out: ChatTurnResult = { ...result }; + out.answer = placeholderText; + delete out.attachments; + delete out.followUpOptions; + delete out.pendingUserChoice; + delete out.pendingSlotCard; + delete out.pendingRoutineList; + return out; +} diff --git a/middleware/packages/harness-channel-sdk/src/index.ts b/middleware/packages/harness-channel-sdk/src/index.ts index 95797e966..d65360c64 100644 --- a/middleware/packages/harness-channel-sdk/src/index.ts +++ b/middleware/packages/harness-channel-sdk/src/index.ts @@ -47,6 +47,16 @@ export type { // adapters can call it without crossing into kernel internals. export { toSemanticAnswer } from './toSemanticAnswer.js'; +// Privacy-Shield v2 (Slice S-6) — egress filter glue. Serialises a +// `ChatTurnResult` into the privacy-guard's flat `{ id, text }` slot +// shape and re-merges the filter's replacements back onto the result. +export { + collectEgressSlots, + applyEgressReplacements, + buildBlockedResult, + type EgressSlot, +} from './egressWalker.js'; + // Semantic outgoing-message contracts (connectors render native) export type { SemanticAnswer, diff --git a/middleware/packages/harness-channel-sdk/src/plugin.ts b/middleware/packages/harness-channel-sdk/src/plugin.ts index ab08b48fd..0fc55464f 100644 --- a/middleware/packages/harness-channel-sdk/src/plugin.ts +++ b/middleware/packages/harness-channel-sdk/src/plugin.ts @@ -69,4 +69,15 @@ export interface ChannelPluginResolver { resolve( agentId: string, ): Promise<ChannelPlugin | undefined> | ChannelPlugin | undefined; + + /** + * Drop any cached ChannelPlugin implementation for this agentId so the + * next `resolve()` re-imports from disk. Required between deactivate and + * re-activate during a plugin upgrade — otherwise the old module wins + * because dynamic `import()` is keyed by URL and the agentId-keyed + * resolver cache returns the stale value. + * + * Optional: synchronous fixed-imports resolvers have nothing to drop. + */ + invalidate?(agentId: string): void; } diff --git a/middleware/packages/harness-embeddings/manifest.yaml b/middleware/packages/harness-embeddings/manifest.yaml index 60420477f..223c6c1ce 100644 --- a/middleware/packages/harness-embeddings/manifest.yaml +++ b/middleware/packages/harness-embeddings/manifest.yaml @@ -9,8 +9,8 @@ identity: description: "Stateless embedding-compute plugin (Ollama-compatible /api/embeddings wrapper, concurrency-limited). Provider des Kernel-Service 'embeddingClient' — konsumiert von KG-Ingest, ContextRetriever, FactExtractor, TopicDetector." authors: - name: "byte5 GmbH" - email: "dev@byte5.de" - url: "https://byte5.de" + email: "info@omadia.ai" + url: "https://omadia.ai" license: "Proprietary" categories: - "infrastructure" diff --git a/middleware/packages/harness-knowledge-graph-inmemory/manifest.yaml b/middleware/packages/harness-knowledge-graph-inmemory/manifest.yaml index ff7e4bdc2..3520d973b 100644 --- a/middleware/packages/harness-knowledge-graph-inmemory/manifest.yaml +++ b/middleware/packages/harness-knowledge-graph-inmemory/manifest.yaml @@ -9,8 +9,8 @@ identity: description: "In-memory KnowledgeGraph provider plugin. No persistence, no embeddings, no Postgres — every node/edge lives in process RAM and disappears on restart. Provider der Capabilities `knowledgeGraph@1` und `entityRefBus@1`. Operator wählt diesen Provider via RequiresWizard wenn keine Neon-Datenbank verfügbar ist (Empty-Middleware-Demo, lokales Dev, CI). Mutual exclusion mit @omadia/knowledge-graph-neon — nur einer der beiden Provider darf gleichzeitig in installed.json stehen, der Capability-Resolver duldet keine zwei aktiven Provider für `knowledgeGraph@1`." authors: - name: "byte5 GmbH" - email: "dev@byte5.de" - url: "https://byte5.de" + email: "info@omadia.ai" + url: "https://omadia.ai" license: "Proprietary" categories: - "infrastructure" diff --git a/middleware/packages/harness-knowledge-graph-inmemory/src/inMemoryKnowledgeGraph.ts b/middleware/packages/harness-knowledge-graph-inmemory/src/inMemoryKnowledgeGraph.ts index 54f6fc24f..d7f3d2d44 100644 --- a/middleware/packages/harness-knowledge-graph-inmemory/src/inMemoryKnowledgeGraph.ts +++ b/middleware/packages/harness-knowledge-graph-inmemory/src/inMemoryKnowledgeGraph.ts @@ -29,8 +29,8 @@ import { type GraphNodeType, type GraphStats, type KnowledgeGraph, - type LinkCompanyToEntityOptions, - type LinkCompanyToEntityResult, + type LinkCompanyToOdooOptions, + type LinkCompanyToOdooResult, type PersonIngest, type PersonIngestResult, type RunAgentInvocationView, @@ -825,11 +825,11 @@ export class InMemoryKnowledgeGraph implements KnowledgeGraph { return result; } - async linkCompanyToEntity( - opts: LinkCompanyToEntityOptions, - ): Promise<LinkCompanyToEntityResult> { + async linkCompanyToOdoo( + opts: LinkCompanyToOdooOptions, + ): Promise<LinkCompanyToOdooResult> { const from = companyNodeId(opts.companyExternalId); - const to = opts.entityExternalId; + const to = opts.odooEntityExternalId; if (!this.nodes.has(from) || !this.nodes.has(to)) { return { linked: false }; } diff --git a/middleware/packages/harness-knowledge-graph-neon/manifest.yaml b/middleware/packages/harness-knowledge-graph-neon/manifest.yaml index bc2c2db35..f6e3184ed 100644 --- a/middleware/packages/harness-knowledge-graph-neon/manifest.yaml +++ b/middleware/packages/harness-knowledge-graph-neon/manifest.yaml @@ -9,8 +9,8 @@ identity: description: "Neon-Postgres + pgvector KnowledgeGraph provider plugin. Durable storage, vector search, embedding-backfill scheduler, schema-Migrationen. Provider der Capabilities `knowledgeGraph@1`, `entityRefBus@1` und `graphPool@1` (Pool-Bridge für kernel-side Konsumenten wie VerifierStore und devGraph-Router). Operator wählt diesen Provider via RequiresWizard für Produktion. Mutual exclusion mit @omadia/knowledge-graph-inmemory — nur einer der beiden Provider darf gleichzeitig in installed.json stehen." authors: - name: "byte5 GmbH" - email: "dev@byte5.de" - url: "https://byte5.de" + email: "info@omadia.ai" + url: "https://omadia.ai" license: "Proprietary" categories: - "infrastructure" diff --git a/middleware/packages/harness-knowledge-graph-neon/src/neonKnowledgeGraph.ts b/middleware/packages/harness-knowledge-graph-neon/src/neonKnowledgeGraph.ts index 113a99e13..f9350092b 100644 --- a/middleware/packages/harness-knowledge-graph-neon/src/neonKnowledgeGraph.ts +++ b/middleware/packages/harness-knowledge-graph-neon/src/neonKnowledgeGraph.ts @@ -30,8 +30,8 @@ import { type GraphNodeType, type GraphStats, type KnowledgeGraph, - type LinkCompanyToEntityOptions, - type LinkCompanyToEntityResult, + type LinkCompanyToOdooOptions, + type LinkCompanyToOdooResult, type PersonIngest, type PersonIngestResult, type RunAgentInvocationView, @@ -647,9 +647,9 @@ export class NeonKnowledgeGraph implements KnowledgeGraph { return { snapshotIds, inserted, updated, skipped }; } - async linkCompanyToEntity( - opts: LinkCompanyToEntityOptions, - ): Promise<LinkCompanyToEntityResult> { + async linkCompanyToOdoo( + opts: LinkCompanyToOdooOptions, + ): Promise<LinkCompanyToOdooResult> { const client = await this.pool.connect(); try { const fromUuid = await this.findUuidByExternalId( @@ -658,7 +658,7 @@ export class NeonKnowledgeGraph implements KnowledgeGraph { ); const toUuid = await this.findUuidByExternalId( client, - opts.entityExternalId, + opts.odooEntityExternalId, ); if (!fromUuid || !toUuid) return { linked: false }; await this.upsertEdge(client, { diff --git a/middleware/packages/harness-memory/manifest.yaml b/middleware/packages/harness-memory/manifest.yaml index c6f6ec820..293af42df 100644 --- a/middleware/packages/harness-memory/manifest.yaml +++ b/middleware/packages/harness-memory/manifest.yaml @@ -9,8 +9,8 @@ identity: description: "Stellt persistenten /memories-Dateispeicher, den Anthropic-nativen memory-Tool-Handler und einen Dev-Memory-Browser bereit. Provider des Kernel-Service 'memoryStore'." authors: - name: "byte5 GmbH" - email: "dev@byte5.de" - url: "https://byte5.de" + email: "info@omadia.ai" + url: "https://omadia.ai" license: "Proprietary" categories: - "infrastructure" diff --git a/middleware/packages/harness-orchestrator-extras/manifest.yaml b/middleware/packages/harness-orchestrator-extras/manifest.yaml index d8cbc2ac9..173fa8a92 100644 --- a/middleware/packages/harness-orchestrator-extras/manifest.yaml +++ b/middleware/packages/harness-orchestrator-extras/manifest.yaml @@ -9,8 +9,8 @@ identity: description: "Tool-Set: ContextRetriever (pre-turn KG/Embedding-Lookup), FactExtractor (LLM-getriebener KG-Ingest aus Chat-Turns), TopicDetector (per-Message-Embedding-Similarity-Routing) und GraphBackfill-Function (Offline-Graph-Evidence-Catchup; vom Kernel-Boot direkt aus dem Plugin-Barrel aufgerufen — nicht in activate(), da der 88-Turn-Replay routinemäßig das 10s-Activate-Budget sprengt). Konsumiert die Kernel-Capabilities `knowledgeGraph` und `embeddingClient` via `ctx.services.get` und published seinerseits drei Service-Capabilities (`contextRetriever`, `factExtractor`, `topicDetector`) für Orchestrator + Channels." authors: - name: "byte5 GmbH" - email: "dev@byte5.de" - url: "https://byte5.de" + email: "info@omadia.ai" + url: "https://omadia.ai" license: "Proprietary" categories: - "infrastructure" diff --git a/middleware/packages/harness-orchestrator-extras/src/captureFilteringKnowledgeGraph.ts b/middleware/packages/harness-orchestrator-extras/src/captureFilteringKnowledgeGraph.ts index c694a740e..867b7baae 100644 --- a/middleware/packages/harness-orchestrator-extras/src/captureFilteringKnowledgeGraph.ts +++ b/middleware/packages/harness-orchestrator-extras/src/captureFilteringKnowledgeGraph.ts @@ -52,8 +52,8 @@ import type { PersonIngestResult, CompanyRelationsIngest, CompanyRelationsResult, - LinkCompanyToEntityOptions, - LinkCompanyToEntityResult, + LinkCompanyToOdooOptions, + LinkCompanyToOdooResult, FinancialSnapshotIngest, FinancialSnapshotIngestResult, } from '@omadia/plugin-api'; @@ -193,10 +193,10 @@ export class CaptureFilteringKnowledgeGraph implements KnowledgeGraph { return this.inner.ingestCompanyRelations(relations); } - linkCompanyToEntity( - opts: LinkCompanyToEntityOptions, - ): Promise<LinkCompanyToEntityResult> { - return this.inner.linkCompanyToEntity(opts); + linkCompanyToOdoo( + opts: LinkCompanyToOdooOptions, + ): Promise<LinkCompanyToOdooResult> { + return this.inner.linkCompanyToOdoo(opts); } ingestFinancialSnapshots( diff --git a/middleware/packages/harness-orchestrator/manifest.yaml b/middleware/packages/harness-orchestrator/manifest.yaml index 13fded4bd..9679beb20 100644 --- a/middleware/packages/harness-orchestrator/manifest.yaml +++ b/middleware/packages/harness-orchestrator/manifest.yaml @@ -9,8 +9,8 @@ identity: description: "Core chat orchestrator: the Orchestrator class itself (agentic tool-loop against Anthropic Claude), the seven native tools (memory, query_knowledge_graph, chat_participants, ask_user_choice, suggest_follow_ups, find_free_slots, book_meeting), the chat-API HTTP route and the VerifierService wrapper that binds verifier@1 to the live Orchestrator + toSemanticAnswer converter. Erstmaliger Konsument der TurnHookRegistry: ContextRetriever wandert zu onBeforeTurn, FactExtractor zu onAfterTurn (fire-and-forget). Konsumiert die plugin-owned Capabilities `knowledgeGraph` (hard requires; query_knowledge_graph + KG-Lookups), `memoryStore` (memory tool), `embeddingClient` (semantic search), `entityRefBus` (turn-scoped entity tracking), `contextRetriever` + `factExtractor` + `topicDetector` (orchestrator-extras hooks), `verifier` (Bundle: pipeline + store + mode + maxRetries) sowie optional `microsoft365.graph` (find_free_slots + book_meeting via delegated OBO-exchange). Published `chatAgent@1` als Bundle-Capability `{ agent: ChatAgent, raw: Orchestrator }` — Channel-Plugins (Teams, Telegram, HTTP) konsumieren `agent.chat()` / `agent.chatStream()` für ihre Connector-Loops." authors: - name: "byte5 GmbH" - email: "dev@byte5.de" - url: "https://byte5.de" + email: "info@omadia.ai" + url: "https://omadia.ai" license: "Proprietary" categories: - "infrastructure" diff --git a/middleware/packages/harness-orchestrator/src/knowledgeGraphTool.ts b/middleware/packages/harness-orchestrator/src/knowledgeGraphTool.ts index a46d920d6..bcb9542a8 100644 --- a/middleware/packages/harness-orchestrator/src/knowledgeGraphTool.ts +++ b/middleware/packages/harness-orchestrator/src/knowledgeGraphTool.ts @@ -29,7 +29,7 @@ export const KNOWLEDGE_GRAPH_TOOL_NAME = 'query_knowledge_graph'; export const knowledgeGraphToolSpec = { name: KNOWLEDGE_GRAPH_TOOL_NAME, description: - 'Read-only lookup against the middleware\'s local knowledge graph of past sessions, turns, and the domain entities they touched. Use BEFORE delegating to a sub-agent when the user references prior work. Queries:\n- `stats`: node/edge counts.\n- `list_sessions`: recent sessions with counts.\n- `find_entity`: entities by `name_contains` and/or `model`, plus turns that mentioned them. Use for "who is …" / "do we have customer X" questions.\n- `session_summary`: turns in one scope with captured entities.\n- **`search_turns`**: full-text search across ALL past turn bodies (userMessage + assistantAnswer). Use for topical questions — pass `text` with the keyword(s).\n- **`search_turns_semantic`**: embedding-based (cosine) search. Use for paraphrases / conceptual questions where exact keywords may not appear. Pass `text`. More expensive than `search_turns`; prefer it when FTS returns nothing.', + 'Read-only lookup against the middleware\'s local knowledge graph of past sessions, turns, and the Odoo/Confluence entities they touched. Use BEFORE delegating to a sub-agent when the user references prior work ("wie bei Müller letztens", "die Diskussion über Projekt X", "das gleiche wie gestern"). Queries:\n- `stats`: node/edge counts.\n- `list_sessions`: recent sessions with counts.\n- `find_entity`: entities by `name_contains` and/or `model`, plus turns that mentioned them. Use for "wer ist …" / "haben wir Kunde X" questions.\n- `session_summary`: turns in one scope with captured entities.\n- **`search_turns`**: full-text search across ALL past turn bodies (userMessage + assistantAnswer). Use for topical questions like "haben wir schon mal über Mahnwesen gesprochen?" — pass `text` with the keyword(s).\n- **`search_turns_semantic`**: embedding-based (cosine) search. Use for paraphrases / conceptual questions where exact keywords may not appear ("Rechnungsprobleme" ≈ "offene Posten", "Darlehen" ≈ "Kredit"). Pass `text`. More expensive than `search_turns`; prefer it when FTS returns nothing.', input_schema: { type: 'object' as const, properties: { diff --git a/middleware/packages/harness-orchestrator/src/localSubAgent.ts b/middleware/packages/harness-orchestrator/src/localSubAgent.ts index 5134a9763..8bc04cbf5 100644 --- a/middleware/packages/harness-orchestrator/src/localSubAgent.ts +++ b/middleware/packages/harness-orchestrator/src/localSubAgent.ts @@ -16,7 +16,7 @@ import { buildDateHeader, turnContext } from './turnContext.js'; export type { LocalSubAgentTool, LocalSubAgentToolSpec }; interface LocalSubAgentOptions { - /** Label used in logs — typically the domain, e.g. `accounting`. */ + /** Label used in logs — typically the domain, e.g. `odoo-hr`. */ name: string; client: Anthropic; model: string; @@ -58,10 +58,14 @@ type ContentBlock = any; type Message = any; /** - * A tool-loop agent that runs entirely inside this middleware process. The - * skill markdown becomes the system prompt; tools call straight into the - * domain's published service handles; the whole thing is observable in - * the local logs and on the EntityRef bus. + * A tool-loop agent that runs entirely inside this middleware process. Replaces + * the former Anthropic-hosted Managed Agent per domain — skill stays as the + * system prompt, tools call straight into our Odoo/Confluence code paths, and + * the whole thing is observable in the local logs + EntityRef bus. + * + * Matches the old `OdooAgentClient.ask(question)` signature so the orchestrator + * and `domainQueryTool` don't need to know whether they're talking to a + * Managed Agent or a local sub-agent. */ export class LocalSubAgent { private readonly name: string; @@ -157,7 +161,7 @@ export class LocalSubAgent { // On the final allowed iteration, forbid further tool use. The model // is forced to emit a text answer from whatever it has already - // gathered — no more tool probes. Without this, long + // gathered — no more Odoo/Confluence probes. Without this, long // multi-step queries hit the iteration cap and we threw away every // partial insight the agent had accumulated. See the `tool_choice` // docs on https://docs.anthropic.com/en/docs/agents-and-tools. @@ -434,8 +438,8 @@ export class LocalSubAgent { // Slice 2.2 — privacy-proxy tool roundtrip for sub-agent inner calls. // // Same contract as orchestrator.dispatchTool: restore tokens in the - // input BEFORE the tool handler runs (so domain tools - // see the actual user data rather than `tok_<hex>_name`), and + // input BEFORE the tool handler runs (so query_graph / odoo_execute + // see the actual employee name rather than `tok_<hex>_name`), and // re-tokenise PII in the textual result AFTER the handler returns // (so the next sub-agent LLM call doesn't see fresh plaintext PII // it would otherwise have to be defensively cautious about). @@ -461,6 +465,21 @@ export class LocalSubAgent { } } const result = await tool.handle(dispatchInput); + // Phase C.2 — Raw tool-result capture (parallel to orchestrator.dispatchTool). + // Sub-agent tool calls also feed routine templates, so the capture + // hook must fire here too. Same last-write-wins semantics; absent + // callback ⇒ no capture. + const capture = turnContext.current()?.captureRawToolResult; + if (capture !== undefined && typeof result === 'string') { + try { + capture(toolName, result); + } catch (err) { + console.warn( + `[sub-agent ${this.name}] captureRawToolResult threw on '${toolName}' — continuing without capture:`, + err, + ); + } + } if (privacy !== undefined && typeof result === 'string' && result.length > 0) { try { const tokenised = await privacy.processToolResult({ diff --git a/middleware/packages/harness-orchestrator/src/orchestrator.ts b/middleware/packages/harness-orchestrator/src/orchestrator.ts index 1fe1eb3ca..388fb1d4f 100644 --- a/middleware/packages/harness-orchestrator/src/orchestrator.ts +++ b/middleware/packages/harness-orchestrator/src/orchestrator.ts @@ -1,6 +1,9 @@ import { randomUUID } from 'node:crypto'; import type Anthropic from '@anthropic-ai/sdk'; import { + applyEgressReplacements, + buildBlockedResult, + collectEgressSlots, toSemanticAnswer, type ChatStreamEvent, type ChatTurnInput, @@ -57,6 +60,7 @@ import type { NudgeRegistry, NudgeStateStore, PrivacyGuardService, + PrivacyOutputValidationResult, ProcessMemoryService, ResponseGuardService, SessionBriefingService, @@ -75,6 +79,14 @@ import { RunTraceCollector, type InvocationHandle } from './runTraceCollector.js import type { NativeToolRegistry } from './nativeToolRegistry.js'; import type { SessionLogger } from './sessionLogger.js'; import { streamMessageEvents } from './streaming.js'; +import { + appendOrphanPlaceholderFooter, + detectOrphanPlaceholders, +} from './orphanPlaceholderCheck.js'; +import { + analyzeTokenSaturation, + bypassCannedAnswer, +} from './tokenSaturationBypass.js'; import { buildDateHeader, today, turnContext } from './turnContext.js'; // S+10-2 back-compat re-exports: kernel-side callers that still @@ -323,10 +335,10 @@ function buildSystemBlocks( // Trust tiers (important — get this wrong and the bot re-fetches data // it just delivered, or hallucinates facts from unrelated chats): // - "Letzte Turns in diesem Chat" = your own recent replies. Trust. - // Follow-ups like "das gleiche als Line-Chart" / "ohne X" refer - // *directly* to these turns. Don't re-query the source for the - // base numbers, don't speculate about different time ranges — - // build on what's already here. + // Follow-ups like "das gleiche als Line-Chart" / "ohne Gutschriften" + // refer *directly* to these turns. Don't re-query Odoo for the base + // numbers, don't speculate about different time ranges — build on + // what's already here. // - "Früher besprochene Entitäten" + "Inhaltlich ähnliche Turns" come // from OTHER chats of the same user. Treat as working hypothesis; // if the current question hinges on a concrete number from there, @@ -383,7 +395,7 @@ Für diesen EINEN Turn: Ignoriere die Memory-Lese-Konvention aus dem stabilen Sy Stattdessen: - Beantworte die aktuelle User-Frage ausschließlich mit dem, was in ihrer Nachricht steht (inkl. eventuellem \`[attachments-info]\`-Block) + frischen Fach-Agent-Calls. -- Wenn du Daten brauchst, die du sonst aus \`/memories/\` zögen würdest, MACH jetzt direkt den passenden Tool-Call (z. B. einen domain-spezifischen Sub-Agenten). +- Wenn du Daten brauchst, die du sonst aus \`/memories/\` zögen würdest, MACH jetzt direkt den passenden Tool-Call (z.B. \`query_odoo_accounting\`, \`query_odoo_hr\`). - Keine Referenz auf frühere Gespräche. Keine "wie eben erwähnt". Behandle den Turn als isoliert. Der Grund für diesen Modus: der User vermutet, dass dich ein früherer Memory-Eintrag oder ein FTS-Treffer auf eine falsche Antwort gelockt hat. Jetzt ist die Chance, unabhängig von diesem Altlast-Pfad zu antworten.`, @@ -413,23 +425,23 @@ function buildSystemPrompt( ? '\n- `ask_user_choice`: Stellt dem User eine Rückfrage mit 2–4 vordefinierten Button-Optionen als Smart Card. Nur aufrufen, wenn die User-Eingabe **genuin mehrdeutig** ist UND es eine **endliche, kleine Menge plausibler Interpretationen** gibt (z.B. zwei Module tracken Umsatz, zwei Kunden haben ähnlichen Namen). **NICHT** nutzen für: offene "was meinst du?"-Fragen, Trivial-Bestätigungen, oder wenn der Kontext die Intention bereits eindeutig macht. Max 1× pro Turn — der Turn endet direkt nach dem Call; die Auswahl kommt im nächsten Turn als normale User-Nachricht.\n' : ''; const calendarBlock = hasCalendar - ? '\n- `find_free_slots` + `book_meeting`: **Calendar integration.** When the user asks for an appointment / meeting / time-with-<person> in any phrasing ("send X three options", "when does Y have time?", "book meeting with Z", "find slot tomorrow") — **call `find_free_slots`**. Do NOT interpret as email, do NOT just look up the contact and write prose. The tool output ships clickable slot buttons; the user picks one, then `book_meeting` follows automatically.\n **Host logic:**\n - Slots come from the **host\'s** (meeting organiser\'s) calendar. Default = caller themselves.\n - When the caller offers their own time ("send Tita 3 options", "offer Max times") → **do NOT set hostEmail** (caller is host).\n - When the caller searches on behalf of someone else ("find a slot in John\'s calendar") → set `hostEmail` to the target.\n **Required steps for every appointment intent:**\n 1. Resolve attendee emails (e.g. via a directory sub-agent if available).\n 2. Call `find_free_slots({durationMinutes, attendees, hostEmail?, windowDays?})` — default 5 days, default 30 min if the user doesn\'t specify.\n 3. Summarise the found slots in **one sentence** ("Here are 3 free slots for …"). The buttons render automatically as a card.\n 4. On `consent_required` / `sso_unavailable` errors: briefly explain that one-time consent is needed — the OAuth card is attached automatically.\n **Do NOT use** for queries about already-booked appointments (not implemented).\n' + ? '\n- `find_free_slots` + `book_meeting`: **M365-Kalender-Integration.** Wenn der User Termin/Meeting/Sprechstunde/Slot/Zeit-mit-<Person> anfragt — egal wie die Formulierung lautet ("schicke X drei Vorschläge", "wann hat Y Zeit?", "buche Termin mit Z", "finde Slot morgen") — **RUFE `find_free_slots`**. NICHT als Email interpretieren, NICHT nur HR-Kontakt nachschlagen und Prose zurückschreiben. Der Tool-Output liefert klickbare Slot-Buttons; der User wählt, dann folgt automatisch `book_meeting`.\n **Host-Logik (wichtig):**\n - Die Slots kommen aus dem Kalender des **Hosts** (Meeting-Organizers). Default = Caller selbst.\n - Wenn der Caller eigene Zeit anbietet ("schicke Tita 3 Vorschläge", "biete Max Termine", "finde Slot morgen") → **hostEmail NICHT setzen** (Caller ist Host).\n - Wenn der Caller im Auftrag einer anderen Person sucht ("such bei John Termin", "wann hat die GF Zeit?") → `hostEmail` auf die Ziel-Email setzen.\n **Pflicht-Schritte bei jedem Termin-Intent:**\n 1. Teilnehmer-Emails resolven (ggf. via `query_odoo_hr` nach Vorname/Nachname → email).\n 2. `find_free_slots({durationMinutes, attendees, hostEmail?, windowDays?})` aufrufen — Default 5 Tage, Default 30 min wenn User keine Dauer nennt.\n 3. Die gefundenen Slots im Antwort-Text in **1 Satz** zusammenfassen ("Hier 3 freie Slots für …"). Die Buttons erscheinen automatisch als Card darunter.\n 4. Bei `consent_required` / `sso_unavailable` Fehler: kurz erklären dass einmalig Zustimmung nötig ist — die OAuthCard wird automatisch vom System angehängt.\n **NICHT nutzen:** wenn der User nach bereits gebuchten Terminen fragt (nicht implementiert).\n' : ''; const suggestFollowUpsBlock = hasSuggestFollowUps ? '\n- `suggest_follow_ups`: Hängt 2–4 1-Klick-Refinement-Buttons unter deine Antwort. **Nicht-blockierend** — Du antwortest ganz normal zu Ende; die Buttons erscheinen zusätzlich. Nutze das bei **Top-N / Ranking / Trend / Aggregat**-Fragen, wo der User plausibel eine Variante will (anderer Zeitraum, andere Basis Brutto/Netto/DB, offene Posten statt Umsatz). Jedes `prompt` muss eine **vollständige, eigenständige Frage** sein — bei Klick wird es als neue User-Nachricht gesendet. **NICHT** nutzen für: Trivial-Antworten, Ja/Nein-Lookups, oder zusammen mit `ask_user_choice`. Max 1× pro Turn.\n' : ''; const chatParticipantsBlock = hasChatParticipants - ? '\n- `get_chat_participants`: Returns the participants of the current chat. Call this only when you want to address someone in the answer text **via @-mention** — handoff, follow-up question, ownership tag. Max 1× per turn. Do not use in 1:1 chats.\n' + - '\n **REQUIRED after the tool call — otherwise the call was wasted:**\n' + - ' 1. Write the name in the answer text as `<at>EXACT_DISPLAY_NAME</at>`.\n' + - ' 2. `EXACT_DISPLAY_NAME` must match the `displayName` field from the tool response byte-for-byte — including any suffix, hyphens, capitalisation.\n' + - ' 3. Without these `<at>…</at>` tags NO mention is rendered and the person is NOT notified — writing the name alone is NOT enough.\n' + - ' 4. Example: if the roster returns `displayName: "Alex Example"` and you want to address them, write `Hey <at>Alex Example</at>, can you take this?` — not `Hey Alex Example` and not `Hey @Alex`.\n' + ? '\n- `get_chat_participants`: Liefert die Teilnehmer des aktuellen Teams-Chats. Nur aufrufen, wenn du jemanden im Antworttext **per @-Mention ansprechen** willst — Handoff, Rückfrage, Zuständigkeits-Tag. Max 1× pro Turn. In 1:1-Chats nicht nutzen.\n' + + '\n **PFLICHT nach dem Tool-Call — sonst war der Call umsonst:**\n' + + ' 1. Den Namen im Antworttext in der Form `<at>EXAKTER_DISPLAY_NAME</at>` schreiben.\n' + + ' 2. `EXAKTER_DISPLAY_NAME` muss byte-für-byte dem `displayName`-Feld aus der Tool-Response entsprechen — inklusive Firmensuffix, Bindestriche, Großschreibung.\n' + + ' 3. Ohne diese `<at>…</at>`-Tags wird KEINE Mention gerendert und die Person NICHT benachrichtigt — das Schreiben des Namens allein reicht NICHT.\n' + + ' 4. Beispiel: wenn der Roster `displayName: "Jane Doe - ACME"` zurückgibt und du sie ansprechen willst, schreibst du `Hey <at>Jane Doe - ACME</at>, kannst du das übernehmen?` — nicht `Hey Jane Doe` und auch nicht `Hey @Jane`.\n' : ''; const graphBlock = hasGraph - ? `\n- \`query_knowledge_graph\`: Local knowledge graph over past sessions/turns + domain entities (whatever the active integration plugins have ingested). **For questions about the chat history** ("did we already discuss X?", "was there a debate about Y?", "which topics did we cover recently?") **use \`search_turns\` (FTS, keyword) or \`search_turns_semantic\` (embedding, for paraphrases)**. \`find_entity\` matches ONLY entity names/IDs, NOT turn text — use it for "who is customer Z?". For back-references to specific people/things ("like with X recently") try \`find_entity\` or \`session_summary\` first. **Important:** if you answer a content question about earlier chats with \`find_entity\` and get an empty result, also try \`search_turns\` — that searches the actual turn text.\n` + ? `\n- \`query_knowledge_graph\`: Lokaler Wissens-Graph über vergangene Sessions/Turns + Odoo-/Confluence-Entitäten. **Bei Fragen nach dem Chat-Verlauf** ("haben wir schon mal über X gesprochen?", "gab es eine Diskussion zu Y?", "welche Themen hatten wir zuletzt?") **nutze \`search_turns\` (FTS, Keyword) oder \`search_turns_semantic\` (Embedding, für Paraphrasen)**. \`find_entity\` matcht NUR Entity-Namen/IDs (res.partner, hr.employee, …), NICHT Turn-Text — verwende es für "wer ist Kunde Z?". Bei Rückbezügen auf spezifische Personen/Dinge ("wie bei Müller letztens") zuerst \`find_entity\` oder \`session_summary\`. **Wichtig:** Wenn du eine inhaltliche Frage zu früheren Chats mit \`find_entity\` beantwortest und leer rauskommst, probiere unbedingt zusätzlich \`search_turns\` — dort durchsuchst du tatsächlich die Turn-Texte.\n` : ''; // Diagrams moved out of the kernel in Phase 1.2b-iii. The diagram plugin @@ -438,65 +450,65 @@ function buildSystemPrompt( // caller signature stays stable during the transition. void hasDiagramTool; - return `You are the Omadia orchestrator. You answer the user's questions by delegating to specialised sub-agents and persisting durable learnings to memory. + return `Du bist der byte5 Assistent. Du beantwortest Fragen zu unserer Odoo-17-Produktion, indem du an die spezialisierten Sub-Agenten delegierst und Lernpunkte persistent merkst. -Language: match the user's language. The default is the language of the most recent user message. +Sprache: Antworte immer auf Deutsch, außer der Nutzer wechselt explizit die Sprache. -Tools: -- \`memory\` (virtual /memories directory): persist domain learnings, user preferences, business conventions, and recurring patterns. Memory is shared across sessions and global for this agent. At the start of each new task read the directory listing once before answering, so you can draw on relevant learnings. Place new learnings in topical files (e.g. /memories/customers/<name>.md, /memories/observations/<period>.md). +Werkzeuge: +- \`memory\` (virtuelles /memories-Verzeichnis): Persistiere Domänen-Learnings, Nutzer-Präferenzen, Geschäfts-Konventionen und häufige Anfragen. Der Memory wird über Sessions hinweg geteilt und ist global für diesen Agent. Lies zu Beginn jeder neuen Aufgabe einmal den Verzeichnisinhalt, bevor du antwortest, damit du auf relevante Learnings zurückgreifen kannst. Lege neue Learnings in themenbezogenen Dateien ab (z.B. /memories/customers/kundenname.md, /memories/observations/2026-q2.md). ${graphBlock}${chatParticipantsBlock}${askUserChoiceBlock}${suggestFollowUpsBlock}${calendarBlock}${extraToolDocs.length > 0 ? '\n' + extraToolDocs.map((doc) => `- ${doc.trim()}`).join('\n') + '\n' : ''} -Sub-agents (routing rule: pick by question domain; for mixed questions call several and merge results): +Fach-Agenten (Routing-Regel: wähle anhand der Fragedomäne; bei Mischfragen beide/mehrere aufrufen und Ergebnisse zusammenführen): ${domainList} -Memory namespaces (convention): -- /memories/_rules/… → **curated rules from the repo**. Don't overwrite or delete on your own. Only extend if the user explicitly confirms. -- /memories/customers/… → stable facts about individual customers. -- /memories/observations/… → time-stamped observations for back-comparisons. -- /memories/sessions/<scope>/YYYY-MM-DD.md → **chronological Q&A transcripts**, written by the middleware (not by you). These contain real prior conversations. When the user references an earlier conversation ("like we discussed last time", "the way we did it before"), **first look up the matching entry in /memories/sessions/** before re-querying a sub-agent — that typically saves a full roundtrip. But: don't read all sessions by default, that's token waste. Look up only when there's an actual back-reference. +Memory-Namensräume (Konvention): +- /memories/_rules/… → **gepflegte Regeln aus dem Repo**. Nicht eigenständig überschreiben oder löschen. Nur ergänzen, wenn der Nutzer es ausdrücklich bestätigt. +- /memories/customers/… → stabile Fakten zu einzelnen Kunden. +- /memories/observations/… → Zeitstempelbezogene Beobachtungen für Rück-Vergleiche. +- /memories/sessions/<scope>/YYYY-MM-DD.md → **chronologische Q&A-Transkripte**, von der Middleware geschrieben (nicht von dir). Diese enthalten echte vorangegangene Konversationen. Wenn der Nutzer auf ein früheres Gespräch verweist ("wie wir das letztens diskutiert haben", "so wie bei den Kostenstellen", "mach das wie beim letzten Mal"), **zuerst den passenden Eintrag in /memories/sessions/ suchen**, bevor du einen Fach-Agenten neu befragst — du sparst dir damit typischerweise einen ganzen Roundtrip. Aber: lies nicht standardmäßig alle Sessions, das wäre Token-Verschwendung. Nur auf Rückbezug gezielt nachschlagen. -**Rule for reading /memories/_rules/:** -- For a **new domain question** (first question on a domain in this session, or domain switch) read the relevant rule files under /memories/_rules/ first and follow the conventions strictly. -- For a **follow-up** in the same chat (variant, refinement, clarification, "and the same without X", "and for Q4?", "show as a line chart") **do NOT re-read** the rules — the verbatim tail in the conversation context already has the relevant state. Answer directly (with \`render_diagram\` for chart variants). Re-read rules only when the follow-up introduces a substantively new dimension. -- Heuristic: if the context block contains a \`## Letzte Turns in diesem Chat\` section and the current question relates to one of those turns → skip the memory read. +**Regel für /memories/_rules/ lesen:** +- Bei einer **neuen fachlichen Frage** (Erstfrage zu einer Domäne in dieser Session, oder Wechsel der Domäne) zuerst die relevanten Regel-Dateien unter /memories/_rules/ lesen und die Konventionen strikt befolgen. +- Bei einem **Follow-up** im selben Chat (Variante, Bereinigung, Klarifikation, Nachfrage zum letzten Turn wie "und das Ganze nochmal ohne X", "und für Q4?", "zeig das als Line-Chart") **NICHT erneut** die Regeln lesen — der Verbatim-Tail im Gesprächskontext hat bereits den relevanten Stand. Direkt antworten (ggf. mit \`render_diagram\` für Chart-Varianten). Regel erneut lesen nur, wenn die Follow-up eine fachlich neue Dimension einführt (z. B. "jetzt das Gleiche auf HR-Ebene"). +- Heuristik: enthält der Kontext-Block einen \`## Letzte Turns in diesem Chat\`-Abschnitt und bezieht sich die aktuelle Frage auf einen dieser Turns → Memory-Read überspringen. -**Silence permission (NO_REPLY):** +**Antwort-Verzicht (NO_REPLY):** -When you have nothing to contribute, answer with the **sole, exact** token \`NO_REPLY\` (no explanation, no prefix, no suffix). The system intercepts the token and sends **no message** to the user. Use cases: -- The user explicitly asked you not to reply ("don't reply", "stay silent", "no answer needed", "just be quiet"). -- A **routine** (scheduled trigger without an active user question) has **no reportable result** — e.g. "no birthdays today", "no open tickets", "all green". For routines, **silence is the default**: speak only when there's actually something to report. Do NOT write "Nothing to report today" or "Per instruction, sending no message" — both still get delivered. Write only \`NO_REPLY\`. -- Pure FYI messages in chat without a question or call-to-action that expects a response. +Wenn du nichts beizutragen hast, antworte mit dem **alleinigen, exakten** Token \`NO_REPLY\` (keine Erklärung, kein Präfix, kein Suffix). Das System fängt das Token ab und sendet **keine Nachricht** an den User. Anwendungsfälle: +- Der User hat explizit gebeten, nicht zu antworten ("antworte nicht", "still bleiben", "keine Antwort nötig", "halt einfach den Mund"). +- Eine **Routine** (zeitgesteuerter Trigger ohne aktive User-Frage) hat **kein berichtenswertes Ergebnis** — z.B. "heute hat niemand Geburtstag", "keine offenen Tickets", "alles im grünen Bereich". Bei Routinen ist **Schweigen der Default**: sprich nur, wenn es wirklich etwas Berichtenswertes gibt. Schreibe NICHT "Heute nichts zu berichten" oder "Gemäß Anweisung sende ich keine Nachricht" — beides wird trotzdem als Nachricht zugestellt. Schreibe nur \`NO_REPLY\`. +- Reine FYI-Nachricht im Chat ohne Frage oder Aufforderung, auf die keine Reaktion erwartet wird. -**Required form**: \`NO_REPLY\` must be the **entire** answer — nothing before, nothing after, no quotes, no explanation. "NO_REPLY because…" or "— NO_REPLY" does NOT qualify and causes the whole answer (including the explanation) to be delivered to the user. +**Pflicht-Form**: \`NO_REPLY\` muss die **vollständige** Antwort sein — nichts davor, nichts danach, keine Anführungszeichen, keine Begründung. "NO_REPLY weil…" oder "— NO_REPLY" reicht NICHT und führt dazu, dass die ganze Antwort inkl. Begründung an den User rausgeht. -Rules: -1. Don't invent data. When you need a number, a date, a customer name, or an employee, fetch it through the responsible sub-agent. -2. Only write to memory when the learning is relevant beyond the current session — no session-specific notes. -3. **Persist learnings early, not at the end.** As soon as you've gained a durable insight from a sub-agent answer or user instruction (mapping, convention, stable fact), write it to memory **on the very next tool call** — before further delegations or the final answer. This way the learning survives a connection drop or container restart mid-turn. -4. Cite sources briefly in memory (e.g. "observed 2026-04-17 in record X-2026-0042"). -5. Avoid memory spam: before creating a new file, check whether a fitting one exists and extend it via \`str_replace\` / \`insert\`. -6. Personal data (contact names etc.) only when needed for the actual work. Domain-specific privacy rules (e.g. HR red lines) are enforced server-side by the responsible sub-agent — respect them in your summary too. -7. At the end of an answer: do NOT write a status update to memory if nothing new emerged. +Regeln: +1. Erfinde keine Daten. Wenn du eine Zahl, ein Datum, einen Kundennamen oder einen Mitarbeiter brauchst, hole sie über den zuständigen Fach-Agenten. +2. Schreib nur dann in den Memory, wenn der Lernwert über die aktuelle Session hinaus relevant ist — keine Session-spezifischen Notizen. +3. **Persistiere Learnings früh, nicht erst am Ende.** Sobald du aus einer Fach-Agent-Antwort oder Nutzer-Anweisung eine dauerhaft gültige Erkenntnis gewonnen hast (Mapping, Konvention, stabiler Fakt), schreibe sie **direkt im nächsten Tool-Call** in den Memory — noch bevor du weitere Delegationen machst oder die finale Antwort formulierst. So überleben Learnings auch einen Verbindungsabbruch oder Container-Restart mitten im Turn. +4. Zitiere im Memory Quellen knapp (z.B. "beobachtet am 2026-04-17 bei Rechnung RE-2026-0042"). +5. Vermeide Memory-Spam: Bevor du eine neue Datei anlegst, prüfe ob es schon eine passende Datei gibt, und erweitere diese per \`str_replace\`/\`insert\`. +6. Persönliche Daten (Ansprechpartner-Namen etc.) nur speichern, wenn sie für die fachliche Arbeit notwendig sind. Der HR-Agent hat zusätzlich eigene PII-Guardrails — respektiere diese auch in deiner Zusammenfassung der Antwort. +7. Am Ende jeder Antwort: Schreibe KEIN Zwischenstand-Update in den Memory, wenn sich nichts Neues ergeben hat. -**Critical integrity rules (verifier-hardening):** +**Kritische Integritäts-Regeln (Verifier-Härtung):** -8. **No self-verification in the answer text.** Never write words like "verified", "checked", "confirmed", "live", "looked up" to mark data as fresh. The verifier badge after turn-end decides that based on the tool trace. If you use those words without an actual sub-agent call, the verifier will hard-contradict. +8. **Keine Selbst-Verifizierung im Antworttext.** Schreibe NIEMALS Wörter wie "verifiziert", "geprüft", "bestätigt", "live", "live-verifiziert", "nachgeschlagen", "aus Odoo geholt" in deine Antwort, um Daten als frisch zu kennzeichnen. Das entscheidet ausschließlich das Verifier-Badge nach Turn-Ende — und es prüft anhand deines Tool-Traces, ob du wirklich einen Fach-Agenten gefragt hast. Wenn du diese Wörter trotzdem nutzt und in Wirklichkeit keinen Fach-Agent-Call gemacht hast, widerspricht der Verifier hart. -9. **Numbers from the context block are NOT live.** Numbers under \`## Früher besprochene Entitäten\`, \`## Inhaltlich ähnliche Turns\`, \`## Letzte Turns in diesem Chat\` are from the past. Don't present them as current. When the user asks for current figures, you must make at least one sub-agent call in the same turn — otherwise the verifier will auto-contradict and force a retry. +9. **Zahlen aus dem Kontext-Block sind NICHT live.** Konkret: Zahlen unter \`## Früher besprochene Entitäten\`, \`## Inhaltlich ähnliche Turns\`, \`## Letzte Turns in diesem Chat\` stammen aus der Vergangenheit. Präsentiere sie NICHT als aktuellen Stand. Wenn der User nach aktuellen Zahlen fragt (Umsatz, offene Rechnungen, Urlaubstage, Teamleistung), musst du im selben Turn mindestens EINEN Fach-Agent-Call (\`query_odoo_accounting\` / \`query_odoo_hr\`) machen — sonst widerspricht der Verifier automatisch und erzwingt einen Retry. -10. **Valid back-reference:** when the user explicitly refers to an earlier turn ("as just reported", "yesterday's number"), you may quote the context number — but phrase it clearly as a back-reference ("as of <date>, no fresh query this turn"), never as "verified/checked". For aggregates spanning multiple dimensions (team × customer × period) always do a plausibility check against known patterns from \`/memories/\`: if a number deviates >50 % from the expected band, mark it explicitly as an anomaly and ask back rather than confirm. +10. **Gültiger Rückbezug:** Wenn der User explizit auf einen früheren Turn verweist ("wie eben berichtet", "die Zahl von gestern"), darfst du die Kontext-Zahl zitieren — aber formuliere dann klar als Rückbezug ("laut Stand vom <Datum>, keine Neu-Abfrage in diesem Turn"), niemals als "verifiziert/geprüft". Für Aggregate über mehrere Dimensionen (Team × Kunde × Zeitraum) immer einen Plausibilitäts-Check gegen bekannte Muster aus \`/memories/\`: wenn die Zahl >50 % vom Erwartungsband abweicht, EXPLIZIT als Auffälligkeit markieren und nachfragen statt bestätigen. -**File attachments (channel uploads):** +**Dateianhänge (Teams-Uploads):** -11. **Recognise the attachment hint.** When a user message ends with an \`[attachments-info] …\` block, the user has uploaded files — they're already persisted (storage_key + signed_url in the block). Treat the metadata as additional context, not as text of the request. +11. **Anhang-Hinweis erkennen.** Wenn am Ende einer User-Nachricht ein Block \`[attachments-info] …\` auftaucht, hat der User Dateien hochgeladen — sie sind bereits persistiert (storage_key + signed_url im Block). Behandle die Metadaten wie Zusatzkontext, nicht wie Text der Anfrage. -12. **Recognise brand-asset intent.** Phrasings like "this is our logo", "use that as a banner", "this is our team icon" → **right now** write/update the memory file \`/memories/_brand/<asset-name>.md\` (e.g. \`logo.md\`, \`banner.md\`) with YAML frontmatter from the attachments-info block (storage_key, signed_url, file_name, content_type, uploaded_at, asset_role). Then briefly confirm. When the user does NOT mark the file as an asset ("look at this", "here's a screenshot"), do NOT write to \`/memories/_brand/\`. +12. **Brand-Asset-Intent erkennen.** Formulierungen wie "das ist unser Logo", "unser Firmenlogo", "nimm das als Banner", "das ist unser Team-Icon" → **jetzt sofort** die Memory-Datei \`/memories/_brand/<asset-name>.md\` (z.B. \`logo.md\`, \`banner.md\`) schreiben/aktualisieren mit YAML-Frontmatter aus dem attachments-info-Block (storage_key, signed_url, file_name, content_type, uploaded_at, asset_role). Danach kurz bestätigen. Wenn der User die Datei **nicht** als Asset markiert ("schau dir das an", "hier ein Screenshot"), **nicht** in \`/memories/_brand/\` schreiben. -13. **Use brand asset in diagrams.** On \`render_diagram\` calls, if the user requests "with branding", "with our logo", "with corporate design", read \`/memories/_brand/logo.md\`. Don't write the signed_url directly into the spec (Kroki has no public egress) — use the placeholder URL \`brand://logo\` AND pass the \`storage_key\` as the tool parameter \`brand_logo_storage_key\`. The middleware base64-inlines the image automatically before it reaches Kroki — works reliably even with expired signed_urls. Examples: - - **Vega-Lite**: layer \`{"mark":"image","encoding":{"url":{"value":"brand://logo"},"x":{...},"y":{...},"width":{"value":80},"height":{"value":80}}}\` +13. **Brand-Asset in Diagrammen einsetzen.** Beim \`render_diagram\`-Aufruf: wenn der User "mit Branding", "mit unserem Logo", "mit Corporate Design" anfragt, lies \`/memories/_brand/logo.md\`. Schreibe im Spec **nicht** die signed_url direkt (Kroki hat keinen Public-Egress), sondern den Platzhalter-URL \`brand://logo\` UND übergib den \`storage_key\` als Tool-Parameter \`brand_logo_storage_key\`. Die Middleware base64-inlined das Bild automatisch bevor es zu Kroki geht — funktioniert zuverlässig, auch bei ausgelaufenen signed_urls. Beispiele: + - **Vega-Lite**: Layer \`{"mark":"image","encoding":{"url":{"value":"brand://logo"},"x":{...},"y":{...},"width":{"value":80},"height":{"value":80}}}\` - **Graphviz**: \`node [image="brand://logo", label=""]\` - - **PlantUML**: \`<img src="brand://logo" width="120">\` in note/header - - **Mermaid**: limited; render without logo if in doubt. - Tool call shape: \`render_diagram({kind: "vegalite", source: "<spec with brand://logo>", brand_logo_storage_key: "<from memory>"})\`. Without the parameter the \`brand://logo\` placeholder stays unchanged — Kroki renders an empty image cell.`; + - **PlantUML**: \`<img src="brand://logo" width="120">\` in Note/Header + - **Mermaid**: eingeschränkt, im Zweifel ohne Logo rendern. + Tool-Call-Shape: \`render_diagram({kind: "vegalite", source: "<spec mit brand://logo>", brand_logo_storage_key: "<aus memory>"})\`. Ohne den Parameter bleibt \`brand://logo\` ungeändert — Kroki rendert das Bild-Feld dann leer.`; } /** @@ -1147,9 +1159,90 @@ export class Orchestrator { ? { chatParticipants: parent.chatParticipants } : {}), ...(privacyHandle ? { privacyHandle } : {}), + ...(parent?.captureRawToolResult + ? { captureRawToolResult: parent.captureRawToolResult } + : {}), }, async () => { - const result = await this.chatInContext(input, turnId); + let result = await this.chatInContext(input, turnId); + // Privacy-Shield v2 (D-2) — Output Validator + Retry-Loop. + // The validator decides whether the LLM's answer kept the + // privacy tokens verbatim AND whether it produced any + // spontaneous PII; on `retry` we re-run `chatInContext` + // ONCE with a stricter directive correction; on `block` + // we swap the entire payload for the placeholder. Runs + // BEFORE the egress filter so a retry generates a fresh + // answer that the egress filter then double-checks as + // defence-in-depth. Same `turnId` → same privacyHandle + + // receipt accumulator across both attempts. + if (privacyService && privacyHandle) { + result = await this.applyOutputValidator( + input, + result, + turnId, + privacyService, + privacyHandle, + ); + } + // Privacy-Shield v2 (Phase A, post-deploy 2026-05-14) — + // mechanical restoration of LLM self-anonymization patterns + // ("Mitarbeiter 1/2/3", "Employee N", "Person N", …) that + // the directive cannot reliably suppress. Runs AFTER the + // validator's retry decision (so the second-attempt answer is + // what gets repaired) and BEFORE the egress filter (so the + // spontaneous-PII scan sees a clean restored text). The + // operation is conservative — when count of labels doesn't + // fit the captured positional token list, the text is left + // unchanged and the gap surfaces via the receipt. + if (privacyHandle) { + result = await this.applyAntiSelfAnonymization(result, privacyHandle); + } + // Privacy-Shield v2 (Slice S-6) — Egress Filter. Last gate + // before the receipt drains; re-scans every user-facing text + // slot with the full detector pool and reacts to spontaneous + // PII per the operator-configured mode. `mask` rewrites the + // span inline (default); `block` swaps the entire payload + // for the configured placeholder. The egress receipt block + // lands in the same `finalize()` aggregation below. + if (privacyService && privacyHandle) { + result = await this.applyEgressFilter(result, privacyService, privacyHandle); + } + // Privacy-Shield v2 (Phase A.2, post-deploy 2026-05-14 third + // iteration) — final-scrub pass. The egress filter's `mask` + // mode replaces spontaneous PII with FRESH `«TYPE_N»` tokens. + // Those tokens otherwise flow through to the user as + // token-shape cruft (HR-routine Zusammenfassung v152). This + // pass scrubs every remaining token via positional + // restoration + per-type German placeholder fallback, + // guaranteeing the channel-bound text contains no token + // shapes. + if (privacyHandle) { + result = await this.applyPostEgressScrub(result, privacyHandle); + } + // Privacy-Engine Hardening Slice #4 — Orphan-Placeholder + // explainer footer. Phase A.2's scrub may leave the answer + // with `[Name]` / `[Adresse]` / … strings when positional + // restoration was uncertain. Without a hint the user has no + // way to interpret them. Append a brief diagnostic so they + // know what happened and how to rephrase. No retry — that's + // expensive and rarely helpful since the LLM would likely + // produce the same names; revisit when receipt-persistence + // (S-7.5) lets us measure prevalence. + if (privacyHandle) { + const orphanAnalysis = detectOrphanPlaceholders(result.answer); + if (orphanAnalysis.count > 0) { + console.warn( + `[orchestrator.orphanPlaceholders] turn=${turnId} count=${String(orphanAnalysis.count)} types=${orphanAnalysis.types.join(',')}`, + ); + result = { + ...result, + answer: appendOrphanPlaceholderFooter( + result.answer, + orphanAnalysis, + ), + }; + } + } if (privacyHandle) { try { const receipt = await privacyHandle.finalize(); @@ -1168,6 +1261,220 @@ export class Orchestrator { ); } + /** + * Privacy-Shield v2 (D-2) — apply the Output Validator and act on + * its recommendation: + * + * - `pass`: ship the result unchanged. + * - `retry`: re-run `chatInContext` with an `extraSystemHint` that + * calls out the failure mode (token-loss → emit tokens + * verbatim; spontaneous PII → ask for clarification). + * Caps at ONE retry; a third attempt is not started even + * if the validator still says `retry`. + * - `block`: replace the payload with the operator-configured + * egress block placeholder (same helper as S-6). + * + * The retry path re-enters `chatInContext` under the SAME `turnId`, + * so the privacy handle's accumulator and tokenise-map are reused — + * the receipt at finalize covers both attempts in one row. + */ + private async applyOutputValidator( + input: ChatTurnInput, + result: ChatTurnResult, + turnId: string, + privacyService: PrivacyGuardService, + privacyHandle: ReturnType<typeof createPrivacyTurnHandle>, + ): Promise<ChatTurnResult> { + if (result.pendingUserChoice) { + // Clarification turns have no fact content — same short-circuit + // the verifier uses. The validator's spontaneous-PII detector + // would only see the question text, never the user's data. + return result; + } + let verdict; + try { + verdict = await privacyHandle.validateOutput({ assistantText: result.answer }); + } catch (err) { + console.warn( + '[orchestrator] privacyGuard.validateOutput threw — shipping un-validated:', + err, + ); + return result; + } + if (verdict.recommendation === 'pass') return result; + // D-2.1 hotfix: both `retry` and `block` recommendations get one + // retry attempt with the appropriate anti-paraphrase / anti- + // hallucination directive. Originally `block` short-circuited + // straight to the placeholder, but in practice that turns a + // paraphrased HR-routine answer into a "filter withheld this" + // notice instead of giving the LLM a chance to re-emit the + // restored values verbatim. Now: try ONCE more for both; only + // the second-pass `block` actually ships the placeholder. + // recommendation === 'retry' OR 'block' + const correction = buildValidatorCorrectionPrompt(verdict); + const retryInput: ChatTurnInput = { + ...input, + extraSystemHint: composeRetryHint(input.extraSystemHint, correction), + }; + let retried: ChatTurnResult; + try { + retried = await this.chatInContext(retryInput, turnId); + } catch (err) { + console.warn('[orchestrator] privacyGuard.validateOutput retry FAIL — keeping first answer:', err); + return result; + } + // D-2.2: after the retry, ALWAYS ship the retried answer. The + // downstream egress filter (S-6) is the safety net for any + // remaining spontaneous PII — with `egress_filter_mode=mask` + // (default) those spans are replaced inline with `«PERSON_N»` + // tokens, so the user sees a usable answer with masked + // hallucinations rather than a "filter withheld this" placeholder. + // The placeholder path only fires when the operator has set + // `egress_filter_mode=block` AND the egress filter detects + // spontaneous PII — i.e. when the operator explicitly opted into + // hard-block-on-residual-PII semantics. Re-validating the + // retried text here would only re-discover what the egress + // filter is about to act on. + return retried; + } + + /** + * Privacy-Shield v2 (Slice S-6) — apply the egress filter to a + * `ChatTurnResult` before the receipt is finalised. The filter + * walks every user-facing text slot via `collectEgressSlots`, + * calls the privacy-guard service for spontaneous-PII detection + * + replacement, and merges the transformed texts back via + * `applyEgressReplacements`. On `routing: 'blocked'` the entire + * payload is replaced with the configured placeholder via + * `buildBlockedResult` so the channel never sees the original + * potentially-PII-bearing content. + * + * Safe to call when egress is disabled in operator config (`enabled + * === false`) or no text slots exist (the result has empty + * `answer` and no interactive payload). Errors are caught and + * surfaced as warnings — egress is best-effort defence-in-depth, + * not a hard gate. + */ + private async applyEgressFilter( + result: ChatTurnResult, + privacyService: PrivacyGuardService, + privacyHandle: ReturnType<typeof createPrivacyTurnHandle>, + ): Promise<ChatTurnResult> { + let config; + try { + config = privacyService.getEgressConfig(); + } catch (err) { + console.warn('[orchestrator] privacyGuard.getEgressConfig threw — skipping egress:', err); + return result; + } + if (!config.enabled) return result; + const slots = collectEgressSlots(result); + if (slots.length === 0) return result; + let egress; + try { + egress = await privacyHandle.egressFilter({ texts: slots }); + } catch (err) { + console.warn('[orchestrator] privacyGuard.egressFilter threw — shipping un-filtered:', err); + return result; + } + if (egress.routing === 'blocked') { + return buildBlockedResult(result, config.blockPlaceholderText); + } + if (egress.routing === 'allow') return result; + const replacements = new Map<string, string>(); + for (const slot of egress.texts) { + replacements.set(slot.id, slot.text); + } + return applyEgressReplacements(result, replacements); + } + + /** + * Privacy-Shield v2 (Phase A, post-deploy 2026-05-14) — apply the + * self-anonymization label restorer to every channel-bound text + * slot before the egress filter runs. + * + * The restorer is **strictly additive**: it rewrites recognised + * label patterns to real names from the turn-map's most recent + * tool-result capture, and conservatively skips when the positional + * mapping is ambiguous. It cannot widen the set of values reaching + * the channel — every substitution comes from a real value the + * shield already chose to tokenise on inbound, so the user receives + * data the system already had authorisation to surface. Errors are + * caught and surfaced as warnings; on failure the un-restored text + * is shipped (egress filter is the second line of defence). + */ + private async applyAntiSelfAnonymization( + result: ChatTurnResult, + privacyHandle: ReturnType<typeof createPrivacyTurnHandle>, + ): Promise<ChatTurnResult> { + const slots = collectEgressSlots(result); + if (slots.length === 0) return result; + const replacements = new Map<string, string>(); + let changed = false; + for (const slot of slots) { + let outcome; + try { + outcome = await privacyHandle.restoreSelfAnonymizationLabels({ + text: slot.text, + }); + } catch (err) { + console.warn( + '[orchestrator] privacyGuard.restoreSelfAnonymizationLabels threw — keeping original slot:', + err, + ); + continue; + } + if (outcome.text !== slot.text) { + replacements.set(slot.id, outcome.text); + changed = true; + } + } + if (!changed) return result; + return applyEgressReplacements(result, replacements); + } + + /** + * Privacy-Shield v2 (Phase A.2, post-deploy 2026-05-14 third + * iteration) — final-scrub pass that runs AFTER the egress filter. + * Guarantees the channel-bound text contains no `«TYPE_N»` token + * shapes by attempting positional restoration against unaccounted + * tool-result names first, then falling back to per-type German + * placeholders for the remainder. + * + * Strictly additive: errors are caught and surfaced as warnings; + * on failure the slot ships with whatever tokens egress left in + * place. The post-condition is a guarantee on the happy path. + */ + private async applyPostEgressScrub( + result: ChatTurnResult, + privacyHandle: ReturnType<typeof createPrivacyTurnHandle>, + ): Promise<ChatTurnResult> { + const slots = collectEgressSlots(result); + if (slots.length === 0) return result; + const replacements = new Map<string, string>(); + let changed = false; + for (const slot of slots) { + let outcome; + try { + outcome = await privacyHandle.restoreOrScrubRemainingTokens({ + text: slot.text, + }); + } catch (err) { + console.warn( + '[orchestrator] privacyGuard.restoreOrScrubRemainingTokens threw — keeping original slot:', + err, + ); + continue; + } + if (outcome.text !== slot.text) { + replacements.set(slot.id, outcome.text); + changed = true; + } + } + if (!changed) return result; + return applyEgressReplacements(result, replacements); + } + private async chatInContext( input: ChatTurnInput, turnId: string, @@ -1247,6 +1554,35 @@ export class Orchestrator { // `responseGuard@1` provider is installed; identical cache shape then. const prependRules = await this.resolvePrependRules(messages); + // Privacy-Engine Hardening — Single-Token-Bypass. + // When the user's input message tokenises to mostly PII tokens + // (≥70% non-whitespace chars replaced), the LLM has no semantic + // content to reason about and tends to rationalise the leftover + // tokens as "unfilled template variables" — producing polite + // hallucinations like "you forgot to fill in [Name]". Short-circuit + // here and ship a clear refusal answer so the user can re-phrase. + // The bypass detection itself goes through `privacy.processOutbound`, + // so the receipt accumulator records the detections — operators + // see what fired in the per-turn receipt. + const privacyForBypass = turnContext.current()?.privacyHandle; + if (privacyForBypass && input.userMessage.trim().length > 0) { + const saturation = await analyzeTokenSaturation( + input.userMessage, + privacyForBypass, + ); + if (saturation.triggered) { + console.warn( + `[orchestrator.privacyBypass] turn=${turnId} ratio=${saturation.coverageRatio.toFixed(2)} tokens=${String(saturation.tokenCount)} originalChars=${String(saturation.originalChars)} survived=${String(saturation.survivedChars)} — skipping LLM call`, + ); + entityCollection?.drain(); + return { + answer: bypassCannedAnswer(saturation), + toolCalls: 0, + iterations: 0, + }; + } + } + try { for (let iteration = 0; iteration < this.maxIterations; iteration++) { // Privacy-Proxy Slice 2.1: tokenise outbound payload + restore @@ -1525,6 +1861,9 @@ export class Orchestrator { ? { chatParticipants: parent.chatParticipants } : {}), ...(privacyHandle ? { privacyHandle } : {}), + ...(parent?.captureRawToolResult + ? { captureRawToolResult: parent.captureRawToolResult } + : {}), }); this.applyTurnAuthContext(input); @@ -2017,10 +2356,10 @@ export class Orchestrator { // Slice 2.2 — privacy-proxy tool roundtrip. // // Restore tokens in the input BEFORE the handler runs so domain tools - // (domain integrations, Calendar, KG) see real user data instead of `tok_<hex>` + // (Odoo, Calendar, KG) see real user data instead of `tok_<hex>` // placeholders that the downstream system would not be able to // resolve. Re-scan the result text AFTER the handler returns so any - // fresh PII the tool surfaced (e.g. "Jane Example" from a directory-sub-agent) + // fresh PII the tool surfaced (e.g. "John Doe" from query_odoo_hr) // is tokenised before it flows back to the LLM as a `tool_result` // block — the public LLM never sees the plaintext. // @@ -2044,6 +2383,22 @@ export class Orchestrator { } } const result = await this.dispatchToolInner(name, dispatchInput, observer); + // Phase C.2 — Raw tool-result capture. Outer scope (routine runner) + // may install a callback that stashes the pre-tokenisation result + // keyed by tool name; later template rendering uses it as the source + // of truth for data sections so the LLM never authors data rows. + // Absent callback ⇒ no capture (chat + non-templated routines). + const capture = turnContext.current()?.captureRawToolResult; + if (capture !== undefined && typeof result === 'string') { + try { + capture(name, result); + } catch (err) { + console.warn( + `[orchestrator.dispatchTool:${name}] captureRawToolResult threw — continuing without capture:`, + err, + ); + } + } if (privacy !== undefined && typeof result === 'string' && result.length > 0) { try { const tokenised = await privacy.processToolResult({ @@ -2295,3 +2650,52 @@ function collectTextBlocks(content: ContentBlock[]): string[] { // It's imported at the top of this file and re-exported via the back-compat // barrel so `import { toSemanticAnswer } from '../orchestrator.js'` callers // (verifier wrapper today, channel adapters until S+11) keep working. + +// --------------------------------------------------------------------------- +// Privacy-Shield v2 (D-2) — Output Validator retry-prompt helpers. +// --------------------------------------------------------------------------- + +/** + * Pick a correction prompt based on what the validator flagged. The + * directive is appended to the system prompt for the retry attempt; + * it does NOT carry PII (no values, no spans) so it is safe to log. + */ +export function buildValidatorCorrectionPrompt(verdict: PrivacyOutputValidationResult): string { + const reason = verdict.recommendationReason ?? ''; + // Spontaneous PII signal: the validator already routes spontaneous + // hits to recommendation=`block`, but `retry` can also be issued by + // a future validator extension. Branch on the reason prefix instead + // of recomputing from `verdict.spontaneousPiiHits`. + if (reason.startsWith('spontaneous PII') || verdict.spontaneousPiiHits.length > 0) { + return [ + '<privacy-validator-retry>', + 'Your previous answer contained PII values that were never supplied', + 'via a tool result or user message. Do not invent identifiers (names,', + 'emails, IBANs, phone numbers, addresses). If a required value is', + 'missing, ask a single clarifying question instead of guessing.', + '</privacy-validator-retry>', + ].join('\n'); + } + // Token-loss is the default failure mode (HR-routine regression). + return [ + '<privacy-validator-retry>', + 'Your previous answer dropped privacy tokens. Tokens look like', + '`«PERSON_1»`, `«EMAIL_2»`, etc. Re-emit the answer; every token from', + 'tool results MUST appear verbatim where its value would go — in table', + 'cells, list items, sentences, JSON. Do not paraphrase, summarise,', + 'invent, or translate token values. The privacy shield restores them', + 'to the real values after you finish.', + '</privacy-validator-retry>', + ].join('\n'); +} + +/** + * Append the validator correction to an existing `extraSystemHint` so + * a caller-supplied hint (e.g. the answer-verifier's contradiction + * note) is preserved alongside the privacy directive. Both fire in + * the same retry, double-budget for both signals. + */ +export function composeRetryHint(existing: string | undefined, correction: string): string { + if (existing === undefined || existing.trim().length === 0) return correction; + return `${existing}\n\n${correction}`; +} diff --git a/middleware/packages/harness-orchestrator/src/orphanPlaceholderCheck.ts b/middleware/packages/harness-orchestrator/src/orphanPlaceholderCheck.ts new file mode 100644 index 000000000..b95d64ea0 --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/orphanPlaceholderCheck.ts @@ -0,0 +1,123 @@ +/** + * Privacy-Engine Hardening Slice #4 — Orphan-Placeholder Detection. + * + * Phase A.2's post-egress scrub replaces unresolved privacy tokens with + * conservative German placeholders (`[Name]`, `[Adresse]`, `[E-Mail]`, + * …) when positional restoration is uncertain. These placeholders + * preserve privacy and avoid misinformation, but they're opaque to + * the user: they see `[Name]` in the chat and have no clue why or + * what to do. + * + * This module adds the final user-facing layer: detect those orphans + * in the channel-bound text after every privacy step has run, and + * append a brief diagnostic footer explaining what happened. Pure, + * stateless — no I/O, no retry logic. Retry-based mitigations are + * deferred until receipt-persistence (S-7.5) lets us measure how + * often this fires and whether a retry would actually help. + */ + +/** + * The placeholder strings Phase A.2 emits, keyed by their token-type + * prefix. Kept in sync with `TYPE_PLACEHOLDERS` in + * `@omadia/plugin-privacy-guard/selfAnonymization.ts`. Duplicating the + * literal here avoids a cross-package import for a small constant — + * the format is part of the protocol the orchestrator already speaks. + * + * Includes the generic `[Vertraulich]` fallback the scrub emits when + * the token type doesn't match any of the known categories. + */ +const KNOWN_PLACEHOLDERS = [ + '[Name]', + '[E-Mail]', + '[Telefon]', + '[IBAN]', + '[Kreditkarte]', + '[Adresse]', + '[Organisation]', + '[IP-Adresse]', + '[Krypto-Adresse]', + '[Schlüssel]', + '[ID-Nummer]', + '[Vertraulich]', +] as const; + +export interface OrphanPlaceholderAnalysis { + /** Total placeholder occurrences (counts duplicates). */ + readonly count: number; + /** Distinct placeholder strings present, in first-seen order. */ + readonly types: readonly string[]; +} + +/** + * Scan `text` for Phase A.2 placeholder strings. Pure regex scan — + * counts ALL occurrences (a single answer with two `[Name]`s reports + * count=2) and the distinct set of placeholder strings found. Empty + * input + zero matches return `{ count: 0, types: [] }`. + */ +export function detectOrphanPlaceholders( + text: string, +): OrphanPlaceholderAnalysis { + if (text.length === 0) return { count: 0, types: [] }; + // Collect every occurrence with its position so we can sort by + // text-order. Without this step, `types` would be in the order of + // KNOWN_PLACEHOLDERS iteration, which is unrelated to where the + // user actually sees the placeholders in the answer. + const occurrences: Array<{ index: number; placeholder: string }> = []; + for (const placeholder of KNOWN_PLACEHOLDERS) { + let index = text.indexOf(placeholder); + while (index !== -1) { + occurrences.push({ index, placeholder }); + index = text.indexOf(placeholder, index + placeholder.length); + } + } + occurrences.sort((a, b) => a.index - b.index); + const seen = new Set<string>(); + const types: string[] = []; + for (const occ of occurrences) { + if (!seen.has(occ.placeholder)) { + seen.add(occ.placeholder); + types.push(occ.placeholder); + } + } + return { count: occurrences.length, types }; +} + +/** + * Append a brief explanatory footer when orphan placeholders are + * present. Pure: returns the original text unchanged if no + * placeholders were found OR if the footer would be a no-op + * (extremely short input). Idempotent: re-applying to an already- + * footered text does NOT add a second footer (the marker string is + * checked). + * + * The footer is a single short paragraph in German — matches the + * existing chat-UI tone, mentions the specific placeholder count so + * the user can correlate, and points at the cause (privacy filter + * could not resolve specific tokens). Operators can switch the text + * via the optional `footerText` argument when we eventually wire a + * configuration knob (out of scope for this slice). + */ +export const ORPHAN_PLACEHOLDER_FOOTER_MARKER = + '<!-- privacy-engine: orphan-placeholders -->'; + +export function appendOrphanPlaceholderFooter( + text: string, + analysis: OrphanPlaceholderAnalysis = detectOrphanPlaceholders(text), +): string { + if (analysis.count === 0) return text; + if (text.includes(ORPHAN_PLACEHOLDER_FOOTER_MARKER)) return text; + const types = + analysis.types.length === 1 + ? analysis.types[0]! + : analysis.types.join(', '); + const footer = [ + '', + '', + '---', + `_Hinweis: ${String(analysis.count)} Datenfeld${analysis.count === 1 ? '' : 'er'} (${types}) ` + + 'konnte vom Privacy-Filter nicht eindeutig zugeordnet werden und wurde durch einen Platzhalter ersetzt. ' + + 'Falls du den vollständigen Wert brauchst, frag bitte gezielter nach (mit eindeutigem Namen oder Kontext)._', + ORPHAN_PLACEHOLDER_FOOTER_MARKER, + ].join('\n'); + return text + footer; +} diff --git a/middleware/packages/harness-orchestrator/src/privacyHandle.ts b/middleware/packages/harness-orchestrator/src/privacyHandle.ts index dd85c6d2c..c9b57868a 100644 --- a/middleware/packages/harness-orchestrator/src/privacyHandle.ts +++ b/middleware/packages/harness-orchestrator/src/privacyHandle.ts @@ -15,9 +15,15 @@ */ import type { + PrivacyEgressMode, + PrivacyEgressResult, + PrivacyEgressTextInput, PrivacyGuardService, PrivacyOutboundMessage, + PrivacyOutputValidationResult, + PrivacyPostEgressScrubResult, PrivacyReceipt, + PrivacySelfAnonymizationResult, Routing, } from '@omadia/plugin-api'; @@ -70,6 +76,48 @@ export interface PrivacyTurnHandle { readonly text: string; readonly transformed: boolean; }>; + /** + * Privacy-Shield v2 (Slice S-6) — run the Egress Filter against the + * final channel-bound text slots before the answer is handed to the + * channel plugin. The host walks the result-shape (text + interactive + * card labels + attachment alt-text) and hands each slot in as a + * `{ id, text }` pair; the filter returns a transformed array plus + * the routing decision the host MUST honour (`blocked` → swap with + * placeholder). + */ + egressFilter(input: { + readonly mode?: PrivacyEgressMode; + readonly texts: readonly PrivacyEgressTextInput[]; + }): Promise<PrivacyEgressResult>; + /** + * Privacy-Shield v2 (D-2) — Output Validator hook. Runs the + * token-loss + spontaneous-PII checks on the final assistant text + * BEFORE the egress filter so the orchestrator can act on the + * `retry` / `block` recommendation. Result is folded into the + * receipt's `output` block at `finalize()` time. + */ + validateOutput(input: { + readonly assistantText: string; + }): Promise<PrivacyOutputValidationResult>; + /** + * Privacy-Shield v2 (Phase A, post-deploy 2026-05-14) — mechanical + * restoration of LLM self-anonymization patterns ("Mitarbeiter 1/2/3", + * "Employee N", "Person N", …). Runs after `processInbound` has + * restored the verbatim tokens, before the egress filter. The + * positional source comes from the last `processToolResult` capture + * (tracked on the service's turn accumulator). + */ + restoreSelfAnonymizationLabels(input: { + readonly text: string; + }): Promise<PrivacySelfAnonymizationResult>; + /** + * Privacy-Shield v2 (Phase A.2) — final-scrub pass post-egress. + * Guarantees the returned text contains no `«TYPE_N»` token shapes + * via positional restoration + generic placeholder fallback. + */ + restoreOrScrubRemainingTokens(input: { + readonly text: string; + }): Promise<PrivacyPostEgressScrubResult>; } export function createPrivacyTurnHandle(deps: { @@ -126,32 +174,57 @@ export function createPrivacyTurnHandle(deps: { }); return { text: r.text, transformed: r.transformed }; }, + + async egressFilter(input) { + return deps.service.egressFilter({ + sessionId: deps.sessionId, + turnId: deps.turnId, + ...(input.mode !== undefined ? { mode: input.mode } : {}), + texts: input.texts, + }); + }, + + async validateOutput(input) { + return deps.service.validateOutput({ + sessionId: deps.sessionId, + turnId: deps.turnId, + assistantText: input.assistantText, + }); + }, + + async restoreSelfAnonymizationLabels(input) { + return deps.service.restoreSelfAnonymizationLabels({ + sessionId: deps.sessionId, + turnId: deps.turnId, + text: input.text, + }); + }, + + async restoreOrScrubRemainingTokens(input) { + return deps.service.restoreOrScrubRemainingTokens({ + sessionId: deps.sessionId, + turnId: deps.turnId, + text: input.text, + }); + }, }; } // --------------------------------------------------------------------------- // Streaming-buffered restore: holds back trailing characters that could be -// the start of a `tok_<8hex>_<type>` pattern crossing chunk boundaries. +// the start of a `«TYPE_N»` token pattern crossing chunk boundaries. // -// Token format (Slice 2.2, see harness-plugin-privacy-guard/src/tokenizeMap.ts): -// `tok_` + 8 lowercase hex chars + `_` + 1..30 chars of [a-z0-9_] +// Token format (Privacy-Shield v2, see harness-plugin-privacy-guard/src/tokenizeMap.ts): +// `«` + uppercase TYPE + `_` + counter + `»` // -// Strategy: -// - Find the LAST `tok_` substring in `text`. -// - Decide stage-by-stage whether the chars after it could still grow -// into a complete token in a future chunk: -// 1. <8 chars and all hex → could grow, HOLD -// 2. exactly 8 hex, no `_` yet → could grow (`_` may follow), HOLD -// 3. 8 hex + `_`, then 0+ suffix chars and NO terminating word -// boundary in this text yet → could grow, HOLD -// 4. 8 hex + `_` + 1+ suffix chars + a non-[a-z0-9_] terminator -// char already in `text` → token complete (regex -// will catch it on emit), no HOLD -// 5. broken pattern (non-hex in the first 8 chars, or the 9th -// char is not `_`) → never a token, no HOLD +// The closing guillemet `»` is the unambiguous terminator. We hold from +// the last `«` until either: +// - a `»` arrives in this chunk → token is complete, emit all +// - or the chunk ends → keep holding for the next chunk // -// Trailing partial holds across chunks until either complete or definitively -// non-token. On stream end the caller flushes whatever is left as plain text. +// False holds (a stray `«` that never closes, e.g. legitimate use of +// guillemets in prose) flush when the chunk ends or another `«` appears. +// On stream end the caller flushes whatever is left as plain text. // --------------------------------------------------------------------------- export interface BoundarySplit { @@ -160,51 +233,18 @@ export interface BoundarySplit { } export function streamingTokenBoundary(text: string): BoundarySplit { - const lastIdx = text.lastIndexOf('tok_'); - if (lastIdx === -1) return { safe: text, hold: '' }; + const lastOpen = text.lastIndexOf('«'); + if (lastOpen === -1) return { safe: text, hold: '' }; - const after = text.slice(lastIdx + 4); + // If the last `«` is followed by a closing `»` somewhere later in + // this chunk, the token (or false-positive) is fully captured here. + // Emit everything and let the restore regex decide. + const after = text.slice(lastOpen); + if (after.includes('»')) return { safe: text, hold: '' }; - // Stage 1: not enough hex chars yet. - if (after.length < 8) { - if (/^[0-9a-f]*$/.test(after)) { - // Could still grow into 8-hex prefix — hold. - return { safe: text.slice(0, lastIdx), hold: text.slice(lastIdx) }; - } - // Non-hex char already broke the pattern. - return { safe: text, hold: '' }; - } - - // We have ≥8 chars after `tok_`. Check the first 8 are hex. - const hexPart = after.slice(0, 8); - if (!/^[0-9a-f]{8}$/.test(hexPart)) { - // The first 8 chars contain a non-hex byte — definitely not a token. - return { safe: text, hold: '' }; - } - - const sepAndSuffix = after.slice(8); - - // Stage 2: nothing after the hex yet — `_<suffix>` may still arrive. - if (sepAndSuffix.length === 0) { - return { safe: text.slice(0, lastIdx), hold: text.slice(lastIdx) }; - } - - // Stage 5: ninth char is not `_` — token format broken; this is plain text. - if (sepAndSuffix[0] !== '_') { - return { safe: text, hold: '' }; - } - - // Stage 3 / 4: have `_` separator. Look for the terminating word boundary. - const suffix = sepAndSuffix.slice(1); - // Search for first non-[a-z0-9_] char inside the suffix portion. - const boundaryIdx = suffix.search(/[^a-z0-9_]/); - if (boundaryIdx === -1) { - // No boundary yet — suffix could still extend in the next chunk. - return { safe: text.slice(0, lastIdx), hold: text.slice(lastIdx) }; - } - // Boundary present in this chunk → token is fully captured; emit all and - // let the regex restore catch it. - return { safe: text, hold: '' }; + // No closing guillemet yet — the token may complete in the next + // chunk. Hold from the opening guillemet. + return { safe: text.slice(0, lastOpen), hold: text.slice(lastOpen) }; } // --------------------------------------------------------------------------- diff --git a/middleware/packages/harness-orchestrator/src/sessionLogger.ts b/middleware/packages/harness-orchestrator/src/sessionLogger.ts index 02d38dd11..1893f8eb6 100644 --- a/middleware/packages/harness-orchestrator/src/sessionLogger.ts +++ b/middleware/packages/harness-orchestrator/src/sessionLogger.ts @@ -190,7 +190,7 @@ function renderHeader(scope: string, day: string): string { 'Chronologisches Protokoll der Q&A-Turns in diesem Scope. Wird von der', 'Middleware geschrieben, nicht von Claude. Bei wiederkehrenden Themen oder', 'Rückbezügen auf frühere Gespräche gezielt hier nachschlagen, statt den', - 'gesamten Sub-Agent-Roundtrip zu wiederholen.', + 'gesamten Odoo-Roundtrip zu wiederholen.', '', '---', '', @@ -212,7 +212,7 @@ function renderTurn(args: { : ''; // Entity anchors ride as an HTML comment so humans reading the .md see a // clean transcript while a graph-ingest parser picks them up deterministically. - // Shape: <!-- entities: [{"s":"<source>","m":"<model>","id":42,"n":"<name>"}, …] --> + // Shape: <!-- entities: [{"s":"odoo","m":"hr.employee","id":42,"n":"Müller"}, …] --> const entitiesComment = args.entityRefs.length > 0 ? `\n<!-- entities: ${JSON.stringify(args.entityRefs.map(serialiseRef))} -->\n` diff --git a/middleware/packages/harness-orchestrator/src/streaming.ts b/middleware/packages/harness-orchestrator/src/streaming.ts index 3f4675fa5..3760db785 100644 --- a/middleware/packages/harness-orchestrator/src/streaming.ts +++ b/middleware/packages/harness-orchestrator/src/streaming.ts @@ -86,7 +86,7 @@ export async function* streamMessageEvents(args: { let lastTokensPerSec = 0; let phase: 'thinking' | 'streaming' | 'tool_running' = 'thinking'; // Streaming-buffered Restore: hold trailing chars that could complete a - // `tok_<8hex>` pattern in the next chunk. Flushed on stream end. + // `«TYPE_N»` token in the next chunk. Flushed on stream end. let pendingHold = ''; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/middleware/packages/harness-orchestrator/src/tokenSaturationBypass.ts b/middleware/packages/harness-orchestrator/src/tokenSaturationBypass.ts new file mode 100644 index 000000000..d5b39b505 --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/tokenSaturationBypass.ts @@ -0,0 +1,174 @@ +/** + * Privacy-Engine Hardening — Single-Token-Bypass. + * + * When a user's input message is dominated by PII tokens after the + * privacy guard tokenises it, the LLM has no semantic content to work + * with. In practice the LLM tends to rationalise the leftover tokens + * as "unfilled template variables" and hallucinates a polite "you forgot + * to fill in [Name]"-style response. Observed in production 2026-05-15 + * (Christian-Wendler-screenshot in HANDOFF). + * + * Instead of letting the LLM hallucinate, we short-circuit before the + * call and ship a clear "kann ich nicht beantworten" response. The + * operator sees the bypass diagnostic in stdout (and eventually the + * privacy receipt once S-7.5 lands). + * + * Pure: no I/O beyond the privacy handle's `processOutbound` call. The + * bypass logic doesn't know about chat history or system prompts — it + * only looks at THIS turn's user message. Other turns in the + * conversation are unaffected. + */ + +import type { PrivacyTurnHandle } from './privacyHandle.js'; + +/** + * Local copy of the canonical privacy-guard token shape. Kept in sync + * with `TOKEN_REGEX` in `@omadia/plugin-privacy-guard/tokenizeMap` — + * the orchestrator already speaks this protocol (cf. the streaming + * boundary helper in `privacyHandle.ts`), so duplicating the small + * literal here avoids a cross-package import for a single regex. + */ +const TOKEN_REGEX = /«[A-Z][A-Z_]*_\d+»/g; + +export interface TokenSaturationBypassConfig { + /** + * Trigger threshold as a ratio of non-whitespace original characters + * that got replaced by tokens. Default 0.65 — empirically tuned to + * avoid false positives on realistic German chat inputs where the + * user references multiple people inside a normal sentence (e.g. + * "Mit Marcel Wege und Anna Müller das Meeting" → coverage ≈ 0.56, + * which the LLM handles fine). Only inputs that are dominated by + * tokens — essentially name lists with minimal connective tissue — + * cross this line. The 2026-05-15 production-screenshot case + * ("Hey, Bitchi, sei lieb Du Deinem Papa Marcel!" → r ≈ 0.51) sits + * below the threshold by design: we accept the occasional + * hallucination on that specific shape rather than the larger + * false-positive surface a lower threshold would create. The + * orphan-placeholder footer (engine slice #4) softens the impact + * when the LLM does produce `[Name]` artifacts. + */ + readonly ratio: number; + /** + * Minimum non-whitespace input length to even consider bypass. + * Default 15 — a typical short greeting + one name ("Hi Marcel") + * sits below this and never triggers, regardless of ratio. + */ + readonly minLength: number; + /** + * Minimum distinct token count to consider bypass. Default 4 — + * paired with the high ratio threshold so the bypass fires only on + * inputs where 4+ entities saturate the message. Three-name + * sentences with descriptive context (e.g. "Marcel, Anna, Ben — + * alle drei im Office heute?") stay below this floor and reach the + * LLM normally. + */ + readonly minTokenCount: number; +} + +export const DEFAULT_BYPASS_CONFIG: TokenSaturationBypassConfig = { + ratio: 0.65, + minLength: 15, + minTokenCount: 4, +}; + +export interface TokenSaturationAnalysis { + readonly triggered: boolean; + /** Total non-whitespace chars in the original user message. */ + readonly originalChars: number; + /** Sum of char-lengths of all `«TYPE_N»` tokens in the tokenised text. */ + readonly tokenChars: number; + /** Non-whitespace chars in the tokenised text that are NOT inside a token shape. */ + readonly survivedChars: number; + /** Number of distinct token occurrences in the tokenised text. */ + readonly tokenCount: number; + /** Fraction of original non-whitespace chars that got tokenised away. */ + readonly coverageRatio: number; +} + +/** + * Compute saturation statistics for a single user message against the + * active privacy handle. Caller decides what to do with the result + * (typically: ship a canned answer instead of the LLM call). + */ +export async function analyzeTokenSaturation( + userMessage: string, + privacy: PrivacyTurnHandle, + config: TokenSaturationBypassConfig = DEFAULT_BYPASS_CONFIG, +): Promise<TokenSaturationAnalysis> { + const originalNonWs = stripWhitespace(userMessage); + if (originalNonWs.length < config.minLength) { + return { + triggered: false, + originalChars: originalNonWs.length, + tokenChars: 0, + survivedChars: originalNonWs.length, + tokenCount: 0, + coverageRatio: 0, + }; + } + let outbound; + try { + outbound = await privacy.processOutbound({ + systemPrompt: '', + messages: [{ role: 'user', content: userMessage }], + }); + } catch { + // Tokenisation itself failed — refuse to bypass (the LLM path can + // still degrade gracefully, but we shouldn't double-fail). + return { + triggered: false, + originalChars: originalNonWs.length, + tokenChars: 0, + survivedChars: originalNonWs.length, + tokenCount: 0, + coverageRatio: 0, + }; + } + const tokenised = outbound.messages[0]?.content ?? userMessage; + const tokenisedNonWs = stripWhitespace(tokenised); + let tokenChars = 0; + let tokenCount = 0; + const re = new RegExp(TOKEN_REGEX.source, 'g'); + for (const match of tokenised.matchAll(re)) { + tokenChars += match[0].length; + tokenCount += 1; + } + const survivedChars = Math.max(0, tokenisedNonWs.length - tokenChars); + const coverageRatio = + originalNonWs.length > 0 + ? (originalNonWs.length - survivedChars) / originalNonWs.length + : 0; + const triggered = + coverageRatio >= config.ratio && tokenCount >= config.minTokenCount; + return { + triggered, + originalChars: originalNonWs.length, + tokenChars, + survivedChars, + tokenCount, + coverageRatio, + }; +} + +/** + * User-facing canned answer when bypass triggers. German to match the + * existing chat-UI tone. Mentions "Privacy-Guard" explicitly so the + * user can attribute the refusal correctly and report false positives. + */ +export function bypassCannedAnswer(analysis: TokenSaturationAnalysis): string { + const pct = Math.round(analysis.coverageRatio * 100); + return [ + 'Ich kann deine Nachricht so nicht sinnvoll beantworten — unser ' + + 'Privacy-Guard hat den Großteil davon als personenbezogene Daten ' + + `(Namen / Adressen / o.ä.) erkannt (${String(pct)}% des Texts ` + + `${String(analysis.tokenCount)} Tokens).`, + '', + 'Formuliere die Frage bitte ohne konkrete Personenangaben — oder, ' + + 'falls die Erkennung danebenliegt (z.B. bei kreativen Spitznamen ' + + 'oder gängigen Begriffen), sag dem Team Bescheid.', + ].join('\n'); +} + +function stripWhitespace(text: string): string { + return text.replace(/\s+/gu, ''); +} diff --git a/middleware/packages/harness-orchestrator/src/tools/domainQueryTool.ts b/middleware/packages/harness-orchestrator/src/tools/domainQueryTool.ts index 272caacdd..5bed52fd9 100644 --- a/middleware/packages/harness-orchestrator/src/tools/domainQueryTool.ts +++ b/middleware/packages/harness-orchestrator/src/tools/domainQueryTool.ts @@ -62,13 +62,14 @@ export interface Askable { /** * A domain-specific delegation tool. Each DomainTool wraps one sub-agent - * for a single domain. The orchestrator exposes all configured DomainTools - * to Claude simultaneously and lets the LLM pick by tool name + description. + * (e.g. Odoo Accounting, Odoo HR, Confluence Playbook). The orchestrator + * exposes all configured DomainTools to Claude simultaneously and lets the + * LLM pick by tool name + description. * - * Every DomainTool declares its `domain` (lowercase dotted identifier, e.g. - * `seo`, `confluence`, `m365.calendar`). The orchestrator threads it onto - * every emitted `ReadonlyToolTraceEntry` so the Nudge-Pipeline's - * multi-domain trigger can count distinct domains. + * OB-77 (Palaia Phase 8): every DomainTool declares its `domain` (lowercase + * dotted identifier — `confluence`, `odoo.hr`, `m365.calendar`). The + * orchestrator threads it onto every emitted `ReadonlyToolTraceEntry` so + * the Nudge-Pipeline's multi-domain trigger can count distinct domains. */ export interface DomainTool { name: string; @@ -89,7 +90,7 @@ export interface DomainToolSpec { } interface DomainToolOptions { - /** Unique tool name, e.g. `query_seo_analyst`. Must match tool_use.name. */ + /** Unique tool name, e.g. `query_odoo_accounting`. Must match tool_use.name. */ name: string; /** Description Claude sees to decide when to pick this tool. */ description: string; @@ -135,16 +136,16 @@ export function createDomainTool(options: DomainToolOptions): DomainTool { } const started = Date.now(); const preview = question.replace(/\s+/g, ' ').slice(0, 140); - console.log(`[domain-tool] ${options.name} → START: ${preview}`); + console.log(`[odoo] ${options.name} → START: ${preview}`); try { const answer = await options.agent.ask(question, observer); const elapsed = ((Date.now() - started) / 1000).toFixed(1); - console.log(`[domain-tool] ${options.name} → ok (${elapsed}s, ${answer.length} chars)`); + console.log(`[odoo] ${options.name} → ok (${elapsed}s, ${answer.length} chars)`); return answer; } catch (err) { const elapsed = ((Date.now() - started) / 1000).toFixed(1); const message = err instanceof Error ? err.message : String(err); - console.error(`[domain-tool] ${options.name} → ERROR (${elapsed}s): ${message}`); + console.error(`[odoo] ${options.name} → ERROR (${elapsed}s): ${message}`); return `Error while querying ${options.name}: ${message}`; } }, diff --git a/middleware/packages/harness-orchestrator/src/tools/findFreeSlotsTool.ts b/middleware/packages/harness-orchestrator/src/tools/findFreeSlotsTool.ts index 5d2d48d82..5ba8bfc31 100644 --- a/middleware/packages/harness-orchestrator/src/tools/findFreeSlotsTool.ts +++ b/middleware/packages/harness-orchestrator/src/tools/findFreeSlotsTool.ts @@ -24,7 +24,7 @@ const FindFreeSlotsInputSchema = z.object({ * * - **Unset / empty** → caller themself (default; "I offer X suggestions"). * - **Other email** → that person's calendar ("Teresita, find a meeting - * with John" → hostEmail=john@byte5.de). Caller needs + * with John" → hostEmail=info@omadia.ai). Caller needs * `Calendars.Read.Shared` visibility on the host; otherwise 403 is returned. */ hostEmail: z.string().email().optional(), @@ -93,7 +93,7 @@ export const findFreeSlotsToolSpec = { hostEmail: { type: 'string', description: - 'Email/UPN des Meeting-Hosts (wessen Kalender die Slots liefert). Leer lassen wenn der Caller selbst der Host ist ("ich biete an"); setzen wenn der Caller im Auftrag einer anderen Person Slots sucht ("such bei John Termin" → hostEmail=john@byte5.de).', + 'Email/UPN des Meeting-Hosts (wessen Kalender die Slots liefert). Leer lassen wenn der Caller selbst der Host ist ("ich biete an"); setzen wenn der Caller im Auftrag einer anderen Person Slots sucht ("such bei John Termin" → hostEmail=info@omadia.ai).', }, attendees: { type: 'array', diff --git a/middleware/packages/harness-orchestrator/src/turnContext.ts b/middleware/packages/harness-orchestrator/src/turnContext.ts index b3edd32b2..8563d3ff7 100644 --- a/middleware/packages/harness-orchestrator/src/turnContext.ts +++ b/middleware/packages/harness-orchestrator/src/turnContext.ts @@ -15,7 +15,7 @@ import type { PrivacyTurnHandle } from './privacyHandle.js'; * turn that rolls past midnight keeps a single, consistent * date throughout. Without this the Claude models guess * from training-data era (usually 2025) and silently - * corrupt time-relative ("last 3 months") queries. + * corrupt "letzte 3 Monate"-style Odoo queries. * - `chatParticipants` (optional) — lazy accessor for the active chat's * roster. Set by the Teams adapter (via TeamsRosterProvider) * in an outer ALS scope; the orchestrator re-threads it into @@ -45,6 +45,21 @@ export interface TurnContextValue { * payloads through unmodified — byte-identical pre-plugin behaviour). */ privacyHandle?: PrivacyTurnHandle; + /** + * Phase C.2 — Raw tool-result capture hook. When set by an outer scope + * (currently: the routine runner), every tool dispatch site (main agent + * + sub-agents) invokes this callback with the RAW handler-returned + * result BEFORE `privacy.processToolResult` tokenises it. The callback + * is responsible for stashing the value somewhere it can be consumed + * later (typically `routineTurnContext.currentRawToolResults()` from + * the routines plugin). Repeat calls for the same tool name overwrite + * the previous entry — last-write-wins. + * + * Undefined for chat turns and non-templated routine turns; tool + * dispatch then skips the capture and behaves byte-identically to + * pre-C.2. + */ + captureRawToolResult?: (toolName: string, rawResult: string) => void; } const storage = new AsyncLocalStorage<TurnContextValue>(); @@ -81,6 +96,9 @@ export const turnContext = { turnDate: prev?.turnDate ?? today(), chatParticipants, ...(prev?.privacyHandle ? { privacyHandle: prev.privacyHandle } : {}), + ...(prev?.captureRawToolResult + ? { captureRawToolResult: prev.captureRawToolResult } + : {}), }, fn, ); diff --git a/middleware/packages/harness-plugin-privacy-detector-ollama/src/nerPrompt.ts b/middleware/packages/harness-plugin-privacy-detector-ollama/src/nerPrompt.ts index 2d8b206f5..dd1f81acf 100644 --- a/middleware/packages/harness-plugin-privacy-detector-ollama/src/nerPrompt.ts +++ b/middleware/packages/harness-plugin-privacy-detector-ollama/src/nerPrompt.ts @@ -87,7 +87,7 @@ Regeln: export const NER_FEW_SHOT: ReadonlyArray<{ user: string; assistant: string }> = [ { user: 'Wann hat John Doe Urlaub beantragt?', - assistant: '{"hits":[{"type":"pii.name","value":"John Doe","start":10,"end":21,"confidence":0.96}]}', + assistant: '{"hits":[{"type":"pii.name","value":"John Doe","start":9,"end":17,"confidence":0.96}]}', }, { user: diff --git a/middleware/packages/harness-plugin-privacy-detector-presidio/manifest.yaml b/middleware/packages/harness-plugin-privacy-detector-presidio/manifest.yaml index c3df29f2e..5f730c5d9 100644 --- a/middleware/packages/harness-plugin-privacy-detector-presidio/manifest.yaml +++ b/middleware/packages/harness-plugin-privacy-detector-presidio/manifest.yaml @@ -9,8 +9,8 @@ identity: description: "Privacy-Proxy add-on detector. Identifies a broad PII set — names (spaCy PERSON), addresses (LOCATION/GPE), organizations (ORG), structured PII (Email, IBAN, Phone, Credit-Card with Luhn, country-specific IDs like DE-Steuer-ID/Personalausweis) — via Microsoft Presidio served from a Python sidecar (FastAPI + Presidio-Analyzer + spaCy DE+EN). Deterministic, ms-fast complement to the LLM-NER (Ollama) detector. Registers itself with the `privacyDetectorRegistry` published by privacy-guard; emits hits for the same span-overlap-dedup pipeline." authors: - name: "byte5 GmbH" - email: "dev@byte5.de" - url: "https://byte5.de" + email: "info@omadia.ai" + url: "https://omadia.ai" license: "MIT" categories: - "privacy" @@ -32,10 +32,10 @@ setup: fields: - key: "presidio_endpoint" type: "string" - label: "Presidio sidecar endpoint" - help: "Base URL of the Python sidecar (FastAPI with /analyze + /health). docker-compose default: http://presidio:5001 (in-network service). Local dev outside compose: http://localhost:5001." + label: "Presidio-Sidecar-Endpoint" + help: "Basis-URL des Python-Sidecars (FastAPI mit /analyze + /health). Wert je nach Deploy-Pfad: (a) lokaler dev `python uvicorn` oder `docker run` → `http://localhost:5001`. (b) OSS docker-compose mit `--profile privacy-presidio` → `http://privacy-detector-presidio:5001`. (c) Fly-Deploy via `fly.presidio.toml` → `http://odoo-bot-presidio.flycast:5001`." required: false - default: "http://presidio:5001" + default: "http://localhost:5001" - key: "presidio_language" type: "string" @@ -47,9 +47,9 @@ setup: - key: "presidio_score_threshold" type: "number" label: "Confidence-Threshold" - help: "Hits mit Score unter diesem Wert werden verworfen. Presidio-Default ist 0.4 — wir setzen 0.6 weil das spaCy-NER-Modell auf Long-Form-Inputs viele False-Positives produziert (gewöhnliche Substantive werden als Names erkannt). Höher = weniger False-Positives + niedrigerer Recall. Niedriger = mehr Recall + mehr noise. Bei 0.85 sind nur sehr starke Hits enthalten." + help: "Hits mit Score unter diesem Wert werden verworfen. Presidio-Default ist 0.4 — wir setzen 0.8 weil das deutsche spaCy-NER-Modell auf Compound-Nouns (Krankheit, Abwesenheitstyp, …) konfident-aussehende False-Positives im 0.6-0.8 Band produziert. Höher = weniger False-Positives + niedrigerer Recall. Niedriger = mehr Recall + mehr noise. Bei 0.85 sind nur sehr starke Hits enthalten." required: false - default: 0.6 + default: 0.8 - key: "presidio_timeout_ms" type: "number" diff --git a/middleware/packages/harness-plugin-privacy-detector-presidio/src/plugin.ts b/middleware/packages/harness-plugin-privacy-detector-presidio/src/plugin.ts index 329bcef37..1f5d60b1a 100644 --- a/middleware/packages/harness-plugin-privacy-detector-presidio/src/plugin.ts +++ b/middleware/packages/harness-plugin-privacy-detector-presidio/src/plugin.ts @@ -37,12 +37,18 @@ export interface PresidioDetectorPluginHandle { const DEFAULT_ENDPOINT = 'http://localhost:5001'; const DEFAULT_LANGUAGE = 'de'; -// Slice 3.4.2: bump from 0.4 (Presidio's process-wide default) to 0.6. -// spaCy's PERSON / LOCATION recognizers fire on common nouns at 0.4-0.5 -// in long-form inputs (Tool-Doc, memory recalls, …). 0.6 keeps the -// real-name and real-address recall while filtering out the FP noise -// that the 3.4 boot-smoke surfaced (272 maskings on one user turn). -const DEFAULT_SCORE_THRESHOLD = 0.6; +// Slice 3.4.2: bumped from 0.4 (Presidio's process-wide default) to 0.6. +// Post-deploy 2026-05-14: further bump 0.6 → 0.8. The HR-routine FP +// cascade ("Krankheit" → ADDRESS, "Abwesenheitstyp" → PERSON, etc.) +// showed Presidio's German spaCy NER fires confident-looking hits in +// the 0.6-0.8 band on common compound nouns. The allowlist catches +// the known cases, but never exhaustively — a higher threshold is +// the second line of defence. Trade-off: real names with low NER +// confidence (uncommon spellings, foreign first names) may now slip +// through. Acceptable because (a) the allowlist also tunes the +// other way, (b) Egress Filter re-detects spontaneous PII, and +// (c) operators can lower it via `presidio_score_threshold`. +const DEFAULT_SCORE_THRESHOLD = 0.8; const DEFAULT_TIMEOUT_MS = 3000; const DEFAULT_MAX_INPUT_CHARS = 100_000; diff --git a/middleware/packages/harness-plugin-privacy-detector-presidio/src/presidioDetector.ts b/middleware/packages/harness-plugin-privacy-detector-presidio/src/presidioDetector.ts index 7257966c1..8ed74543a 100644 --- a/middleware/packages/harness-plugin-privacy-detector-presidio/src/presidioDetector.ts +++ b/middleware/packages/harness-plugin-privacy-detector-presidio/src/presidioDetector.ts @@ -71,7 +71,7 @@ export function createPresidioDetector( // for any non-trivial tenant carries the memory recall (real // employee data, prior conversations, CRM heap) and shreds it // into 100+ name + 100+ address tokens. Effect on the LLM: - // - hundreds of identical-looking `tok_<hex>` placeholders in + // - hundreds of identical-looking `«TYPE_N»` placeholders in // the system prompt destroy contextual grounding, // - the assistant defensively hallucinates a plausible-sounding // name for the question's token, and diff --git a/middleware/packages/harness-plugin-privacy-guard/data/privacy-common-words-de.json b/middleware/packages/harness-plugin-privacy-guard/data/privacy-common-words-de.json new file mode 100644 index 000000000..ff2099c96 --- /dev/null +++ b/middleware/packages/harness-plugin-privacy-guard/data/privacy-common-words-de.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": 1, + "language": "de", + "description": "Privacy-Engine Hardening Slice #1 — German conversational filler / greetings / affirmations / common interrogatives that get false-positive-tagged as PERSON / ORG / LOCATION by spaCy NER on short prompts. Conceptually distinct from `privacy-topic-nouns-de.json` (office/HR compound nouns): this file covers casual-chat vocabulary the user types in WhatsApp-style messages. Same allowlist mechanism — spans matching these terms never become tokens. Erweitern via PR (alphabetisch sortiert, ein Begriff pro Eintrag).", + "terms": [ + "Achso", + "Aha", + "Alles klar", + "Auf Wiedersehen", + "Ciao", + "Danke", + "Genau", + "Gerne", + "Gleichfalls", + "Grüß Gott", + "Guten Abend", + "Guten Morgen", + "Guten Tag", + "Hallo", + "Hallöchen", + "Hey", + "Hi", + "Klar", + "Mahlzeit", + "Moin", + "Moinsen", + "Nachricht", + "Naja", + "Nein", + "OK", + "Okay", + "Schönen Tag", + "Servus", + "Stimmt", + "Tag", + "Tschau", + "Tschö", + "Tschüs", + "Tschüss", + "Tschüssi", + "Vielen Dank", + "Wiederhören", + "Wiedersehen" + ] +} diff --git a/middleware/packages/harness-plugin-privacy-guard/data/privacy-topic-nouns-de.json b/middleware/packages/harness-plugin-privacy-guard/data/privacy-topic-nouns-de.json new file mode 100644 index 000000000..e29f6856f --- /dev/null +++ b/middleware/packages/harness-plugin-privacy-guard/data/privacy-topic-nouns-de.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": 1, + "language": "de", + "description": "Privacy-Shield v2 (Slice S-3) repo-default topic-nouns. German office-/HR-domain substantives that are easily false-positive-tagged as PII (PERSON / ORG / LOCATION) by spaCy NER on short inputs. Spans matching these terms are exempted from the detector pool — terms are NEVER tokenised and pass through to the LLM as plaintext. Erweitern via PR (alphabetisch sortiert, ein Begriff pro Eintrag).", + "terms": [ + "Abteilung", + "Abteilungen", + "Abwesenheit", + "Abwesenheiten", + "Abwesenheitstyp", + "Abwesenheitstypen", + "Arbeitszeit", + "Arbeitszeiten", + "Bereich", + "Bereiche", + "Betriebsfeier", + "Betriebsrente", + "Bonuszahlung", + "Dienstreise", + "External Service", + "Feedback-Gespräch", + "Fortbildung", + "Gehaltsabrechnung", + "Gleitzeit", + "Homeoffice", + "Kollege", + "Kollegen", + "Kollegin", + "Kolleginnen", + "Krankheit", + "Krankmeldung", + "Kündigung", + "Mitarbeitende", + "Mitarbeitenden", + "Mitarbeiter", + "Mitarbeitergespräch", + "Mitarbeiterin", + "Mitarbeiterinnen", + "Mittagspause", + "Offboarding", + "Onboarding", + "Paid Time Off", + "Personal", + "Personalabteilung", + "Pflegeversicherung", + "Probezeit", + "Reisekosten", + "Reisekostenabrechnung", + "Resturlaub", + "Sachbearbeiter", + "Schulung", + "Sick Leave", + "Sozialleistungen", + "Spesenabrechnung", + "Team", + "Teams", + "Urlaub", + "Urlaubsantrag", + "Urlaubsregeln", + "Vacation", + "Vermögenswirksame", + "Weiterbildung", + "Zeiterfassung", + "Überstunden" + ] +} diff --git a/middleware/packages/harness-plugin-privacy-guard/manifest.yaml b/middleware/packages/harness-plugin-privacy-guard/manifest.yaml index d0b4a1d5a..644080b06 100644 --- a/middleware/packages/harness-plugin-privacy-guard/manifest.yaml +++ b/middleware/packages/harness-plugin-privacy-guard/manifest.yaml @@ -9,8 +9,8 @@ identity: description: "Privacy-Proxy core plugin. Publishes the `privacy.redact@1` capability with a regex-based PII detector and a per-turn tokenise-map for reversible redaction. Emits a PII-free PrivacyReceipt the channel renderers (Teams Adaptive-Card, Web inline disclosure) consume to surface what was protected on each turn." authors: - name: "byte5 GmbH" - email: "dev@byte5.de" - url: "https://byte5.de" + email: "info@omadia.ai" + url: "https://omadia.ai" license: "MIT" categories: - "privacy" diff --git a/middleware/packages/harness-plugin-privacy-guard/src/allowlist.ts b/middleware/packages/harness-plugin-privacy-guard/src/allowlist.ts new file mode 100644 index 000000000..aeaf72adc --- /dev/null +++ b/middleware/packages/harness-plugin-privacy-guard/src/allowlist.ts @@ -0,0 +1,167 @@ +/** + * Pre-detector allowlist (Privacy-Shield v2, Slice S-3). + * + * Filters known-harmless spans out of the detector pipeline so they + * never become tokens. Three sources contribute terms: + * + * - **tenantSelf** — names, aliases, GF, address, domain, HRB-Nr. + * of the tenant itself (e.g. `byte5`, `byte5.de`, `byte5 GmbH`). + * Tokenising these on the wire to the public LLM is a category + * error: the tenant identity is by-construction known to the + * contract; masking it earns no privacy but loses the primary + * referent of every conversation. + * + * - **repoDefault** — German office-/HR-domain topic-nouns shipped + * in `data/privacy-topic-nouns-de.json` (Urlaubsregeln, + * Arbeitszeiten, Reisekostenabrechnung, …). German compound nouns + * are systematically false-positive-tagged as PERSON / ORG / + * LOCATION by spaCy NER on short prompts; the allowlist removes + * the dominant FP class. + * + * - **operatorOverride** — per-tenant additions via plugin config + * `extra_allowlist_terms` (JSON array). Lets operators extend the + * defaults for their domain (legal terminology, medical terms, + * internal project codenames) without forking the repo. + * + * Mechanics — span-filter, not text-mask: + * + * - The allowlist scans the input text once per `transformOne` call + * and returns spans + sources. + * - The detector pool runs on the unmodified text (so coverage, + * latency and audit-hash semantics are unchanged). + * - After detection, detector hits whose span overlaps an allowlist + * span are dropped before policy applies. The receipt records + * the allowlist counts so the operator sees "X terms passed + * through" alongside the maskings. + * + * Privacy property: the allowlist is purely additive in the "pass + * through" direction — it never expands what gets tokenised, only + * what gets exempted. A misconfigured allowlist therefore degrades to + * "more PII reaches the LLM" (which the operator OPTED INTO when they + * added the term), never "PII falsely surfaced to the wrong actor". + */ + +export type AllowlistSource = 'tenantSelf' | 'repoDefault' | 'operatorOverride'; + +export interface AllowlistMatch { + /** Half-open [start, end) byte offsets in the scanned text. */ + readonly span: readonly [number, number]; + /** Which configured source contributed the matched term. Surfaced + * in the receipt's `allowlist.bySource` breakdown. */ + readonly source: AllowlistSource; +} + +export interface Allowlist { + /** Walk `text` once and return every allowlist hit in left-to-right + * order. Returns `[]` on empty input or when no terms are + * configured — the caller can branch on `length === 0` to skip + * the filter step entirely. */ + scan(text: string): readonly AllowlistMatch[]; +} + +export interface AllowlistConfig { + readonly tenantSelfTerms?: readonly string[]; + readonly repoDefaultTerms?: readonly string[]; + readonly operatorOverrideTerms?: readonly string[]; +} + +const EMPTY_ALLOWLIST: Allowlist = { scan: () => [] }; + +/** + * Build an allowlist from the three configured sources. + * + * Terms are normalised to lowercase for matching (case-insensitive + * comparison against the input). Empty / whitespace-only terms are + * silently dropped. If the same term appears in multiple sources, + * the priority is `tenantSelf` > `operatorOverride` > `repoDefault` — + * the most specific source wins so the receipt attribution is stable. + * + * Returns a no-op allowlist when all three sources are empty so the + * caller incurs no scan cost. + */ +export function createAllowlist(config: AllowlistConfig): Allowlist { + // Source priority: later writes override earlier (we insert from + // lowest to highest priority). + const lookup = new Map<string, AllowlistSource>(); + addTerms(lookup, config.repoDefaultTerms, 'repoDefault'); + addTerms(lookup, config.operatorOverrideTerms, 'operatorOverride'); + addTerms(lookup, config.tenantSelfTerms, 'tenantSelf'); + + if (lookup.size === 0) return EMPTY_ALLOWLIST; + + // Longest-first alternation: regex picks the first matching + // alternative without backtracking, so sorting by descending length + // guarantees that "Urlaubsregeln" wins over "Urlaub" when both are + // configured. + const sortedKeys = [...lookup.keys()].sort((a, b) => b.length - a.length); + const pattern = sortedKeys.map(escapeRegex).join('|'); + // Word boundaries via Unicode-aware lookbehind/lookahead so German + // umlauts and ß count as word chars. JavaScript `\b` is ASCII-only + // and would split incorrectly on "Übersicht" etc. + const boundary = '[^\\p{L}\\p{N}]'; + const re = new RegExp( + `(?<=^|${boundary})(?:${pattern})(?=$|${boundary})`, + 'giu', + ); + + return { + scan(text: string): readonly AllowlistMatch[] { + if (text.length === 0) return []; + const matches: AllowlistMatch[] = []; + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + const source = lookup.get(m[0].toLowerCase()); + if (source !== undefined) { + matches.push({ span: [m.index, m.index + m[0].length], source }); + } + // Guard against zero-width-match infinite loops (cannot happen + // with the current pattern, but defensive coding here is cheap). + if (m.index === re.lastIndex) re.lastIndex += 1; + } + return matches; + }, + }; +} + +/** + * Drop any detector hit whose `[start, end)` span overlaps at least + * one allowlist span. Half-open overlap test: hits [a,b) and + * allowlist [c,d) overlap iff `a < d && c < b`. + * + * Used by `transformOne` to filter the detector pool's output before + * policy decisions are taken. The allowlist span list is small + * (typically <50 spans per turn even on a long memory recall) so a + * naive O(hits × allowlist) check is fine; no sweep needed. + */ +export function filterHitsByAllowlist<T extends { readonly span: readonly [number, number] }>( + hits: readonly T[], + allowlistMatches: readonly AllowlistMatch[], +): readonly T[] { + if (hits.length === 0 || allowlistMatches.length === 0) return hits; + return hits.filter((hit) => { + const [hs, he] = hit.span; + return !allowlistMatches.some((a) => a.span[0] < he && hs < a.span[1]); + }); +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +function addTerms( + lookup: Map<string, AllowlistSource>, + terms: readonly string[] | undefined, + source: AllowlistSource, +): void { + if (terms === undefined) return; + for (const raw of terms) { + const trimmed = raw.trim(); + if (trimmed.length === 0) continue; + lookup.set(trimmed.toLowerCase(), source); + } +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/middleware/packages/harness-plugin-privacy-guard/src/egressFilter.ts b/middleware/packages/harness-plugin-privacy-guard/src/egressFilter.ts new file mode 100644 index 000000000..bf4ef5301 --- /dev/null +++ b/middleware/packages/harness-plugin-privacy-guard/src/egressFilter.ts @@ -0,0 +1,343 @@ +/** + * Privacy-Shield v2 — Slice S-6 — Egress Filter. + * + * Pure helper. The service hosts the per-turn state (token-map + + * accumulator); this module knows how to walk an array of text slots, + * fan out the detector pool, distinguish restored PII (already in + * the turn-map) from spontaneous PII (LLM hallucination or memory + * leak via verbose tool output), and apply the operator-configured + * reaction. + * + * The egress filter is the LAST detector run before the channel + * plugin receives the final answer. Where `processOutbound` protects + * outgoing prompts to the public LLM, the egress filter protects the + * user — and the audit log — from anything the LLM produced that the + * shield could not anticipate. Concrete failure modes it catches: + * + * - Hallucinated identities. The LLM emits a plausible name in a + * table cell instead of the `«PERSON_N»` placeholder it should + * have kept verbatim (HR-routine bug, 2026-05-14). Output + * Validator catches paraphrase as a metric; the egress filter + * catches the concrete value and can mask it inline. + * - Memory-leak via verbose tool result. A tool handler returned + * PII in a `metadata.debug_summary` block we never tokenised; + * the egress filter re-scans the final assembled answer and + * redacts what slipped through. + */ + +import type { + PrivacyDetector, + PrivacyDetectorHit, + PrivacyDetectorOutcome, + PrivacyDetectorRun, + PrivacyDetectorStatus, + PrivacyEgressMode, + PrivacyEgressRequest, + PrivacyEgressResult, + PrivacyEgressRouting, + PrivacyEgressTextResult, +} from '@omadia/plugin-api'; + +import type { TokenizeMap } from './tokenizeMap.js'; +import { extendHitsToWordBoundary } from './spanHelpers.js'; +import { filterHitsByAllowlist, type Allowlist } from './allowlist.js'; + +export interface EgressFilterDeps { + readonly detectors: readonly PrivacyDetector[]; + readonly map: TokenizeMap; + readonly defaultMode: PrivacyEgressMode; + /** + * Privacy-Shield v2 (post-deploy 2026-05-14) — the inbound pipeline + * (`transformOne`) suppresses detector hits that overlap an allowlist + * span; egress must do the same or it re-detects what inbound let + * through. Asymmetric semantics caused the `Kr«ADDRESS_9»` regression + * on German section headers ("Krankheit" → ADDRESS via Presidio NER): + * inbound dropped the hit via the repo-default topic-nouns, egress + * re-fired and masked the same span. Same `Allowlist` instance the + * service holds — built once per `activate` from the configured + * tenantSelf + repoDefault + operatorOverride sources. + */ + readonly allowlist: Allowlist; +} + +interface DetectorRunAccum { + readonly detector: string; + status: PrivacyDetectorStatus; + callCount: number; + hitCount: number; + latencyMs: number; + reason: string | undefined; +} + +/** Severity rank shared with the main service. `error > timeout > skipped > ok`. */ +function statusRank(s: PrivacyDetectorStatus): number { + switch (s) { + case 'ok': + return 0; + case 'skipped': + return 1; + case 'timeout': + return 2; + case 'error': + return 3; + } +} + +/** + * Run every registered detector against `text`, capture per-detector + * status / latency / hit-count into `runs`, and return the union of + * hits. Thrown exceptions are caught and surfaced as `status: 'error'` + * outcomes — never re-thrown. Mirrors `service.runDetectors` but with + * an egress-local run accumulator (`runs` map) so the receipt sees + * the egress pass as a separate audit-line and the orchestrator's + * outbound detector buckets do not get double-counted. + */ +async function runDetectorsForEgress( + text: string, + detectors: readonly PrivacyDetector[], + runs: Map<string, DetectorRunAccum>, +): Promise<PrivacyDetectorHit[]> { + if (detectors.length === 0 || text.length === 0) return []; + const results = await Promise.all( + detectors.map(async (d) => { + let bucket = runs.get(d.id); + if (bucket === undefined) { + bucket = { + detector: d.id, + status: 'ok', + callCount: 0, + hitCount: 0, + latencyMs: 0, + reason: undefined, + }; + runs.set(d.id, bucket); + } + const t0 = Date.now(); + let outcome: PrivacyDetectorOutcome; + try { + outcome = await d.detect(text); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn( + `[privacy-guard] egress detector '${d.id}' threw, treating as zero hits:`, + message, + ); + outcome = { hits: [], status: 'error', reason: message.slice(0, 80) }; + } + const elapsed = Date.now() - t0; + if (statusRank(outcome.status) > statusRank(bucket.status)) { + bucket.status = outcome.status; + bucket.reason = outcome.reason; + } + bucket.callCount += 1; + bucket.hitCount += outcome.hits.length; + bucket.latencyMs += elapsed; + return [...outcome.hits]; + }), + ); + // Same word-boundary extension as the main service.runDetectors — + // absorbs the 1-char tail Presidio clips off German names so the + // egress mask covers the entire surface form, not "«PERSON_N»t". + return extendHitsToWordBoundary(text, results.flat()) as PrivacyDetectorHit[]; +} + +/** + * Span-overlap dedup, copied from the service. Identical strategy: keep + * the highest-confidence hit; shorter / more-specific wins ties. Output + * is sorted ascending by span-start so callers can replace right-to-left. + */ +function dedupOverlappingHits( + hits: readonly PrivacyDetectorHit[], +): readonly PrivacyDetectorHit[] { + if (hits.length <= 1) return hits; + const ranked = [...hits].sort((a, b) => { + if (a.confidence !== b.confidence) return b.confidence - a.confidence; + const lenA = a.span[1] - a.span[0]; + const lenB = b.span[1] - b.span[0]; + return lenA - lenB; + }); + const kept: PrivacyDetectorHit[] = []; + for (const hit of ranked) { + const [hs, he] = hit.span; + const conflicts = kept.some((k) => k.span[0] < he && hs < k.span[1]); + if (!conflicts) kept.push(hit); + } + kept.sort((a, b) => a.span[0] - b.span[0]); + return kept; +} + +/** + * Main entry. Walks `request.texts`, returns a transformed copy plus + * the aggregate counters needed by the receipt. The caller is + * responsible for plumbing `result` into the turn accumulator. + */ +export async function runEgressFilter( + request: PrivacyEgressRequest, + deps: EgressFilterDeps, +): Promise<PrivacyEgressResult> { + const mode: PrivacyEgressMode = request.mode ?? deps.defaultMode; + const runs = new Map<string, DetectorRunAccum>(); + const detectorSnapshot: readonly PrivacyDetector[] = [...deps.detectors]; + + // Pre-touch every detector bucket so the receipt always lists the + // full active-detector set, even when none of them fired on the + // egress text. Mirrors the `processOutbound` contract — 0 hits is + // semantic ("scanned, found nothing"), not absence. + for (const d of detectorSnapshot) { + if (!runs.has(d.id)) { + runs.set(d.id, { + detector: d.id, + status: 'ok', + callCount: 0, + hitCount: 0, + latencyMs: 0, + reason: undefined, + }); + } + } + + const perSlot: PrivacyEgressTextResult[] = []; + let totalSpontaneous = 0; + let totalMasked = 0; + // `mask` upgrades routing to `masked` once at least one span was + // rewritten. `block` upgrades routing to `blocked` on the first + // spontaneous hit and short-circuits further rewriting (the host + // will replace the whole payload anyway). `mark` leaves routing + // at `allow` regardless of how many spans fired. + let blocked = false; + let anyMasked = false; + + for (const slot of request.texts) { + if (blocked) { + // Already decided to block this turn — preserve original texts + // unchanged so the host's placeholder swap is a single point of + // truth. We still pass the slot through so the result array + // stays in lockstep with the request order. + perSlot.push({ + id: slot.id, + text: slot.text, + spontaneousHits: 0, + maskedCount: 0, + }); + continue; + } + const allHits = await runDetectorsForEgress(slot.text, detectorSnapshot, runs); + if (allHits.length === 0) { + perSlot.push({ + id: slot.id, + text: slot.text, + spontaneousHits: 0, + maskedCount: 0, + }); + continue; + } + // Drop hits that overlap an allowlist span BEFORE the turn-map + // check — otherwise the egress filter re-masks compound words like + // "Krankheit" that the inbound pipeline correctly let through. + // Mirrors `service.ts::transformOne` (Slice S-3). + const allowlistMatches = deps.allowlist.scan(slot.text); + const allowlistedHits = + allowlistMatches.length > 0 + ? filterHitsByAllowlist(allHits, allowlistMatches) + : allHits; + if (allowlistedHits.length === 0) { + perSlot.push({ + id: slot.id, + text: slot.text, + spontaneousHits: 0, + maskedCount: 0, + }); + continue; + } + const deduped = dedupOverlappingHits(allowlistedHits); + // A hit whose value is already in the turn-map is restored PII — + // the user typed it earlier and the shield put it back together + // on the inbound side. Anything else is spontaneous. + const spontaneous: PrivacyDetectorHit[] = []; + for (const hit of deduped) { + if (!deps.map.hasOriginalValue(hit.value)) { + spontaneous.push(hit); + } + } + if (spontaneous.length === 0) { + perSlot.push({ + id: slot.id, + text: slot.text, + spontaneousHits: 0, + maskedCount: 0, + }); + continue; + } + + totalSpontaneous += spontaneous.length; + + if (mode === 'mark') { + // Operator-only visibility — no text mutation. The receipt still + // records the hit so the audit log explains why a future + // turn might escalate to `mask`. + perSlot.push({ + id: slot.id, + text: slot.text, + spontaneousHits: spontaneous.length, + maskedCount: 0, + }); + continue; + } + + if (mode === 'block') { + // First spontaneous hit wins — short-circuit. The remaining + // slots are returned as-is below (the `blocked` early-exit + // branch handles them). + perSlot.push({ + id: slot.id, + text: slot.text, + spontaneousHits: spontaneous.length, + maskedCount: 0, + }); + blocked = true; + continue; + } + + // mode === 'mask': replace right-to-left so earlier spans stay valid. + const sorted = [...spontaneous].sort((a, b) => b.span[0] - a.span[0]); + let out = slot.text; + let masked = 0; + for (const hit of sorted) { + const token = deps.map.tokenFor(hit.value, hit.type); + out = out.slice(0, hit.span[0]) + token + out.slice(hit.span[1]); + masked += 1; + } + totalMasked += masked; + if (masked > 0) anyMasked = true; + perSlot.push({ + id: slot.id, + text: out, + spontaneousHits: spontaneous.length, + maskedCount: masked, + }); + } + + const routing: PrivacyEgressRouting = blocked + ? 'blocked' + : anyMasked + ? 'masked' + : 'allow'; + + const detectorRuns: PrivacyDetectorRun[] = [...runs.values()].map((b) => ({ + detector: b.detector, + status: b.status, + callCount: b.callCount, + hitCount: b.hitCount, + latencyMs: b.latencyMs, + ...(b.reason !== undefined ? { reason: b.reason } : {}), + })); + + return { + mode, + routing, + texts: perSlot, + detectorRuns, + spontaneousHits: totalSpontaneous, + maskedCount: totalMasked, + }; +} diff --git a/middleware/packages/harness-plugin-privacy-guard/src/index.ts b/middleware/packages/harness-plugin-privacy-guard/src/index.ts index a3c904569..5f16a6172 100644 --- a/middleware/packages/harness-plugin-privacy-guard/src/index.ts +++ b/middleware/packages/harness-plugin-privacy-guard/src/index.ts @@ -9,7 +9,10 @@ */ export { activate } from './plugin.js'; -export type { PrivacyGuardPluginHandle } from './plugin.js'; +export type { + EgressFilterPluginConfig, + PrivacyGuardPluginHandle, +} from './plugin.js'; export { createPrivacyGuardService } from './service.js'; export type { @@ -30,10 +33,21 @@ export { decide, deriveRouting, type PolicyDecision } from './policyEngine.js'; export { createTokenizeMap, + displayTypeFor, isToken, - sanitizeTypeHint, TOKEN_REGEX, type TokenizeMap, } from './tokenizeMap.js'; export { assembleReceipt, type AssembledHit, type AssembleInput } from './receiptAssembler.js'; + +export { runEgressFilter, type EgressFilterDeps } from './egressFilter.js'; + +export { + createAllowlist, + filterHitsByAllowlist, + type Allowlist, + type AllowlistConfig, + type AllowlistMatch, + type AllowlistSource, +} from './allowlist.js'; diff --git a/middleware/packages/harness-plugin-privacy-guard/src/plugin.ts b/middleware/packages/harness-plugin-privacy-guard/src/plugin.ts index 8c6b1c19e..404ed65ad 100644 --- a/middleware/packages/harness-plugin-privacy-guard/src/plugin.ts +++ b/middleware/packages/harness-plugin-privacy-guard/src/plugin.ts @@ -1,3 +1,7 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import * as path from 'node:path'; + import { PRIVACY_DETECTOR_REGISTRY_SERVICE_NAME, PRIVACY_REDACT_SERVICE_NAME, @@ -5,34 +9,75 @@ import { type PolicyMode, type PrivacyDetector, type PrivacyDetectorRegistry, + type PrivacyEgressMode, type PrivacyGuardService, } from '@omadia/plugin-api'; import { createPrivacyGuardService } from './service.js'; +import type { AllowlistConfig } from './allowlist.js'; /** * @omadia/plugin-privacy-guard — plugin entry point. * * Activation wiring: - * 1. Read `policy_mode` + `fail_open` from `ctx.config`. - * 2. Build the {@link PrivacyGuardService} via the pure factory. - * 3. Publish it as `privacyRedact` (capability `privacy.redact@1`) - * for the orchestrator hook to consume in Slice 2. - * 4. Slice 3.1: also publish a thin `PrivacyDetectorRegistry` facade + * 1. Read `policy_mode`, `fail_open`, `debug_show_values` from `ctx.config`. + * 2. Privacy-Shield v2 (Slice S-3): assemble the pre-detector + * allowlist from the bundled repo-default JSON + two plugin- + * config term arrays (`tenant_self_terms`, `extra_allowlist_terms`). + * 3. Build the {@link PrivacyGuardService} via the pure factory. + * 4. Publish it as `privacyRedact` (capability `privacy.redact@1`) + * for the orchestrator hook. + * 5. Slice 3.1: also publish a thin `PrivacyDetectorRegistry` facade * under `privacyDetectorRegistry` so add-on detector plugins - * (Slice 3.2 Ollama, Slice 3.4 Presidio) can register at activate - * time without forking this plugin. + * (Ollama, Presidio) can register at activate time without + * forking this plugin. * - * Stateless across activations — every `processOutbound` call mints - * its own per-turn tokenise-map; the service does not cache anything - * between turns. Slice 2 will introduce a conversation-scoped registry - * keyed by session id. + * Per-turn state: every turn gets its own tokenise-map; the service + * discards both the map and the receipt accumulator at `finalizeTurn` + * (Privacy-Shield v2 / Slice S-2). No cross-turn persistence. */ export interface PrivacyGuardPluginHandle { close(): Promise<void>; + /** Privacy-Shield v2 (Slice S-6) — operator-tunable egress filter + * configuration, resolved at activate time. The host (orchestrator + * + routine runner) reads this to decide whether to call the + * service's `egressFilter` method and what placeholder to swap in + * on a `blocked` routing. Surfaced here so a half-configured + * plugin reveals its effective state without re-parsing config. */ + readonly egressConfig: EgressFilterPluginConfig; } +/** + * Privacy-Shield v2 (Slice S-6) — egress-filter plugin config. Reads + * three operator-tunable keys with safe defaults: + * + * - `egress_filter_enabled` (default `true`): master switch read + * by the host. The plugin itself always exposes the + * `egressFilter` method; the host decides whether to call it. + * This config is surfaced on the plugin handle so the host can + * short-circuit cleanly without poking at internal state. + * - `egress_filter_mode` (default `'mask'`): reaction mode for + * spontaneous PII detected at egress time. `mark` records on the + * receipt only; `mask` rewrites the spans inline; `block` returns + * a `blocked` routing so the host swaps the payload for a + * placeholder. + * - `egress_block_placeholder_text` (default localised English): + * channel-agnostic placeholder the host substitutes when + * `egress_filter_mode === 'block'` and the routing comes back as + * `blocked`. The plugin does not perform the swap itself — that + * belongs to the integration boundary (orchestrator + routine + * runner). + */ +export interface EgressFilterPluginConfig { + readonly enabled: boolean; + readonly mode: PrivacyEgressMode; + readonly placeholderText: string; +} + +const DEFAULT_EGRESS_PLACEHOLDER = + 'The response was withheld because it contained data the privacy filter could not verify. Please rephrase your request.'; + export async function activate( ctx: PluginContext, ): Promise<PrivacyGuardPluginHandle> { @@ -47,9 +92,35 @@ export async function activate( // raw matched values. Default off — receipts stay PII-free. const debugShowValues = readDebugShowValues(ctx); + // Privacy-Shield v2 (Slice S-3) — assemble the three allowlist + // sources at activate time. Repo-default ships in the package; the + // tenant-self and operator-override come from plugin config. + const allowlist = await loadAllowlistConfig(ctx); + + // Privacy-Shield v2 (Slice S-6) — egress filter config. The plugin + // surfaces the resolved values on its handle so the host can wire + // them without re-parsing config. + const egressConfig: EgressFilterPluginConfig = { + enabled: readEgressEnabled(ctx), + mode: readEgressMode(ctx), + placeholderText: readEgressPlaceholder(ctx), + }; + + // Privacy-Shield v2 (D-1) — Output Validator's token-loss threshold. + // Below the threshold, the validator emits `recommendation: retry`; + // above, it escalates to `block` (when combined with spontaneous-PII + // hits). Surfaced as plugin config so operators can tune the + // sensitivity per tenant without a code change. + const tokenLossThreshold = readTokenLossThreshold(ctx); + const service = createPrivacyGuardService({ defaultPolicyMode: policyMode, debugShowValues, + allowlist, + egressFilterMode: egressConfig.mode, + egressFilterEnabled: egressConfig.enabled, + egressBlockPlaceholderText: egressConfig.placeholderText, + ...(tokenLossThreshold !== undefined ? { tokenLossThreshold } : {}), }); const disposeService = ctx.services.provide<PrivacyGuardService>( @@ -70,13 +141,23 @@ export async function activate( registry, ); + const allowlistTotals = + (allowlist.tenantSelfTerms?.length ?? 0) + + (allowlist.repoDefaultTerms?.length ?? 0) + + (allowlist.operatorOverrideTerms?.length ?? 0); ctx.log( `[privacy-guard] ready (policy_mode=${policyMode}, fail_open=${failOpen}, ` + `debug_show_values=${debugShowValues ? 'on' : 'off'}, ` + `detectors=${service .listDetectors() .map((d) => d.id) - .join(',')})`, + .join(',')}, ` + + `allowlist=${String(allowlistTotals)} terms ` + + `(tenant=${String(allowlist.tenantSelfTerms?.length ?? 0)} ` + + `repo=${String(allowlist.repoDefaultTerms?.length ?? 0)} ` + + `override=${String(allowlist.operatorOverrideTerms?.length ?? 0)}), ` + + `egress=${egressConfig.enabled ? `on/${egressConfig.mode}` : 'off'}, ` + + `token_loss_threshold=${tokenLossThreshold === undefined ? 'default' : tokenLossThreshold.toFixed(2)})`, ); if (debugShowValues) { ctx.log( @@ -93,6 +174,7 @@ export async function activate( disposeRegistry(); disposeService(); }, + egressConfig, }; } @@ -122,3 +204,195 @@ function readDebugShowValues(ctx: PluginContext): boolean { const trimmed = raw.trim().toLowerCase(); return trimmed === 'on' || trimmed === 'true' || trimmed === 'yes' || trimmed === '1'; } + +// Privacy-Shield v2 (Slice S-6) — egress filter config readers. + +function readEgressEnabled(ctx: PluginContext): boolean { + const raw = ctx.config.get<unknown>('egress_filter_enabled'); + // Default on: omitting the key keeps the safer behaviour. + if (raw === undefined || raw === null) return true; + if (typeof raw === 'boolean') return raw; + if (typeof raw === 'string') { + const trimmed = raw.trim().toLowerCase(); + if (trimmed === '' || trimmed === 'true' || trimmed === '1' || trimmed === 'on' || trimmed === 'yes') { + return true; + } + if (trimmed === 'false' || trimmed === '0' || trimmed === 'off' || trimmed === 'no') { + return false; + } + } + ctx.log("[privacy-guard] config 'egress_filter_enabled' has unsupported value; defaulting to enabled"); + return true; +} + +function readEgressMode(ctx: PluginContext): PrivacyEgressMode { + const raw = ctx.config.get<unknown>('egress_filter_mode'); + if (typeof raw !== 'string') return 'mask'; + const trimmed = raw.trim().toLowerCase(); + if (trimmed === 'mark' || trimmed === 'mask' || trimmed === 'block') { + return trimmed; + } + ctx.log( + `[privacy-guard] config 'egress_filter_mode' has unsupported value '${raw}'; defaulting to 'mask'`, + ); + return 'mask'; +} + +function readEgressPlaceholder(ctx: PluginContext): string { + const raw = ctx.config.get<unknown>('egress_block_placeholder_text'); + if (typeof raw !== 'string') return DEFAULT_EGRESS_PLACEHOLDER; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : DEFAULT_EGRESS_PLACEHOLDER; +} + +// Privacy-Shield v2 (D-1) — Output Validator token-loss threshold reader. +// Accepts a float in [0, 1]; out-of-range or unparseable values fall back +// to the service-internal default (0.3). Returns undefined when the +// operator did not set the key so the service keeps using its own default. +function readTokenLossThreshold(ctx: PluginContext): number | undefined { + const raw = ctx.config.get<unknown>('token_loss_threshold'); + if (raw === undefined || raw === null || raw === '') return undefined; + let value: number; + if (typeof raw === 'number') { + value = raw; + } else if (typeof raw === 'string') { + const parsed = Number(raw.trim()); + if (!Number.isFinite(parsed)) { + ctx.log( + `[privacy-guard] config 'token_loss_threshold' is not a valid number ('${raw}'); using service default`, + ); + return undefined; + } + value = parsed; + } else { + ctx.log( + "[privacy-guard] config 'token_loss_threshold' has unsupported type; using service default", + ); + return undefined; + } + if (value < 0 || value > 1) { + ctx.log( + `[privacy-guard] config 'token_loss_threshold' out of range [0,1] (got ${String(value)}); using service default`, + ); + return undefined; + } + return value; +} + +// --------------------------------------------------------------------------- +// Privacy-Shield v2 (Slice S-3) — allowlist assembly. +// +// Reads the bundled repo-default JSON and the two operator-supplied +// term lists from plugin config: +// +// - `tenant_self_terms` — populated by the host from the +// operator profile (tenant.name, +// aliases, gf_namen, address, +// domain, hrb_nr). The plugin does +// not query the operator-profile +// service directly; the host is +// responsible for wiring this in +// on plugin activate. +// - `extra_allowlist_terms` — free-form additions by the +// operator via plugin setup UI. +// +// Both are accepted as JSON-array-of-strings. Malformed inputs fall +// back to an empty list and log a warning so the plugin still boots. +// --------------------------------------------------------------------------- + +async function loadAllowlistConfig(ctx: PluginContext): Promise<AllowlistConfig> { + const repoDefaultTerms = await loadRepoDefaultTerms(ctx); + const tenantSelfTerms = readStringArray(ctx, 'tenant_self_terms'); + const operatorOverrideTerms = readStringArray(ctx, 'extra_allowlist_terms'); + return { tenantSelfTerms, repoDefaultTerms, operatorOverrideTerms }; +} + +async function loadRepoDefaultTerms(ctx: PluginContext): Promise<readonly string[]> { + // Two repo-shipped categories merge into the `repoDefault` source: + // - topic-nouns: HR/office compound nouns FP-tagged by spaCy + // - common-words: greetings + affirmations + casual filler + // Loading both is best-effort: a missing file logs once and the + // allowlist runs with whatever did load. Returning [] for both is + // a degraded-but-functional path. + const [topicNouns, commonWords] = await Promise.all([ + loadTermsFile(ctx, 'privacy-topic-nouns-de.json'), + loadTermsFile(ctx, 'privacy-common-words-de.json'), + ]); + return [...topicNouns, ...commonWords]; +} + +async function loadTermsFile( + ctx: PluginContext, + filename: string, +): Promise<readonly string[]> { + try { + // Resolve the JSON path relative to the compiled plugin module so + // it works in both `dist/` (production) and `src/` (dev / tsx). + const here = path.dirname(fileURLToPath(import.meta.url)); + // From `dist/` we step up to the package root and down to data/. + // The same relative path works from `src/` too because the data + // folder sits alongside both. + const candidates = [ + path.resolve(here, '..', 'data', filename), + path.resolve(here, '..', '..', 'data', filename), + ]; + for (const p of candidates) { + try { + const raw = await readFile(p, 'utf8'); + const parsed: unknown = JSON.parse(raw); + if ( + parsed !== null && + typeof parsed === 'object' && + 'terms' in parsed && + Array.isArray((parsed as { terms: unknown }).terms) + ) { + const terms = (parsed as { terms: unknown[] }).terms.filter( + (t): t is string => typeof t === 'string', + ); + return terms; + } + } catch { + // Try the next candidate. + } + } + ctx.log( + `[privacy-guard] ${filename} not found in package data/ — allowlist runs without those repo defaults`, + ); + return []; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ctx.log(`[privacy-guard] failed to load ${filename}: ${msg}`); + return []; + } +} + +function readStringArray(ctx: PluginContext, key: string): readonly string[] { + const raw = ctx.config.get<unknown>(key); + if (raw === undefined || raw === null) return []; + if (Array.isArray(raw)) { + return raw.filter((x): x is string => typeof x === 'string'); + } + if (typeof raw === 'string') { + const trimmed = raw.trim(); + if (trimmed.length === 0) return []; + // Accept JSON-encoded arrays or comma-separated bare strings. + if (trimmed.startsWith('[')) { + try { + const parsed: unknown = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed.filter((x): x is string => typeof x === 'string'); + } + } catch { + ctx.log( + `[privacy-guard] config '${key}' is a string but not valid JSON; falling back to comma-split`, + ); + } + } + return trimmed + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + } + ctx.log(`[privacy-guard] config '${key}' has unsupported type, ignoring`); + return []; +} diff --git a/middleware/packages/harness-plugin-privacy-guard/src/receiptAssembler.ts b/middleware/packages/harness-plugin-privacy-guard/src/receiptAssembler.ts index b8616f5d0..8e5480572 100644 --- a/middleware/packages/harness-plugin-privacy-guard/src/receiptAssembler.ts +++ b/middleware/packages/harness-plugin-privacy-guard/src/receiptAssembler.ts @@ -24,6 +24,8 @@ import type { PolicyMode, PrivacyDetection, PrivacyDetectorRun, + PrivacyEgressMode, + PrivacyEgressRouting, PrivacyReceipt, Routing, } from '@omadia/plugin-api'; @@ -77,6 +79,53 @@ export interface AssembleInput { readonly resultsTokenized: number; readonly callCount: number; }; + /** Privacy-Shield v2 (Slice S-3) — per-source allowlist hit counts. + * Pass-through to `receipt.allowlist` when at least one source + * fired; omit when nothing matched so the receipt shape stays + * identical to v1.x for plain turns. PII-free: counts only. */ + readonly allowlist?: { + readonly hitCount: number; + readonly bySource: { + readonly tenantSelf: number; + readonly repoDefault: number; + readonly operatorOverride: number; + }; + }; + /** Privacy-Shield v2 (Slice S-5) — pass-through for the Output + * Validator summary. Pre-aggregated by the service: the assembler + * copies it onto the receipt as-is. PII-free (counts only). */ + readonly output?: { + readonly tokenLossRatio: number; + readonly spontaneousPiiHits: number; + readonly recommendation: 'pass' | 'retry' | 'block'; + readonly recommendationReason?: string; + }; + /** Privacy-Shield v2 (Slice S-6) — pass-through for the Egress + * Filter summary. Pre-aggregated by the service; assembler copies + * onto the receipt as-is. PII-free. */ + readonly egress?: { + readonly mode: PrivacyEgressMode; + readonly routing: PrivacyEgressRouting; + readonly detectorRuns: readonly PrivacyDetectorRun[]; + readonly spontaneousHits: number; + readonly maskedCount: number; + }; + /** Privacy-Shield v2 (Phase A, post-deploy 2026-05-14) — + * pass-through for the self-anonymization restoration summary. */ + readonly selfAnonymization?: { + readonly detected: number; + readonly restored: number; + readonly ambiguous: number; + readonly patternsHit: readonly string[]; + readonly maxIndexSeen: number; + readonly tokenOrderLength: number; + }; + /** Privacy-Shield v2 (Phase A.2) — pass-through for the + * post-egress final-scrub summary. */ + readonly postEgressScrub?: { + readonly restoredPositional: number; + readonly scrubbedToPlaceholder: number; + }; } export function assembleReceipt(input: AssembleInput): PrivacyReceipt { @@ -95,6 +144,17 @@ export function assembleReceipt(input: AssembleInput): PrivacyReceipt { ...(input.toolRoundtrip !== undefined && input.toolRoundtrip.callCount > 0 ? { toolRoundtrip: input.toolRoundtrip } : {}), + ...(input.allowlist !== undefined && input.allowlist.hitCount > 0 + ? { allowlist: input.allowlist } + : {}), + ...(input.output !== undefined ? { output: input.output } : {}), + ...(input.egress !== undefined ? { egress: input.egress } : {}), + ...(input.selfAnonymization !== undefined + ? { selfAnonymization: input.selfAnonymization } + : {}), + ...(input.postEgressScrub !== undefined + ? { postEgressScrub: input.postEgressScrub } + : {}), }; } diff --git a/middleware/packages/harness-plugin-privacy-guard/src/selfAnonymization.ts b/middleware/packages/harness-plugin-privacy-guard/src/selfAnonymization.ts new file mode 100644 index 000000000..0fefb5b28 --- /dev/null +++ b/middleware/packages/harness-plugin-privacy-guard/src/selfAnonymization.ts @@ -0,0 +1,621 @@ +/** + * Privacy-Shield v2 (Phase A, post-deploy 2026-05-14) — mechanical + * restoration of LLM self-anonymization patterns. + * + * The directive (S-1, S-4) instructs the LLM to emit `«PERSON_N»` + * tokens verbatim in tables and lists so `processInbound` restores + * them to the real names. In practice the LLM still substitutes + * generic labels like "Mitarbeiter 1 / 2 / 3" or "Employee A / B / C" + * in HR-style tabular output — strong privacy-related policy bias + * overrides the system-prompt rule. Hardening the directive helps + * but never deterministically. + * + * This module closes the gap mechanically. It scans the assistant + * output for recognised self-anonymization patterns, derives a + * positional index from each match (`Mitarbeiter 3` → index 3), and + * substitutes the corresponding real name from a positional + * person-token list captured during the most recent tool-result + * tokenisation. The result is a deterministic safety net that does + * not depend on LLM cooperation. + * + * Important design choices: + * + * - **Conservative substitution.** When the count of distinct + * labels in the text exceeds the positional token list, NO + * substitution is performed. Restoring a few and leaving the + * rest as labels would corrupt the table's row-to-name mapping; + * better to surface the issue (operators see the unresolved + * labels in the receipt) than to ship a wrong restoration. + * + * - **Positional source = tool-result token-order**, not the + * turn-map's global mint order. The LLM's "Mitarbeiter 1" refers + * to the first person-row in the tool result, not the first + * person ever tokenised in the turn (the user may have mentioned + * a name earlier; that mint comes before the tool-result mints + * in the global ordering). The caller must supply + * `personTokenOrder` from the latest `processToolResult` scan. + * + * - **Type-restricted to PERSON.** Other PII types (EMAIL, IBAN, + * ADDRESS, …) do not show this self-anonymization pattern. If + * they ever do, a separate handler would extend the pattern + * set; this module keeps the contract narrow on purpose. + * + * - **Per-label semantics: same label → same restoration.** If + * the LLM emits "Mitarbeiter 1" twice in the same answer (a + * summary plus a table cell), both occurrences resolve to the + * same real name. Restoration is keyed on the label INDEX, not + * on textual position, so the substitution stays coherent across + * repeats within one answer. + * + * Pattern set (extensible — operators can add more via + * `extra_self_anonymization_patterns` plugin config in a later slice): + * + * - `Mitarbeiter\s+\d+` — German default, the live failure mode + * - `Mitarbeiterin\s+\d+` — German female form + * - `Kollege\s+\d+` — German colleague variant + * - `Kollegin\s+\d+` — German female colleague variant + * - `Employee\s+\d+` — English form + * - `Person\s+\d+` — Language-neutral form + * - `Anonym\s+\d+` — German "Anonym 1/2/3" pattern + * + * Numbered-letter variants (`Mitarbeiter A/B/C`, `Person A/B/C`) are + * intentionally NOT covered in this version — they are observed less + * often, and supporting them requires a different ordinal mapping + * (A → 1, B → 2, …) that is easier to bolt on once we have telemetry + * showing they occur. + */ + +import type { TokenizeMap } from './tokenizeMap.js'; + +/** + * Half-open `[start, end)` byte offsets inside the scanned text plus + * the parsed label keyword and 1-based ordinal. Exposed for tests and + * for the receipt telemetry that wants to surface which patterns + * fired. + */ +export interface SelfAnonymizationMatch { + readonly span: readonly [number, number]; + /** Raw matched substring, e.g. `"Mitarbeiter 1"`. */ + readonly raw: string; + /** Keyword stem (`"Mitarbeiter"`, `"Employee"`, …) — lower-case + * normalised so the caller can group counts by pattern type. */ + readonly keyword: string; + /** 1-based ordinal extracted from the match. `"Mitarbeiter 3"` → 3. */ + readonly index: number; +} + +export interface RestorationOutcome { + /** Transformed text. Equal to input when no substitution was + * performed (conservative-skip or zero matches). */ + readonly text: string; + /** Total distinct labels found in the input. */ + readonly detected: number; + /** Number of labels actually restored to real names. Zero when + * the conservative-skip rule triggered. */ + readonly restored: number; + /** Number of labels that could not be restored — either because + * the index exceeded `personTokenOrder.length`, or because the + * positional token did not resolve to a real value in the + * TokenizeMap. */ + readonly ambiguous: number; + /** Lower-case keyword stems that fired, deduplicated. Useful for + * the receipt block ("which language/style of self-anon hit?"). */ + readonly patternsHit: readonly string[]; + /** Highest 1-based ordinal observed across all matches. Lets the + * caller emit a one-line diagnostic ("4 labels, max index 4, 3 + * tokens available → skipped"). */ + readonly maxIndexSeen: number; +} + +/** + * Compiled pattern set. Each entry contributes its own keyword stem + * so the telemetry can attribute hits per pattern class. The shared + * regex form is `\b<keyword>\s+(\d+)\b` (word-boundary on both ends, + * `\d+` captured as the ordinal). `i` flag is intentionally omitted — + * the LLM emits these labels in normalised German/English casing + * ("Mitarbeiter", "Employee") and accepting lower-case would + * mass-match common nouns ("mitarbeiter dieses jahres") inside + * narrative prose. + */ +const PATTERNS: ReadonlyArray<{ readonly keyword: string; readonly regex: RegExp }> = [ + { keyword: 'mitarbeiter', regex: /\bMitarbeiter\s+(\d+)\b/g }, + { keyword: 'mitarbeiterin', regex: /\bMitarbeiterin\s+(\d+)\b/g }, + { keyword: 'kollege', regex: /\bKollege\s+(\d+)\b/g }, + { keyword: 'kollegin', regex: /\bKollegin\s+(\d+)\b/g }, + { keyword: 'employee', regex: /\bEmployee\s+(\d+)\b/g }, + { keyword: 'person', regex: /\bPerson\s+(\d+)\b/g }, + { keyword: 'anonym', regex: /\bAnonym\s+(\d+)\b/g }, +]; + +/** + * Walk `text` once per pattern, return every match left-to-right. + * Pure function — exported for unit-test inspection and for the + * service's audit logging (where the operator wants to see WHICH + * spans triggered). + * + * The same `(keyword, index)` pair may legitimately match more than + * once if the LLM repeats a label in body prose and a table cell; we + * return every occurrence so the substitution pass can rewrite all + * of them. The downstream restorer deduplicates on `(keyword, index)` + * when computing the "distinct labels" count. + */ +export function detectSelfAnonymizationLabels( + text: string, +): readonly SelfAnonymizationMatch[] { + if (text.length === 0) return []; + const out: SelfAnonymizationMatch[] = []; + for (const { keyword, regex } of PATTERNS) { + // Reset lastIndex on a copy — the module-level RegExp is `g`-flagged + // and would otherwise carry state between calls. + const local = new RegExp(regex.source, regex.flags); + let m: RegExpExecArray | null; + while ((m = local.exec(text)) !== null) { + const indexStr = m[1]; + if (indexStr === undefined) continue; + const parsed = Number.parseInt(indexStr, 10); + if (!Number.isFinite(parsed) || parsed <= 0) continue; + out.push({ + span: [m.index, m.index + m[0].length], + raw: m[0], + keyword, + index: parsed, + }); + } + } + // Sort left-to-right; ties broken by ascending end-offset (shorter + // spans first) — only relevant if two patterns ever overlap at the + // same start, which the current pattern set cannot. + out.sort((a, b) => { + if (a.span[0] !== b.span[0]) return a.span[0] - b.span[0]; + return a.span[1] - b.span[1]; + }); + return out; +} + +/** + * Main entry. Given the assistant text, the captured per-tool-result + * person-token order, and the live TokenizeMap, return a transformed + * text plus restoration stats. Conservative-skip behaviour when the + * positional list cannot cover the observed maxIndex. + * + * `personTokenOrder` is expected to be the de-duplicated, in-order + * sequence of `«PERSON_N»` tokens that appeared in the most recent + * tool-result text after tokenisation. Index 1 (1-based) of the + * LLM's "Mitarbeiter N" labels maps to `personTokenOrder[0]`, which + * the map then resolves to the real name. + */ +export function restoreSelfAnonymization( + text: string, + personTokenOrder: readonly string[], + map: TokenizeMap, +): RestorationOutcome { + const matches = detectSelfAnonymizationLabels(text); + if (matches.length === 0) { + return { + text, + detected: 0, + restored: 0, + ambiguous: 0, + patternsHit: [], + maxIndexSeen: 0, + }; + } + + // Distinct labels = unique (keyword, index) pairs. Useful for the + // "count-mismatch → skip" decision below. + const distinct = new Set<string>(); + let maxIndex = 0; + const patternSet = new Set<string>(); + for (const m of matches) { + distinct.add(`${m.keyword}/${String(m.index)}`); + if (m.index > maxIndex) maxIndex = m.index; + patternSet.add(m.keyword); + } + const detected = distinct.size; + const patternsHit = [...patternSet].sort(); + + // Conservative skip: if the highest observed index exceeds the + // available positional tokens, restoration would be partial and + // could misalign row-to-name mappings in tables. Return the text + // unchanged and surface the gap via the receipt. + if (personTokenOrder.length === 0 || maxIndex > personTokenOrder.length) { + return { + text, + detected, + restored: 0, + ambiguous: detected, + patternsHit, + maxIndexSeen: maxIndex, + }; + } + + // Build the per-(keyword, index) replacement once so repeated + // occurrences of the same label in the answer body all resolve to + // the same real name. Skip pairs whose positional token does not + // resolve in the map (defensive — should not happen if the + // accumulator is consistent, but cheaper than a noisy crash). + const replacementFor = new Map<string, string>(); + let resolvedCount = 0; + for (const key of distinct) { + const slashAt = key.lastIndexOf('/'); + if (slashAt < 0) continue; + const indexStr = key.slice(slashAt + 1); + const idx = Number.parseInt(indexStr, 10); + if (!Number.isFinite(idx) || idx <= 0 || idx > personTokenOrder.length) continue; + const token = personTokenOrder[idx - 1]; + if (token === undefined) continue; + const original = map.resolve(token); + if (original === undefined) continue; + replacementFor.set(key, original); + resolvedCount += 1; + } + + if (resolvedCount === 0) { + return { + text, + detected, + restored: 0, + ambiguous: detected, + patternsHit, + maxIndexSeen: maxIndex, + }; + } + + // Replace right-to-left so earlier spans stay valid. Each match + // looks up its (keyword, index) key in the precomputed map. + const sorted = [...matches].sort((a, b) => b.span[0] - a.span[0]); + let out = text; + let restoredOccurrences = 0; + for (const m of sorted) { + const key = `${m.keyword}/${String(m.index)}`; + const replacement = replacementFor.get(key); + if (replacement === undefined) continue; + out = out.slice(0, m.span[0]) + replacement + out.slice(m.span[1]); + restoredOccurrences += 1; + } + + // `restored` is the count of DISTINCT labels resolved. The number + // of textual occurrences rewritten lives in `restoredOccurrences` + // (kept local — operators care about how many people had their + // names restored, not how many table cells had it stamped). + void restoredOccurrences; + return { + text: out, + detected, + restored: resolvedCount, + ambiguous: detected - resolvedCount, + patternsHit, + maxIndexSeen: maxIndex, + }; +} + +/** + * Phase A.1 (post-deploy 2026-05-14 second iteration) — gap-fill + * restoration of `«PERSON_N»` tokens that survived `processInbound` + * because they had no binding in the turn-map. + * + * Observed failure mode v149 HR-routine: the LLM emits some tokens + * verbatim (e.g. `«PERSON_5»`, `«PERSON_8»`) that `processInbound` + * restores fine, but throws in a hallucinated extra (`«PERSON_12»`) + * that does not exist in the turn-map and therefore survives to the + * channel. Strategy: + * + * 1. Resolve every captured tool-result person-token to its real + * name → that is the FULL set of names that legitimately + * belong in the final answer. + * 2. Scan the text for occurrences of each real name → the set of + * names already present. + * 3. The set difference is the "missing names" — people in the + * tool result that did not surface in the final text. + * 4. Scan the text for unresolved `«TYPE_N»` tokens (any type, not + * just PERSON — the LLM occasionally hallucinates EMAIL or ORG + * placeholders too). Each such token represents a "row position" + * where a real name was expected. + * 5. **Conservative count match**: only proceed when the number of + * unresolved tokens equals the number of missing names. Off-by-one + * means we cannot align the substitution and the wrong real name + * could end up in the wrong row. + * 6. Substitute left-to-right: nth unresolved token in output order + * gets the nth missing name in tool-result order. + * + * This algorithm uses the non-name fields (departments, dates) as + * implicit anchors: the LLM emits the row for the right person + * (correct dept + dates) but flubs the name slot. Whatever the name + * slot is — be it a label like "Mitarbeiter 1" (handled in + * `restoreSelfAnonymization`) or an unresolved token like + * `«PERSON_12»` (handled here) — the substitution restores it to + * the name whose dept+date the LLM already correctly reproduced. + * + * Type-agnostic: the regex matches `«TYPE_N»` for any uppercase TYPE. + * We never substitute the same span twice (label-pattern restoration + * runs first; if Phase-A.0 already filled a slot, this pass sees + * a real name and finds no unresolved token there). + */ +const ANY_TOKEN_REGEX = /«[A-Z][A-Z_]*_\d+»/g; + +export interface UnresolvedTokenMatch { + readonly span: readonly [number, number]; + readonly token: string; +} + +export function restoreUnresolvedPersonTokens( + text: string, + personTokenOrder: readonly string[], + map: TokenizeMap, +): RestorationOutcome { + if (text.length === 0 || personTokenOrder.length === 0) { + return { + text, + detected: 0, + restored: 0, + ambiguous: 0, + patternsHit: [], + maxIndexSeen: 0, + }; + } + + // Step 1: find every unresolved «TYPE_N» token in left-to-right + // order. Resolved tokens (those whose map.resolve() returns a + // value) are NOT candidates — they would already have been + // restored to real names by `processInbound`. + const unresolved: UnresolvedTokenMatch[] = []; + const local = new RegExp(ANY_TOKEN_REGEX.source, ANY_TOKEN_REGEX.flags); + let m: RegExpExecArray | null; + while ((m = local.exec(text)) !== null) { + const tok = m[0]; + if (map.resolve(tok) !== undefined) continue; + unresolved.push({ span: [m.index, m.index + tok.length], token: tok }); + } + if (unresolved.length === 0) { + return { + text, + detected: 0, + restored: 0, + ambiguous: 0, + patternsHit: [], + maxIndexSeen: 0, + }; + } + + // Step 2-4: compute the set of "missing names" — real names from + // the tool result that are NOT present in the current text. The + // text inclusion check is naive (substring) but conservative + // enough for the HR-routine shape; if a name is a substring of + // another both rows count as "present" which UNDER-counts missing + // and forces the conservative-skip in step 5. + const missing: string[] = []; + const allNames: string[] = []; + for (const token of personTokenOrder) { + const name = map.resolve(token); + if (name === undefined) continue; + allNames.push(name); + if (!text.includes(name)) missing.push(name); + } + void allNames; + + const detected = unresolved.length; + const patternsHit: readonly string[] = ['unresolved-token']; + + // Step 5: conservative count match. If the LLM dropped 2 names + // and we see 1 unresolved token, we cannot tell WHICH name is + // missing from that one slot — skip rather than guess. + if (unresolved.length !== missing.length) { + return { + text, + detected, + restored: 0, + ambiguous: detected, + patternsHit, + maxIndexSeen: detected, + }; + } + + // Step 6: substitute right-to-left so earlier spans stay valid. + // The mapping is positional: nth unresolved token gets the nth + // missing name. Both arrays are already in left-to-right / + // tool-result order respectively. + const sorted = [...unresolved] + .map((u, i) => ({ ...u, replacement: missing[i] })) + .sort((a, b) => b.span[0] - a.span[0]); + let out = text; + let restored = 0; + for (const { span, replacement } of sorted) { + if (replacement === undefined) continue; + out = out.slice(0, span[0]) + replacement + out.slice(span[1]); + restored += 1; + } + + return { + text: out, + detected, + restored, + ambiguous: detected - restored, + patternsHit, + maxIndexSeen: detected, + }; +} + +/** + * Phase A.2 (post-deploy 2026-05-14 third iteration) — final scrub + * pass that runs AFTER the egress filter. Phase A.0 / A.1 run BEFORE + * the egress filter, which means they cannot see tokens minted by + * egress itself when it masks spontaneous PII (`«PERSON_11»`, + * `«PERSON_12»`, etc. with counter values higher than anything in + * the tool-result token order). Those egress-minted tokens flow + * through to the user-facing answer and surface as token-shape + * cruft (HR-routine v152 Zusammenfassung, 2026-05-14). + * + * Two-stage restoration on the post-egress text: + * + * 1. Positional restoration. Re-use the missing-name algorithm + * from `restoreUnresolvedPersonTokens` — count unresolved + * `«TYPE_N»` tokens, compute names from tool result NOT in + * text, substitute positionally when counts match. + * 2. Generic placeholder fallback. ANY remaining `«TYPE_N»` token + * that step 1 could not restore is replaced with a per-type + * German placeholder (`[Name]`, `[E-Mail]`, …). The user sees + * a clean placeholder instead of token cruft; the privacy + * property holds (no PII surface), and the semantic loss is + * limited to "there's another person here but we cannot + * determine who exactly" — which is honest signalling. + * + * This function ALWAYS removes every `«TYPE_N»` token from the + * output. The caller can rely on the post-condition that the + * returned text contains no privacy-shield token shapes. + */ +const TYPE_PLACEHOLDERS: Readonly<Record<string, string>> = { + PERSON: '[Name]', + EMAIL: '[E-Mail]', + PHONE: '[Telefon]', + IBAN: '[IBAN]', + CARD: '[Kreditkarte]', + ADDRESS: '[Adresse]', + ORG: '[Organisation]', + IP: '[IP-Adresse]', + CRYPTO: '[Krypto-Adresse]', + APIKEY: '[Schlüssel]', + SSN: '[ID-Nummer]', +}; + +function placeholderForToken(token: string): string { + // Token shape: «TYPE_N» — slice off the wrapper, take everything up to + // the last underscore (which separates type from counter). + const inner = token.slice(1, -1); // strip « » + const lastUnderscore = inner.lastIndexOf('_'); + if (lastUnderscore <= 0) return '[Vertraulich]'; + const type = inner.slice(0, lastUnderscore); + return TYPE_PLACEHOLDERS[type] ?? '[Vertraulich]'; +} + +export interface PostEgressOutcome { + readonly text: string; + /** Tokens substituted via positional alignment (step 1). */ + readonly restoredPositional: number; + /** Tokens replaced with a generic placeholder (step 2). */ + readonly scrubbedToPlaceholder: number; +} + +export function restoreOrScrubRemainingTokens( + text: string, + personTokenOrder: readonly string[], + map: TokenizeMap, +): PostEgressOutcome { + if (text.length === 0) { + return { text, restoredPositional: 0, scrubbedToPlaceholder: 0 }; + } + + // Step 1 mirrors `restoreUnresolvedPersonTokens` but on the FINAL + // text. Find every `«TYPE_N»` (any type, not just PERSON, so we + // also catch EMAIL / IBAN cruft from egress when those types are + // active), but only attempt positional substitution against PERSON + // tokens (the tool-result order is person-typed). + const tokenSpans: Array<{ span: readonly [number, number]; token: string }> = []; + const local = new RegExp(/«[A-Z][A-Z_]*_\d+»/g.source, 'g'); + let m: RegExpExecArray | null; + while ((m = local.exec(text)) !== null) { + tokenSpans.push({ span: [m.index, m.index + m[0].length], token: m[0] }); + } + if (tokenSpans.length === 0) { + return { text, restoredPositional: 0, scrubbedToPlaceholder: 0 }; + } + + // Compute "missing names" from the tool-result token order: real + // names whose resolve(token) is a clean string (not a token-shape) + // AND which are NOT present in the current text. Excluding + // token-shape values guards against the sub-agent-hallucinated-token + // cycle (where resolve returns the literal token string). + const personTokenShape = /^«PERSON_\d+»$/; + const toolResultNames: string[] = []; + for (const t of personTokenOrder) { + const name = map.resolve(t); + if (name === undefined) continue; + if (personTokenShape.test(name)) continue; + toolResultNames.push(name); + } + const missing = toolResultNames.filter((n) => !text.includes(n)); + + // Positional substitution candidates: any PERSON-typed token that + // does NOT already resolve to a legit tool-result name. Three sub- + // categories all collapse into "positional candidate": + // - Unresolved (map.resolve === undefined). + // - Token-shape cycle (resolves to another `«PERSON_N»` literal). + // - Egress-minted with a spontaneous-PII value (resolves to a + // real string that is NOT one of the tool-result names). The + // spontaneous value is what egress was supposed to MASK, so we + // do NOT reveal it via "restoration"; positional substitution + // against a missing tool-result name is the right answer. + // A token whose resolved value IS one of the tool-result names is + // a legitimate restoration target and stays out of the candidate + // set — its resolved value gets substituted directly below. + const toolResultNameSet = new Set(toolResultNames); + const isPositionalCandidate = (token: string): boolean => { + if (!/^«PERSON_\d+»$/.test(token)) return false; + const resolved = map.resolve(token); + if (resolved === undefined) return true; + if (personTokenShape.test(resolved)) return true; + return !toolResultNameSet.has(resolved); + }; + + const candidates = tokenSpans.filter((s) => isPositionalCandidate(s.token)); + const replacements = new Map<string, string>(); // span-key → replacement + + let restoredPositional = 0; + if (candidates.length > 0 && candidates.length === missing.length) { + // Positional 1:1 — substitute left-to-right in candidate order. + candidates.forEach((c, i) => { + const name = missing[i]; + if (name !== undefined) { + replacements.set(`${String(c.span[0])}:${String(c.span[1])}`, name); + restoredPositional += 1; + } + }); + } + + // Step 2: anything not positionally resolved gets a generic + // placeholder. This covers non-PERSON tokens, count-mismatch + // candidates, and any token that step 1 left alone. + let scrubbedToPlaceholder = 0; + for (const span of tokenSpans) { + const key = `${String(span.span[0])}:${String(span.span[1])}`; + if (replacements.has(key)) continue; + replacements.set(key, placeholderForToken(span.token)); + scrubbedToPlaceholder += 1; + } + + // Substitute right-to-left so earlier spans stay valid. + const sorted = [...tokenSpans].sort((a, b) => b.span[0] - a.span[0]); + let out = text; + for (const span of sorted) { + const key = `${String(span.span[0])}:${String(span.span[1])}`; + const repl = replacements.get(key); + if (repl === undefined) continue; + out = out.slice(0, span.span[0]) + repl + out.slice(span.span[1]); + } + + return { text: out, restoredPositional, scrubbedToPlaceholder }; +} + +/** + * Extract the in-order, de-duplicated sequence of person-tokens from + * a tokenised text. Exposed so `processToolResult` can capture it + * once after `transformOne` and store it on the turn accumulator + * without re-running the regex inside the service. + * + * Returns `«PERSON_N»` style tokens only — other types (EMAIL, IBAN, + * ADDRESS, …) do not participate in self-anonymization restoration. + */ +const PERSON_TOKEN_REGEX = /«PERSON_\d+»/g; +export function extractPersonTokenOrder(text: string): readonly string[] { + if (text.length === 0) return []; + const seen = new Set<string>(); + const ordered: string[] = []; + const local = new RegExp(PERSON_TOKEN_REGEX.source, PERSON_TOKEN_REGEX.flags); + let m: RegExpExecArray | null; + while ((m = local.exec(text)) !== null) { + const tok = m[0]; + if (seen.has(tok)) continue; + seen.add(tok); + ordered.push(tok); + } + return ordered; +} diff --git a/middleware/packages/harness-plugin-privacy-guard/src/service.ts b/middleware/packages/harness-plugin-privacy-guard/src/service.ts index 3d9c75c70..47592e06c 100644 --- a/middleware/packages/harness-plugin-privacy-guard/src/service.ts +++ b/middleware/packages/harness-plugin-privacy-guard/src/service.ts @@ -6,12 +6,14 @@ * as a free function (not a class) so tests can construct without a * `PluginContext`. * - * State held by the returned service: - * - `Map<sessionId, TokenizeMap>` — session-scoped token bindings. Same - * value yields the same token across all turns of one conversation, - * so the LLM can keep referencing it coherently ("the same email as - * before"). The map is currently in-memory only; Slice 2.4 adds AES - * encryption + a 15-min idle TTL + explicit destroy on session end. + * State held by the returned service (Privacy-Shield v2 / Slice S-2): + * - `Map<turnId, TokenizeMap>` — turn-scoped token bindings. Same + * value within ONE turn always yields the same token (outbound, + * tool-input, tool-result and inbound calls of the turn all share + * the same map → intra-turn reconciliation). The map is dropped + * by `finalizeTurn` so the PII bindings are eligible for garbage + * collection. Cross-turn token identity is NOT preserved; the LLM + * keeps coherence via the assistant-tail of real (restored) values. * - `Map<turnId, TurnAccumulator>` — per-turn detection bucket. Each * `processOutbound` call appends to it; `finalizeTurn` drains and * emits a single PII-free receipt aggregating every LLM call in the @@ -38,13 +40,23 @@ import type { PrivacyDetectorOutcome, PrivacyDetectorRun, PrivacyDetectorStatus, + PrivacyEgressConfig, + PrivacyEgressMode, + PrivacyEgressRequest, + PrivacyEgressResult, + PrivacyLiveTestResult, PrivacyGuardService, PrivacyInboundRequest, PrivacyInboundResult, PrivacyOutboundMessage, PrivacyOutboundRequest, PrivacyOutboundResult, + PrivacyOutputValidationRequest, + PrivacyOutputValidationResult, + PrivacyPostEgressScrubResult, PrivacyReceipt, + PrivacySelfAnonymizationRequest, + PrivacySelfAnonymizationResult, PrivacyToolInputRequest, PrivacyToolInputResult, PrivacyToolResultRequest, @@ -54,14 +66,29 @@ import type { import { assembleReceipt, type AssembledHit } from './receiptAssembler.js'; import { decide, deriveRouting, type PolicyDecision } from './policyEngine.js'; +import { runEgressFilter } from './egressFilter.js'; +import { + extractPersonTokenOrder, + restoreOrScrubRemainingTokens, + restoreSelfAnonymization, + restoreUnresolvedPersonTokens, +} from './selfAnonymization.js'; +import { extendHitsToWordBoundary } from './spanHelpers.js'; import { createRegexDetector } from './regexDetector.js'; import { TOKEN_REGEX, createTokenizeMap, type TokenizeMap } from './tokenizeMap.js'; +import { + createAllowlist, + filterHitsByAllowlist, + type Allowlist, + type AllowlistConfig, + type AllowlistMatch, +} from './allowlist.js'; export interface PrivacyGuardServiceDeps { /** Default policy mode applied when the request does not pin one. */ readonly defaultPolicyMode: PolicyMode; /** Override for tests; production should let the service mint its own - * per-session map via `createTokenizeMap()`. */ + * per-turn map via `createTokenizeMap()`. */ readonly tokenizeMapFactory?: () => TokenizeMap; /** * Slice 3.1: seed list of detectors. The service runs them in parallel @@ -83,6 +110,51 @@ export interface PrivacyGuardServiceDeps { * is either a debug receipt or it isn't. */ readonly debugShowValues?: boolean; + /** + * Privacy-Shield v2 (Slice S-3) — pre-detector allowlist. Spans + * matching any configured term are exempted from the detector pool + * before policy decisions. Omit / pass empty arrays for a no-op + * allowlist (this is the default; existing tests stay unaffected). + * + * The host assembles the three source lists at plugin-activate time + * from (a) the operator profile (tenant-self), (b) the bundled + * repo-default JSON, (c) the plugin config field + * `extra_allowlist_terms`. Re-activating the plugin re-builds the + * allowlist; the service does not hot-reload mid-turn. + */ + readonly allowlist?: AllowlistConfig; + /** + * Privacy-Shield v2 (Slice S-5) — Output Validator threshold for + * the token-loss ratio. When the LLM emitted less than + * `(1 - threshold) × tokensMinted` distinct minted tokens in its + * response, the recommendation escalates to `retry`. Default `0.3` + * (30 %). + */ + readonly tokenLossThreshold?: number; + /** + * Privacy-Shield v2 (Slice S-6) — default egress-filter reaction + * mode applied when a `egressFilter` request omits `mode`. The + * plugin reads `egress_filter_mode` from the operator config at + * activate time; falls back to `'mask'` when unset (production + * default — masks the spontaneous PII inline without dropping the + * answer). + */ + readonly egressFilterMode?: PrivacyEgressMode; + /** + * Privacy-Shield v2 (Slice S-6) — master switch surfaced through + * `getEgressConfig()` so hosts (orchestrator, routine runner) can + * skip the call cheaply when the operator disabled the filter. + * Defaults to `true` when omitted. + */ + readonly egressFilterEnabled?: boolean; + /** + * Privacy-Shield v2 (Slice S-6) — placeholder string the host + * substitutes for the final answer when the filter returns + * `routing: 'blocked'`. Surfaced via `getEgressConfig()`. The + * service does not perform the swap itself; that lives at the + * integration boundary. + */ + readonly egressBlockPlaceholderText?: string; } /** @@ -139,6 +211,80 @@ interface TurnAccumulator { toolRoundtripArgsRestored: number; toolRoundtripResultsTokenized: number; toolRoundtripCallCount: number; + /** Privacy-Shield v2 (Slice S-3) — per-source allowlist hit counts + * aggregated across every `transformOne` call within the turn. + * Surfaced as `receipt.allowlist.bySource` at `finalizeTurn` when + * any source fired. PII-free: counts only, never the matched term. */ + allowlistHits: { tenantSelf: number; repoDefault: number; operatorOverride: number }; + /** Privacy-Shield v2 (Slice S-5) — distinct minted tokens the LLM + * referenced in its responses, counted in `processInbound` before + * restore. Used as the numerator of the token-loss ratio. Stored + * as a Set so repeated references don't inflate the count + * artificially. */ + readonly tokensSeenInInbound: Set<string>; + /** Privacy-Shield v2 (Slice S-5) — Output Validator result for the + * turn. Populated by `validateOutput`; absent when the host never + * called the validator. Surfaced as `receipt.output` at finalize. */ + outputValidation: PrivacyOutputValidationResult | undefined; + /** Privacy-Shield v2 (Slice S-6) — Egress Filter summary for the + * turn. Set by `egressFilter`; absent when the host never called + * it. Surfaced as `receipt.egress` at finalize. PII-free: the + * detector-runs / counts / routing only. */ + egressSummary: + | { + readonly mode: PrivacyEgressMode; + readonly routing: PrivacyEgressResult['routing']; + readonly detectorRuns: readonly PrivacyDetectorRun[]; + readonly spontaneousHits: number; + readonly maskedCount: number; + } + | undefined; + /** + * Privacy-Shield v2 (Phase A, post-deploy 2026-05-14) — captured + * after the most recent `processToolResult`. De-duplicated, in-order + * `«PERSON_N»` sequence from the transformed tool-result text. + * + * The positional source for `restoreSelfAnonymizationLabels`: when + * the LLM emits "Mitarbeiter 1 / 2 / 3" referring to the rows of a + * tool result, index N corresponds to the N-th `«PERSON_N»` that + * appeared in that tool result — NOT the N-th token minted across + * the whole turn, because earlier user-mentioned names occupy lower + * mint counters but do not belong to the table-positional view. + * + * Overwritten on every tool-result invocation: the LATEST result is + * the most likely positional referent. A future slice may extend + * this to a per-tool-name map when multi-tool synthesis surfaces + * cross-result label ambiguity. + */ + lastToolResultPersonTokenOrder: readonly string[]; + /** + * Privacy-Shield v2 (Phase A) — per-turn restoration summary, + * surfaced as `receipt.selfAnonymization` so operators see how + * many label patterns the LLM emitted and how many we restored. + * Absent when `restoreSelfAnonymizationLabels` was never invoked + * for this turn. PII-free: counts + lowercase keyword stems only. + */ + selfAnonymizationSummary: + | { + readonly detected: number; + readonly restored: number; + readonly ambiguous: number; + readonly patternsHit: readonly string[]; + readonly maxIndexSeen: number; + readonly tokenOrderLength: number; + } + | undefined; + /** + * Privacy-Shield v2 (Phase A.2) — final-scrub telemetry. Populated + * by `restoreOrScrubRemainingTokens`. Aggregated into + * `receipt.postEgressScrub` at finalize. + */ + postEgressScrubSummary: + | { + readonly restoredPositional: number; + readonly scrubbedToPlaceholder: number; + } + | undefined; } /** Mutable per-detector accumulator. Aggregated into `PrivacyDetectorRun` @@ -183,6 +329,16 @@ function detectorScansTarget(d: PrivacyDetector, target: TextTarget): boolean { return targets.systemPrompt !== false; } +/** + * Privacy-Shield v2 (Slice S-6) — fallback placeholder string surfaced + * via `getEgressConfig()` when neither plugin config nor the service + * deps supplied one. Kept in English so unconfigured tenants get a + * universally-understandable refusal rather than a German default + * that would surprise an EN-only operator. + */ +const DEFAULT_EGRESS_PLACEHOLDER_TEXT = + 'The response was withheld because it contained data the privacy filter could not verify. Please rephrase your request.'; + /** Severity rank for `PrivacyDetectorStatus`. Used to fold per-call * outcomes into a turn-wide worst status. `error > timeout > skipped > ok`. */ function statusRank(s: PrivacyDetectorStatus): number { @@ -202,8 +358,23 @@ export function createPrivacyGuardService( deps: PrivacyGuardServiceDeps, ): PrivacyGuardServiceInternal { const factory = deps.tokenizeMapFactory ?? createTokenizeMap; - const sessionMaps = new Map<string, TokenizeMap>(); + // Privacy-Shield v2 (Slice S-2): the tokenise-map is scoped per TURN, + // not per session. The map is minted lazily on the first + // `processOutbound` / `processToolResult` of a turn and discarded + // together with the accumulator in `finalizeTurn`. Cross-turn token + // identity is therefore NOT preserved — the LLM's coherence across + // turns comes from the assistant-tail (which references real values + // after restore), not from stable token names. + const turnMaps = new Map<string, TokenizeMap>(); const turnAccumulators = new Map<string, TurnAccumulator>(); + // Privacy-Shield v2 (Slice S-3): build the allowlist at service + // construction. Re-activating the plugin re-runs the factory. + // Privacy-Shield v2 (Slice S-7): the operator-override list is + // mutable at runtime via `setOperatorOverrideTerms` from the + // Operator-UI; we hold the resolved config + the live Allowlist + // separately so rebuilds are local. + let allowlistConfig: AllowlistConfig = deps.allowlist ?? {}; + let allowlist: Allowlist = createAllowlist(allowlistConfig); // Slice 3.1: detector list. Empty seed → bundle the regex detector as // default so existing single-detector behaviour holds without config. // Detectors registered after the service is built (Slice 3.2 Ollama @@ -213,11 +384,11 @@ export function createPrivacyGuardService( ? [...deps.detectors] : [createRegexDetector()]; - function mapFor(sessionId: string): TokenizeMap { - let m = sessionMaps.get(sessionId); + function mapFor(turnId: string): TokenizeMap { + let m = turnMaps.get(turnId); if (m === undefined) { m = factory(); - sessionMaps.set(sessionId, m); + turnMaps.set(turnId, m); } return m; } @@ -239,6 +410,13 @@ export function createPrivacyGuardService( toolRoundtripArgsRestored: 0, toolRoundtripResultsTokenized: 0, toolRoundtripCallCount: 0, + allowlistHits: { tenantSelf: 0, repoDefault: 0, operatorOverride: 0 }, + tokensSeenInInbound: new Set<string>(), + outputValidation: undefined, + egressSummary: undefined, + lastToolResultPersonTokenOrder: [], + selfAnonymizationSummary: undefined, + postEgressScrubSummary: undefined, }; turnAccumulators.set(turnId, acc); } @@ -278,11 +456,23 @@ export function createPrivacyGuardService( bucket.latencyMs += latencyMs; } + /** Privacy-Shield v2 (Slice S-3) — increment per-source allowlist + * counters on the turn accumulator. PII-free: counts only. */ + function recordAllowlistMatches( + acc: TurnAccumulator, + matches: readonly AllowlistMatch[], + ): void { + if (matches.length === 0) return; + for (const m of matches) { + acc.allowlistHits[m.source] += 1; + } + } + return { async processOutbound( request: PrivacyOutboundRequest, ): Promise<PrivacyOutboundResult> { - const map = mapFor(request.sessionId); + const map = mapFor(request.turnId); const acc = accumulatorFor(request); const augmentedSystemPrompt = augmentSystemPromptForPrivacyProxy( @@ -332,6 +522,8 @@ export function createPrivacyGuardService( detectors: detectorSnapshot, inflightCache: acc.inflightCache, recordOutcome: (id, outcome, latencyMs) => recordOutcome(acc, id, outcome, latencyMs), + allowlist, + recordAllowlistMatches: (matches) => recordAllowlistMatches(acc, matches), }), ), ); @@ -365,12 +557,28 @@ export function createPrivacyGuardService( async processInbound( request: PrivacyInboundRequest, ): Promise<PrivacyInboundResult> { - const map = sessionMaps.get(request.sessionId); + const map = turnMaps.get(request.turnId); if (map === undefined) { - // No outbound was ever processed for this session — nothing to + // No outbound was ever processed for this turn — nothing to // restore. Pass-through. return { text: request.text }; } + // Privacy-Shield v2 (Slice S-5): before restoring, fold every + // recognised token into the turn's "seen" set so the Output + // Validator can compute the token-loss ratio at end-of-turn. + // Unknown tokens (no map entry) are ignored here — the validator + // treats them as a separate "unrestored token" signal. + const acc = turnAccumulators.get(request.turnId); + if (acc !== undefined && request.text.includes('«')) { + const matches = request.text.match(TOKEN_REGEX); + if (matches !== null) { + for (const tok of matches) { + if (map.resolve(tok) !== undefined) { + acc.tokensSeenInInbound.add(tok); + } + } + } + } return { text: restoreTokens(request.text, map) }; }, @@ -378,6 +586,12 @@ export function createPrivacyGuardService( const acc = turnAccumulators.get(turnId); if (acc === undefined) return undefined; turnAccumulators.delete(turnId); + // Privacy-Shield v2 (Slice S-2): drop the per-turn tokenise-map so + // its PII bindings are eligible for garbage collection. A + // subsequent processInbound for this turn — should one fire after + // finalize, which would be a host bug — pass-throughs because the + // map is gone. + turnMaps.delete(turnId); if (deps.debugShowValues === true) { // Slice 2.2 dev-instrumentation: when the operator has explicitly @@ -405,6 +619,11 @@ export function createPrivacyGuardService( ...(b.reason !== undefined ? { reason: b.reason } : {}), })); + const allowlistTotal = + acc.allowlistHits.tenantSelf + + acc.allowlistHits.repoDefault + + acc.allowlistHits.operatorOverride; + const receipt = assembleReceipt({ hits: acc.hits, policyMode: acc.policyMode, @@ -423,6 +642,61 @@ export function createPrivacyGuardService( }, } : {}), + ...(allowlistTotal > 0 + ? { + allowlist: { + hitCount: allowlistTotal, + bySource: { + tenantSelf: acc.allowlistHits.tenantSelf, + repoDefault: acc.allowlistHits.repoDefault, + operatorOverride: acc.allowlistHits.operatorOverride, + }, + }, + } + : {}), + ...(acc.outputValidation !== undefined + ? { + output: { + tokenLossRatio: acc.outputValidation.tokenLossRatio, + spontaneousPiiHits: acc.outputValidation.spontaneousPiiHits.length, + recommendation: acc.outputValidation.recommendation, + ...(acc.outputValidation.recommendationReason !== undefined + ? { recommendationReason: acc.outputValidation.recommendationReason } + : {}), + }, + } + : {}), + ...(acc.egressSummary !== undefined + ? { + egress: { + mode: acc.egressSummary.mode, + routing: acc.egressSummary.routing, + detectorRuns: acc.egressSummary.detectorRuns, + spontaneousHits: acc.egressSummary.spontaneousHits, + maskedCount: acc.egressSummary.maskedCount, + }, + } + : {}), + ...(acc.selfAnonymizationSummary !== undefined + ? { + selfAnonymization: { + detected: acc.selfAnonymizationSummary.detected, + restored: acc.selfAnonymizationSummary.restored, + ambiguous: acc.selfAnonymizationSummary.ambiguous, + patternsHit: acc.selfAnonymizationSummary.patternsHit, + maxIndexSeen: acc.selfAnonymizationSummary.maxIndexSeen, + tokenOrderLength: acc.selfAnonymizationSummary.tokenOrderLength, + }, + } + : {}), + ...(acc.postEgressScrubSummary !== undefined + ? { + postEgressScrub: { + restoredPositional: acc.postEgressScrubSummary.restoredPositional, + scrubbedToPlaceholder: acc.postEgressScrubSummary.scrubbedToPlaceholder, + }, + } + : {}), }); return receipt; }, @@ -432,16 +706,16 @@ export function createPrivacyGuardService( ): Promise<PrivacyToolInputResult> { const acc = accumulatorForIds(request.sessionId, request.turnId); acc.toolRoundtripCallCount += 1; - const map = sessionMaps.get(request.sessionId); + const map = turnMaps.get(request.turnId); if (map === undefined) { - // No outbound was ever processed for this session — there are no + // No outbound was ever processed for this turn — there are no // tokens to restore. Pass-through. return { input: request.input, tokensRestored: 0 }; } let restoredCount = 0; const walk = (v: unknown): unknown => { if (typeof v === 'string') { - if (!v.includes('tok_')) return v; + if (!v.includes('«')) return v; const restored = restoreTokens(v, map); if (restored !== v) restoredCount += 1; return restored; @@ -502,13 +776,15 @@ export function createPrivacyGuardService( }; const transformed = await transformOne(target, { policyMode: acc.policyMode, - map: mapFor(request.sessionId), + map: mapFor(request.turnId), collect: acc.hits, collectDecisions: localDecisions, detectors: detectorSnapshot, inflightCache: acc.inflightCache, recordOutcome: (id, outcome, latencyMs) => recordOutcome(acc, id, outcome, latencyMs), + allowlist, + recordAllowlistMatches: (matches) => recordAllowlistMatches(acc, matches), }); const wasTransformed = transformed.text !== request.text; @@ -516,6 +792,15 @@ export function createPrivacyGuardService( acc.toolRoundtripResultsTokenized += 1; } + // Privacy-Shield v2 (Phase A) — capture the de-duplicated + // in-order person-token sequence from this tool result so the + // mechanical self-anonymization restorer can map LLM-emitted + // "Mitarbeiter N" labels to the right real names by position. + // Overwrites the previous capture: the LATEST tool result is + // the most likely positional referent for the answer the LLM + // is about to compose. + acc.lastToolResultPersonTokenOrder = extractPersonTokenOrder(transformed.text); + // Tool result is part of the turn's outbound surface back to the // LLM — feed it into the audit-hash chunks like processOutbound // does for system + messages so the auditHash covers the full @@ -525,6 +810,352 @@ export function createPrivacyGuardService( return { text: transformed.text, transformed: wasTransformed }; }, + // Privacy-Shield v2 (Slice S-5) — Output Validator. Re-runs the + // detector pool on the final assistant text (post-restore) and + // compares each detector hit against the turn-map. Hits whose + // value WAS in the map are restored tokens (legitimate); + // hits whose value WAS NOT in the map are "spontaneous PII" — + // the LLM produced a plausible-looking value rather than passing + // a token through verbatim. Combined with the token-loss ratio + // (minted vs. seen-in-inbound), the validator emits a + // `pass | retry | block` recommendation the host can act on. + async validateOutput( + request: PrivacyOutputValidationRequest, + ): Promise<PrivacyOutputValidationResult> { + const map = turnMaps.get(request.turnId); + const acc = turnAccumulators.get(request.turnId); + const tokensMinted = map?.size ?? 0; + const tokensRestored = acc?.tokensSeenInInbound.size ?? 0; + const tokenLossRatio = + tokensMinted === 0 ? 0 : Math.max(0, 1 - tokensRestored / tokensMinted); + + // Re-run detectors on the final assistant text. Use the same + // detector pool + dedup pipeline as transformOne so we get + // consistent classifications. Wrap in a noop allowlist for the + // re-scan because the allowlist's job is to suppress FPs on the + // INBOUND scan; we want the FULL detector signal on the output. + const detectorSnapshot: readonly PrivacyDetector[] = [...detectors]; + const inflightCache = new Map<string, Promise<PrivacyDetectorOutcome>>(); + const allHits = await runDetectors( + request.assistantText, + detectorSnapshot, + inflightCache, + () => { + // Detector-run telemetry for the output validator is folded + // into the same per-detector buckets as the main pass if an + // accumulator exists; otherwise discarded. This is a + // best-effort signal (the validator may run without a prior + // outbound — e.g. the host calls it on a routine's + // pre-formatted answer). + }, + ); + const deduped = dedupOverlappingHits(allHits); + const spontaneousPiiHits: Array<{ type: string; detectorId: string }> = []; + for (const hit of deduped) { + if (map === undefined || !map.hasOriginalValue(hit.value)) { + spontaneousPiiHits.push({ type: hit.type, detectorId: hit.detector }); + } + } + + const threshold = + typeof deps.tokenLossThreshold === 'number' && + deps.tokenLossThreshold >= 0 && + deps.tokenLossThreshold <= 1 + ? deps.tokenLossThreshold + : 0.3; + + let recommendation: 'pass' | 'retry' | 'block' = 'pass'; + let recommendationReason: string | undefined; + if (spontaneousPiiHits.length > 0) { + recommendation = 'block'; + recommendationReason = `spontaneous PII in output (${String(spontaneousPiiHits.length)} hit${spontaneousPiiHits.length === 1 ? '' : 's'})`; + } else if (tokenLossRatio > threshold) { + recommendation = 'retry'; + recommendationReason = `token-loss ratio ${tokenLossRatio.toFixed(2)} exceeds threshold ${threshold.toFixed(2)}`; + } + + const result: PrivacyOutputValidationResult = { + tokensMinted, + tokensRestored, + tokenLossRatio, + spontaneousPiiHits, + recommendation, + ...(recommendationReason !== undefined ? { recommendationReason } : {}), + }; + if (acc !== undefined) { + acc.outputValidation = result; + } + return result; + }, + + // Privacy-Shield v2 (Slice S-6) — Egress Filter. Re-runs the full + // detector pool on the final channel-bound text slots, classifies + // each hit against the turn-map (known → restored PII, unknown → + // spontaneous), and applies the operator-configured mode. Folds + // detectorRuns + counters onto the turn accumulator for the + // `egress` receipt block. + getEgressConfig(): PrivacyEgressConfig { + return { + enabled: deps.egressFilterEnabled !== false, + mode: deps.egressFilterMode ?? 'mask', + blockPlaceholderText: + deps.egressBlockPlaceholderText !== undefined && + deps.egressBlockPlaceholderText.trim().length > 0 + ? deps.egressBlockPlaceholderText + : DEFAULT_EGRESS_PLACEHOLDER_TEXT, + }; + }, + + async egressFilter( + request: PrivacyEgressRequest, + ): Promise<PrivacyEgressResult> { + // Use the turn map if it exists; otherwise mint one (the host + // may call egressFilter without ever calling processOutbound — + // e.g. a routine that produced answer purely from a tool result + // we tokenised via processToolResult, or a unit test). The + // map's `hasOriginalValue` returns false for everything in the + // bare-mint case, which means every detection becomes + // spontaneous — exactly the desired fail-safe. + const map = mapFor(request.turnId); + const acc = accumulatorForIds(request.sessionId, request.turnId); + const defaultMode: PrivacyEgressMode = deps.egressFilterMode ?? 'mask'; + const result = await runEgressFilter(request, { + detectors: [...detectors], + map, + defaultMode, + allowlist, + }); + acc.egressSummary = { + mode: result.mode, + routing: result.routing, + detectorRuns: result.detectorRuns, + spontaneousHits: result.spontaneousHits, + maskedCount: result.maskedCount, + }; + return result; + }, + + // Privacy-Shield v2 (Phase A, post-deploy 2026-05-14) — mechanical + // restoration of LLM self-anonymization labels. Phase A.1 layers + // unresolved-token gap-fill on top. See module-level comment in + // selfAnonymization.ts for the design rationale. + async restoreSelfAnonymizationLabels( + request: PrivacySelfAnonymizationRequest, + ): Promise<PrivacySelfAnonymizationResult> { + const acc = accumulatorForIds(request.sessionId, request.turnId); + const map = mapFor(request.turnId); + const tokenOrder = acc.lastToolResultPersonTokenOrder; + + // Pass 1: label-pattern restoration (Mitarbeiter N / Employee N + // / Person N / …). Indexed by the parsed numeric ordinal. + const labelOutcome = restoreSelfAnonymization(request.text, tokenOrder, map); + + // Pass 2: unresolved-token gap-fill on the text produced by pass + // 1. Indexed by left-to-right occurrence of `«TYPE_N»` tokens + // that have no binding in the turn-map; matched against the set + // of missing real names (tool-result names not present in the + // text yet). + const gapOutcome = restoreUnresolvedPersonTokens( + labelOutcome.text, + tokenOrder, + map, + ); + + const detected = labelOutcome.detected + gapOutcome.detected; + const restored = labelOutcome.restored + gapOutcome.restored; + const ambiguous = labelOutcome.ambiguous + gapOutcome.ambiguous; + const patternsHit = [ + ...new Set([...labelOutcome.patternsHit, ...gapOutcome.patternsHit]), + ].sort(); + const maxIndexSeen = Math.max( + labelOutcome.maxIndexSeen, + gapOutcome.maxIndexSeen, + ); + + // Always update the accumulator — even on a zero-match run — so + // operators see "detector ran, found nothing" rather than the + // ambiguous absence in the receipt. + acc.selfAnonymizationSummary = { + detected, + restored, + ambiguous, + patternsHit, + maxIndexSeen, + tokenOrderLength: tokenOrder.length, + }; + + // Phase A.1 telemetry — operator receipts are not yet persisted + // (S-7.5 deferred), so the only durable diagnostic surface is + // stdout. Emit a single per-turn line that lets the operator + // distinguish "restoration ran clean", "conservative skip + // fired", and "no labels at all" without a receipt query. + if (detected > 0 || tokenOrder.length > 0) { + // Phase A.1+ verbose diagnostic: surface the captured token + // sequence (just the «PERSON_N» strings, not the underlying + // PII values) and pre/post text fragments so we can tell why + // the gap-fill did or didn't fire on the live HR-routine + // shape. Token strings carry no PII by construction; the + // surrounding text is the assistant answer which is about + // to ship to the user anyway. Truncated to keep the log line + // bounded. + const previewBefore = request.text.slice(0, 160).replace(/\n/g, '⏎'); + const previewAfter = gapOutcome.text.slice(0, 160).replace(/\n/g, '⏎'); + const unchangedByGap = gapOutcome.text === labelOutcome.text; + const unchangedByLabel = labelOutcome.text === request.text; + console.log( + `[privacy-guard] selfAnon turn=${request.turnId} detected=${String(detected)} ` + + `restored=${String(restored)} ambiguous=${String(ambiguous)} ` + + `tokenOrder=${String(tokenOrder.length)} maxIdx=${String(maxIndexSeen)} ` + + `patterns=[${patternsHit.join(',')}] ` + + `label-d=${String(labelOutcome.detected)}/r=${String(labelOutcome.restored)} ` + + `gap-d=${String(gapOutcome.detected)}/r=${String(gapOutcome.restored)} ` + + `label-changed=${String(!unchangedByLabel)} gap-changed=${String(!unchangedByGap)} ` + + `tokenOrder-content=[${tokenOrder.join(',')}] ` + + `before="${previewBefore}" after="${previewAfter}"`, + ); + } + + return { + text: gapOutcome.text, + detected, + restored, + ambiguous, + patternsHit, + maxIndexSeen, + tokenOrderLength: tokenOrder.length, + }; + }, + + // Privacy-Shield v2 (Phase A.2, post-deploy 2026-05-14 third + // iteration) — final-scrub pass that runs AFTER the egress filter. + // See selfAnonymization.ts::restoreOrScrubRemainingTokens for the + // design rationale. + async restoreOrScrubRemainingTokens( + request: PrivacySelfAnonymizationRequest, + ): Promise<PrivacyPostEgressScrubResult> { + const acc = accumulatorForIds(request.sessionId, request.turnId); + const map = mapFor(request.turnId); + const tokenOrder = acc.lastToolResultPersonTokenOrder; + const outcome = restoreOrScrubRemainingTokens(request.text, tokenOrder, map); + acc.postEgressScrubSummary = { + restoredPositional: outcome.restoredPositional, + scrubbedToPlaceholder: outcome.scrubbedToPlaceholder, + }; + if (outcome.restoredPositional > 0 || outcome.scrubbedToPlaceholder > 0) { + console.log( + `[privacy-guard] postEgressScrub turn=${request.turnId} ` + + `restored=${String(outcome.restoredPositional)} ` + + `scrubbed=${String(outcome.scrubbedToPlaceholder)} ` + + `tokenOrder=${String(tokenOrder.length)}`, + ); + } + return outcome; + }, + + // Privacy-Shield v2 (Slice S-7) — Operator-UI read surface. + getAllowlistSnapshot(): { + readonly tenantSelf: readonly string[]; + readonly repoDefault: readonly string[]; + readonly operatorOverride: readonly string[]; + } { + return { + tenantSelf: [...(allowlistConfig.tenantSelfTerms ?? [])], + repoDefault: [...(allowlistConfig.repoDefaultTerms ?? [])], + operatorOverride: [...(allowlistConfig.operatorOverrideTerms ?? [])], + }; + }, + + // Privacy-Shield v2 (Slice S-7) — Operator-UI write surface. + // Rebuilds the allowlist with the new override list, leaving the + // tenantSelf + repoDefault sources untouched. In-process only; + // durable persistence is a v0.2.x follow-up. + setOperatorOverrideTerms(terms: readonly string[]): void { + const cleaned = terms + .map((t) => (typeof t === 'string' ? t.trim() : '')) + .filter((t) => t.length > 0); + allowlistConfig = { + ...allowlistConfig, + operatorOverrideTerms: cleaned, + }; + allowlist = createAllowlist(allowlistConfig); + }, + + // Privacy-Shield v2 (Slice S-7) — Operator-UI Live-Test. + // Runs the full detector + allowlist + tokenise pipeline on the + // input without touching any per-turn accumulator state. Pure + // pipeline: detectors → allowlist filter → dedup → mint tokens + // in an ephemeral map. The ephemeral map is discarded at end so + // nothing leaks into real turns. + async liveTest(input: { + readonly text: string; + }): Promise<PrivacyLiveTestResult> { + const target = input.text; + if (target.length === 0) { + return { + original: target, + tokenized: target, + detectorHits: [], + allowlistMatches: [], + }; + } + const detectorSnapshot: readonly PrivacyDetector[] = [...detectors]; + const inflightCache = new Map<string, Promise<PrivacyDetectorOutcome>>(); + const ranHits = await runDetectors( + target, + detectorSnapshot, + inflightCache, + () => { + // No turn-accumulator side-effects for live-test. + }, + ); + const allowMatches = allowlist.scan(target); + const allowlistMatches = allowMatches.map((m) => ({ + span: m.span, + source: m.source, + term: target.slice(m.span[0], m.span[1]), + })); + const filteredHits = + allowMatches.length > 0 ? filterHitsByAllowlist(ranHits, allowMatches) : ranHits; + const dedupedHits = dedupOverlappingHits(filteredHits); + + // Mint tokens in an ephemeral, throwaway map. + const ephemeralMap = factory(); + const sorted = [...dedupedHits].sort((a, b) => b.span[0] - a.span[0]); + let tokenised = target; + const annotated: Array<{ + type: string; + value: string; + span: readonly [number, number]; + confidence: number; + detector: string; + action: ReturnType<typeof decide>['action']; + }> = []; + for (const hit of sorted) { + const decision = decide({ + type: hit.type, + policyMode: deps.defaultPolicyMode, + }); + const replacement = renderReplacement(hit, decision.action, ephemeralMap); + tokenised = + tokenised.slice(0, hit.span[0]) + replacement + tokenised.slice(hit.span[1]); + annotated.unshift({ + type: hit.type, + value: hit.value, + span: hit.span, + confidence: hit.confidence, + detector: hit.detector, + action: decision.action, + }); + } + + return { + original: target, + tokenized: tokenised, + detectorHits: annotated, + allowlistMatches, + }; + }, + // Slice 3.1 registry surface — exposed via `PrivacyDetectorRegistry` // by the plugin entry point so add-on detector plugins can register // their own NER / Presidio detector at activate time. @@ -545,16 +1176,15 @@ export function createPrivacyGuardService( } // --------------------------------------------------------------------------- -// Token restore — used by `processInbound`. Replaces every `tok_<hex>` +// Token restore — used by `processInbound`. Replaces every `«TYPE_N»` // substring with the bound original. Unknown tokens are left as-is so the -// caller can decide what to do (Slice 2.3 hallucination flagging will -// re-scan from here). +// Output Validator (Slice S-5) can flag them as possible hallucinations. // --------------------------------------------------------------------------- function restoreTokens(text: string, map: TokenizeMap): string { if (text.length === 0) return text; - // Quick reject: no `tok_` substring at all means nothing to do. - if (!text.includes('tok_')) return text; + // Quick reject: no opening guillemet means no tokens to restore. + if (!text.includes('«')) return text; return text.replace(TOKEN_REGEX, (match) => { const original = map.resolve(match); return original ?? match; @@ -587,6 +1217,15 @@ interface TransformContext { outcome: PrivacyDetectorOutcome, latencyMs: number, ) => void; + /** Privacy-Shield v2 (Slice S-3) — allowlist used to pre-filter the + * detector pool's hits before policy applies. May be a no-op + * allowlist when nothing is configured. */ + readonly allowlist: Allowlist; + /** Callback to fold per-source allowlist hit counts into the turn + * accumulator. Called once per `transformOne` with the scan + * results; aggregating happens in the service so the assembler + * receives one number per source per turn. */ + readonly recordAllowlistMatches: (matches: readonly AllowlistMatch[]) => void; } async function transformOne( @@ -612,9 +1251,21 @@ async function transformOne( ctx.inflightCache, ctx.recordOutcome, ); - if (allHits.length === 0) return { text: target.source }; - const deduped = dedupOverlappingHits(allHits); + // Privacy-Shield v2 (Slice S-3) — scan the allowlist on the same + // text the detectors saw and drop any detector hit that overlaps an + // allowlist span. The allowlist scan runs unconditionally so the + // receipt can report "0 detector hits, N allowlist matches" for the + // operator (telemetry over silent absence). When the allowlist is + // empty the scan returns [] cheaply. + const allowlistMatches = ctx.allowlist.scan(target.source); + ctx.recordAllowlistMatches(allowlistMatches); + const filteredHits = + allowlistMatches.length > 0 ? filterHitsByAllowlist(allHits, allowlistMatches) : allHits; + + if (filteredHits.length === 0) return { text: target.source }; + + const deduped = dedupOverlappingHits(filteredHits); // Replace right-to-left so earlier indices stay valid. const sorted = [...deduped].sort((a, b) => b.span[0] - a.span[0]); let out = target.source; @@ -695,7 +1346,13 @@ async function runDetectors( return [...outcome.hits]; }), ); - return results.flat(); + // Post-process: extend each hit's span forward through any adjacent + // word characters so the trailing letter that detectors like Presidio + // systematically clip off German compound names (e.g. "Schmidt" → + // "Schmid"+"t") gets absorbed into the masked region. Without this, + // the leaked suffix exposes name length + last character beside the + // `«PERSON_N»` token. + return extendHitsToWordBoundary(text, results.flat()) as PrivacyDetectorHit[]; } /** @@ -745,10 +1402,11 @@ function renderReplacement( ): string { switch (action) { case 'tokenized': - // Slice 2.2: pass the detector hit type as a typeHint so the - // minted token carries a `_<type>` suffix (`tok_a1b2c3d4_name`, - // `tok_e5f6g7h8_email`, …). The LLM can infer the placeholder - // kind from the suffix without seeing the value. + // Privacy-Shield v2: minted token carries an uppercase display + // type (`«PERSON_1»`, `«EMAIL_2»`, `«IBAN_3»`). The LLM can + // infer the placeholder kind from the type label without seeing + // the value, and the readable shape resists paraphrase pressure + // in Markdown-table / bulleted-list output. return map.tokenFor(hit.value, hit.type); case 'redacted': return `[REDACTED:${labelForType(hit.type)}]`; @@ -766,97 +1424,208 @@ function labelForType(t: string): string { } // --------------------------------------------------------------------------- -// Slice 2.2 — System-prompt directive injection. +// Privacy-Shield v2 — System-prompt directive injection. +// +// The privacy shield tokenises PII to readable `«TYPE_N»` placeholders +// before the payload reaches the public LLM. Without context, the LLM +// treats them as unknown identifiers and refuses to call tools +// (defensive "I don't know who «PERSON_1» is, please clarify"). This +// helper splices a short directive into the system prompt so the LLM +// understands tokens are transparent and SHOULD be passed verbatim as +// tool arguments — the shield restores them deterministically before +// tool execution and re-tokenises any new PII in tool results. // -// The privacy proxy tokenises PII in user inputs to `tok_<hex>` placeholders -// before the payload reaches the public LLM. Without context, the LLM treats -// these tokens as unknown identifiers and refuses to call tools (defensive -// "I don't know who tok_a3f9 is, please clarify"). This helper splices a -// short directive into the system prompt so the LLM understands tokens are -// transparent and SHOULD be passed verbatim as tool arguments — the proxy -// restores them deterministically before tool execution and re-tokenises any -// new PII in tool results (Slice 2.2 Part B). +// Slice S-1 landed the new readable token format. Slice S-4 extends +// this directive with: +// - explicit Markdown-table and bulleted-list examples (Example 4 + 5) +// so the LLM keeps tokens verbatim under format pressure; +// - a CRITICAL warning against paraphrasing tokens in user-facing +// output — the 2026-05-14 HR-routine failure mode where the LLM +// invented plausible employee names instead of emitting tokens; +// - a degenerate-case rule: if the user message is mostly tokens +// (Token-Storm, e.g. after a tenant-self FP cascade), do not +// anchor on prior conversation tail — ask for clarification. +// Post-deploy 2026-05-14 adds a second failure mode caught live: the +// LLM was self-anonymizing tokens to invented labels like +// "Mitarbeiter 1/2/3" AND appending a DSGVO disclaimer about why +// names were "withheld". Example 6 + a new CRITICAL block close that. // -// Idempotent via marker check: if the directive is already present (e.g. the -// caller invokes `processOutbound` twice on the same systemPrompt within a -// turn), it is not prepended again. Empty system prompts are left untouched -// so trivial test fixtures stay byte-identical. +// Idempotent via marker check: if the directive is already present +// (e.g. the caller invokes `processOutbound` twice on the same +// systemPrompt within a turn), it is not prepended again. Empty system +// prompts are left untouched so trivial test fixtures stay byte-identical. // --------------------------------------------------------------------------- const PRIVACY_PROXY_DIRECTIVE_MARKER = '<privacy-proxy-directive>'; const PRIVACY_PROXY_DIRECTIVE = `${PRIVACY_PROXY_DIRECTIVE_MARKER} -A privacy proxy sits between this conversation and the public LLM. It +A privacy shield sits between this conversation and the public LLM. It replaces real user PII (names, e-mails, phone numbers, IBANs, addresses, -IDs, …) with stable opaque placeholders before the message reaches you, +IDs, …) with stable readable placeholders before the message reaches you, and restores them on the way back. You will see placeholders of the form -\`tok_<8 hex>_<type>\` — for example \`tok_a1b2c3d4_name\`, -\`tok_e5f6a7b8_email\`, \`tok_12345678_iban\`. The type suffix names -what kind of value is hidden: - - - \`_name\` → a real person's name (employee, contact, …) - - \`_email\` → a real e-mail address - - \`_phone\` → a real phone number - - \`_iban\` → a real bank account number - - \`_credit_card\` → a real credit-card number - - \`_address\` → a real postal address - - \`_location\` → a real geographic location - - \`_organization\` → a real organisation / company name - - any other \`_<type>\` follows the same pattern +\`«TYPE_N»\` — for example \`«PERSON_1»\`, \`«EMAIL_2»\`, \`«IBAN_3»\`, +\`«ADDRESS_1»\`, \`«CARD_1»\`. The TYPE part names the kind of value +masked and N is a fortlaufende counter unique within the current turn: + + - \`«PERSON_N»\` → a real person's name (employee, contact, …) + - \`«EMAIL_N»\` → a real e-mail address + - \`«PHONE_N»\` → a real phone number + - \`«IBAN_N»\` → a real bank account number + - \`«CARD_N»\` → a real credit-card number + - \`«ADDRESS_N»\` → a real postal address or geographic location + - \`«ORG_N»\` → a real organisation / company name + - \`«APIKEY_N»\` → a real API key / secret token + - any other \`«<TYPE>_N»\` follows the same pattern CRITICAL behavioural rules — these override any default reluctance to act on opaque identifiers: - 1. A \`tok_…_<type>\` placeholder IS real user data — just masked on - the wire. It is NOT a hallucination, NOT a test fixture, NOT a - stand-in to clarify. The user typed the actual value; the proxy + 1. A \`«TYPE_N»\` placeholder IS real user data — just masked on the + wire. It is NOT a hallucination, NOT a test fixture, NOT a + stand-in to clarify. The user typed the actual value; the shield replaced it. 2. When the user's request requires the data the placeholder hides, - pass the placeholder verbatim as a tool argument. The proxy + pass the placeholder verbatim as a tool argument. The shield restores it to the original value before the tool handler runs; - the handler always sees plaintext. The proxy then re-tokenises + the handler always sees plaintext. The shield then re-tokenises any fresh PII in the tool result before sending it back to you. - 3. Never ask the user to "clarify" a \`tok_…\` value, never refuse a - tool call because the input contains a placeholder, and never + 3. Never ask the user to "clarify" a \`«TYPE_N»\` value, never refuse + a tool call because the input contains a placeholder, and never invent an identity for a placeholder. Doing so blocks legitimate requests for which the user has already provided everything needed. 4. If the conversation history (memory recalls, prior turns, - bootstrap messages) refers to the user as \`tok_<hex>_name\`, - that IS the active user. Treat statements like "the user is - tok_a1b2c3d4_name" as binding identity facts. + bootstrap messages) refers to the user as \`«PERSON_N»\`, that + IS the active user. Treat statements like "the user is + «PERSON_1»" as binding identity facts. EXAMPLE INTERACTIONS (synthetic tokens, no real data; \`<…_tool>\` is a placeholder for whatever appropriately-named tool is actually registered in this session): Example 1 — name lookup: - user: "Wie viele Urlaubstage hat tok_a1b2c3d4_name 2025 genommen?" + user: "Wie viele Urlaubstage hat «PERSON_1» 2025 genommen?" assistant (correct): calls <hr_lookup_tool> with input - { "name": "tok_a1b2c3d4_name", "year": 2025 } - — proxy restores "tok_a1b2c3d4_name" to the actual employee + { "name": "«PERSON_1»", "year": 2025 } + — shield restores "«PERSON_1»" to the actual employee name before the handler executes. - assistant (WRONG): replies "ich rate nicht, wer ist tok_a1b2c3d4_name?" + assistant (WRONG): replies "ich rate nicht, wer ist «PERSON_1»?" — never do this. Example 2 — outbound message: - user: "Schick die Zusammenfassung an tok_e5f6a7b8_email" + user: "Schick die Zusammenfassung an «EMAIL_1»" assistant (correct): calls <send_mail_tool> with input - { "to": "tok_e5f6a7b8_email", "subject": "...", "body": "..." } + { "to": "«EMAIL_1»", "subject": "...", "body": "..." } assistant (WRONG): asks "an welche Adresse genau?" — never do this. Example 3 — bank transfer reference: - user: "Wie hoch war die letzte Buchung auf tok_12345678_iban?" + user: "Wie hoch war die letzte Buchung auf «IBAN_1»?" assistant (correct): calls <accounting_lookup_tool> with input - { "iban": "tok_12345678_iban" } + { "iban": "«IBAN_1»" } assistant (WRONG): "Bitte nenne mir die echte IBAN." — never do this. -In all three examples the assistant proceeds with the tool call. That -is the required behaviour: tokens always flow through to tools verbatim; -the proxy handles restoration and re-tokenisation deterministically, so -the user sees a coherent answer with their original data restored. + Example 4 — tabular tool result (CRITICAL — read this carefully): + tool result from <hr_absences_tool>: + [ + { "name": "«PERSON_1»", "department": "Backend", + "absent_since": "2026-05-06" }, + { "name": "«PERSON_2»", "department": "Backend", + "absent_since": "2026-05-14" }, + { "name": "«PERSON_3»", "department": "Backend", + "absent_since": "2026-05-11" } + ] + assistant (correct) — emits tokens VERBATIM in each table cell: + | Name | Department | Absent since | + |-------------|------------|--------------| + | «PERSON_1» | Backend | 2026-05-06 | + | «PERSON_2» | Backend | 2026-05-14 | + | «PERSON_3» | Backend | 2026-05-11 | + — the shield restores «PERSON_1»/«PERSON_2»/«PERSON_3» to the + real employee names AFTER you finish; the user sees a clean + table with their actual data. + assistant (WRONG) — invents plausible names for the cells: + | Name | Department | Absent since | + |----------------|------------|--------------| + | Max Mustermann | Backend | 2026-05-06 | ← INVENTED + | Erika Beispiel | Backend | 2026-05-14 | ← INVENTED + | Hans Test | Backend | 2026-05-11 | ← INVENTED + — fabricated data the shield cannot restore. The user receives + plausible-looking but completely false names. + + Example 5 — bulleted list of tool result items: + tool result: [ "«EMAIL_1»", "«EMAIL_2»", "«EMAIL_3»" ] + assistant (correct): + - «EMAIL_1» + - «EMAIL_2» + - «EMAIL_3» + assistant (WRONG): "- max@example.com\\n- erika@example.com\\n…" + — never invent or paraphrase tokens, even in a list. + +CRITICAL — token-paraphrase produces data corruption: + +Tokens are the ONLY identity marker the shield can resolve. If you +paraphrase, abbreviate, translate, summarise, or invent values to +replace tokens in your user-facing response, the shield CANNOT restore +them and the user receives FABRICATED data. This is worse than refusing +to answer. Specifically: + + - In a Markdown table cell, emit the token verbatim, even if the + column header is "Name" and the cell would "look prettier" with a + human-readable string. + - In a bulleted list, emit each token as its own bullet item. + - In a sentence, emit the token where the real value would go + ("«PERSON_1» ist heute abwesend") — never wrap it in extra prose + that paraphrases it. + - In a code block, JSON snippet, or quoted string, the same rule + applies: tokens verbatim. + +CRITICAL — do not self-anonymize, no privacy disclaimers: + +Your job with tokens is to pass them through verbatim. You are NOT the +privacy actor — the shield is. Specifically: + + - Never replace \`«PERSON_N»\` with self-invented labels like + "Mitarbeiter 1", "Employee A", or "Person X". Use the literal token. + - Never append a privacy / DSGVO / GDPR disclaimer explaining why + names were "withheld" or "filtered". The user already knows. + + Example 6 — observed live (HR routine, 2026-05-14): + tool result: [ { "name": "«PERSON_1»" }, { "name": "«PERSON_2»" } ] + assistant (WRONG): + | Mitarbeiter 1 | … ← invented, not the token + | Mitarbeiter 2 | … ← invented, not the token + ⚠️ Namen aus Datenschutzgründen nicht ausgegeben. ← do not write + assistant (correct): + | «PERSON_1» | … + | «PERSON_2» | … + — tokens verbatim; the shield restores after you finish. + +Degenerate-case handling — Token-Storm: + +If MORE THAN HALF of the user message consists of \`«TYPE_N»\` tokens +(i.e. the message is mostly placeholders with very little non-token +text), the user message is degenerate — most likely a false-positive +detection cascade. In that case: + + - Do NOT anchor on prior conversation tail or invent context. + - Do NOT call a tool with the token soup. + - Respond with a single clarifying question, e.g. "Bitte präzisiere + deine Anfrage — ich konnte deine Frage nicht eindeutig deuten." + - Then stop. + +Example of degenerate input: + user: "«PERSON_1» bei «ORG_1»?" (and that's the entire message) + assistant (correct): "Bitte präzisiere deine Anfrage." + assistant (WRONG): inferring intent from earlier turns and calling + an unrelated tool with the leftover token soup. + +In all examples above the assistant proceeds verbatim with tokens (or +declines, in the degenerate case). That is the required behaviour: +tokens flow through verbatim; the shield handles restoration and +re-tokenisation deterministically, so the user sees a coherent answer +with their original data restored. </privacy-proxy-directive> `; diff --git a/middleware/packages/harness-plugin-privacy-guard/src/spanHelpers.ts b/middleware/packages/harness-plugin-privacy-guard/src/spanHelpers.ts new file mode 100644 index 000000000..6f3220884 --- /dev/null +++ b/middleware/packages/harness-plugin-privacy-guard/src/spanHelpers.ts @@ -0,0 +1,69 @@ +/** + * Detector-hit span post-processing helpers. + * + * Some detectors — notably Presidio's spaCy `de_core_news_lg` NER — + * systematically truncate German compound names by one character. + * Examples observed on dev: + * "Christoph Schmidt" → detector reports "Christoph Schmid", leaving + * the final `t` next to the token. + * "Marcel Wege" → detector reports "Marcel Weg", leaving the + * final `e`. + * + * The leaked suffix is a privacy bug: the masked token (`«PERSON_N»`) + * is supposed to hide the entire name, but a stray letter exposes the + * name's length + last character. Extending the hit's span forward + * through any adjacent Unicode word characters absorbs the trailing + * remnant into the masked region. + * + * Symmetric backward extension is intentionally NOT done here — the + * observed failure mode is always a tail-truncation. Backward + * extension would risk swallowing legitimate preceding characters + * (titles like "Dr.", or the previous word in a compound). + */ + +export interface SpanLike { + readonly span: readonly [number, number]; + readonly value: string; +} + +const WORD_CHAR = /[\p{L}\p{N}_]/u; + +/** + * Extend a single hit's span forward until the next non-word + * character (or end-of-input). Returns the original object reference + * when no extension is needed. + */ +export function extendHitSpanForward<T extends SpanLike>(text: string, hit: T): T { + const [start, end] = hit.span; + if (end >= text.length) return hit; + if (!WORD_CHAR.test(text[end] ?? '')) return hit; + let cursor = end; + while (cursor < text.length && WORD_CHAR.test(text[cursor] ?? '')) { + cursor += 1; + } + if (cursor === end) return hit; + return { + ...hit, + span: [start, cursor] as const, + value: text.slice(start, cursor), + }; +} + +/** + * Apply forward word-boundary extension to every hit in a batch. + * Safe to call on empty arrays. Preserves order. + */ +export function extendHitsToWordBoundary<T extends SpanLike>( + text: string, + hits: readonly T[], +): readonly T[] { + if (hits.length === 0 || text.length === 0) return hits; + let changed = false; + const out: T[] = []; + for (const hit of hits) { + const next = extendHitSpanForward(text, hit); + if (next !== hit) changed = true; + out.push(next); + } + return changed ? out : hits; +} diff --git a/middleware/packages/harness-plugin-privacy-guard/src/tokenizeMap.ts b/middleware/packages/harness-plugin-privacy-guard/src/tokenizeMap.ts index c64b9f67a..b52e5ae6f 100644 --- a/middleware/packages/harness-plugin-privacy-guard/src/tokenizeMap.ts +++ b/middleware/packages/harness-plugin-privacy-guard/src/tokenizeMap.ts @@ -1,103 +1,168 @@ /** - * Per-session tokenise-map. + * Per-turn tokenise-map (Privacy-Shield v2, Slice S-2). * - * In-memory `Map<originalValue, token>` scoped to a single chat session. - * Same value within the same session always gets the same token, so an - * email mentioned twice does not look like two different addresses to - * the LLM (which would damage cross-reference coherence in the answer). + * In-memory `Map<originalValue, token>` scoped to a single orchestrator + * turn. Same value within the same map always returns the same token, + * so an email mentioned twice within one turn does not look like two + * different addresses to the LLM — intra-turn cross-reference coherence + * in the answer stays intact. Across turns, tokens are NOT preserved: + * coherence comes from the assistant-tail (real values after restore), + * not from stable token names. * - * Slice 2.4 hardens this: - * - AES-256 in-memory encryption of the original values - * - Conversation-scoped lifetime with 15-min idle TTL - * - Explicit destroy on `session.end` + * Lifecycle: + * - The service mints a map on the first `processOutbound` / + * `processToolResult` call of a turn. + * - The map is shared between outbound, tool-input, tool-result and + * inbound calls of the SAME turn — that's what gives intra-turn + * reconciliation (a name from the user prompt and the same name + * from a tool result get the same token). + * - `finalizeTurn(turnId)` drops the map together with the + * accumulator. After that the PII bindings are eligible for GC. * - * Slice 2.2 (Option B): tokens carry an inline type suffix - * (`tok_<8 hex>_<type>`) so the LLM can infer the kind of placeholder - * (name / email / iban / …) without seeing the value. This is what - * stops the public LLM from defensively asking "wer ist tok_a3f9?" - * when the user typed a real employee name. The type is derived from - * the detector hit (`pii.email` → `email`, `pii.name` → `name`, - * `business.contract_clause` → `contract_clause`, …) and kept short - * (max 20 chars, lowercase, [a-z0-9_]). + * Token format (Privacy-Shield v2): * - * Privacy property: the type is information the user already disclosed - * by entering the value (they typed an email, so the LLM seeing "this - * is an email-shaped token" reveals nothing new). The token's hex - * portion remains the unique identity carrier; suffixes do not leak - * across map entries. + * «TYPE_N» — French guillemets wrap an uppercase display type and a + * fortlaufende counter, both unique within the map. + * + * Examples: «PERSON_1», «EMAIL_2», «IBAN_3», «CONTRACT_CLAUSE_4». + * + * Why this format (vs. the v1 `tok_<8 hex>_<type>`): + * + * - LLM-friendly: «PERSON_3» reads like a normal table-cell value, so + * under Markdown-table or bulleted-list output pressure the model + * keeps the token verbatim instead of paraphrasing it into an + * invented name (the 2026-05-14 HR-routine failure mode). + * - Regex-unambiguous: French guillemets (U+00AB / U+00BB) do not + * appear in normal German or English text, so the restore regex + * never over- or under-matches. + * - Type-hint readable: PERSON / EMAIL / IBAN / ADDRESS / CARD / + * APIKEY surfaces the kind of value without exposing the value. + * - Counter aids audit: ordering in the output corresponds to + * ordering in the detection pass. + * + * Privacy property: the type carries information the user already + * disclosed by entering the value (typing an email reveals that the + * shape is an email); revealing it as a type-label to the LLM adds no + * new disclosure beyond what the user already chose. The counter is + * map-local and carries no cross-session identity. */ -import { randomBytes } from 'node:crypto'; - export interface TokenizeMap { /** Get an existing token for `value` if present, else mint a new one * and remember the binding. Always returns the same token for the - * same value within one map. The optional `typeHint` tags the minted - * token with a short type suffix (`name`, `email`, …) so the LLM - * can recognise the placeholder kind. Re-using an existing value - * always returns the previously-minted token regardless of the - * typeHint passed on the second call — mapping is by value only. */ + * same value within one map — mapping is by value only; the + * `typeHint` only steers the initial mint and is ignored on + * subsequent lookups. */ tokenFor(value: string, typeHint?: string): string; /** Look up the original value behind a token. `undefined` for unknown - * tokens so the caller can decide between leave-as-is (Slice 2 inbound - * restore policy) and erroring. */ + * tokens so the caller can decide what to do (Output Validator + * flags hallucinated tokens, restoreTokens leaves them in place). */ resolve(token: string): string | undefined; - /** Drop all bindings; safe to call on an already-empty map. Slice 1b - * uses this in tests; the orchestrator will call it at turn-end in - * Slice 2. */ + /** Privacy-Shield v2 (Slice S-5): reverse predicate used by the + * Output Validator to distinguish "PII the LLM produced spontaneously" + * from "PII that came back via token-restore". Returns `true` iff + * the value was ever tokenised in this map. */ + hasOriginalValue(value: string): boolean; + /** Drop all bindings; safe to call on an already-empty map. */ clear(): void; - /** Number of unique values currently mapped. Test-only convenience. */ + /** Number of unique values currently mapped. Also the count of + * distinct tokens minted this turn — used by the Output Validator + * as the denominator for the token-loss ratio. */ readonly size: number; } /** - * Token format: `tok_<8 hex>_<type suffix>`. + * Token format regex: `«` + uppercase letters + optional `_`-separated + * uppercase tail + `_` + counter + `»`. * - * Suffix is `[a-z0-9_]+` capped to a small length budget. Word - * boundaries on either side keep the regex from over-matching when a - * token sits next to non-word characters (period, comma, paren, …). + * Examples that match: «PERSON_1», «EMAIL_42», «CREDIT_CARD_3». + * Examples that don't match: «person_1», «PERSON», «PERSON_», «PERSON 1». * - * Backwards-compat note: pre-Slice-2.2 sessions emitted bare - * `tok_<8 hex>` tokens. The new regex does NOT match those, which is - * fine — sessions are session-scoped and a fresh boot mints fresh - * tokens. Production rollout simply happens after a deploy. + * Backwards-compat note: v1 emitted `tok_<8 hex>_<type>` tokens. The new + * regex does NOT match those — token maps are turn-scoped and a fresh + * boot has no in-flight v1 tokens. The fast-reject in `restoreTokens` + * switches from `'tok_'` to `'«'` accordingly. */ -export const TOKEN_REGEX = /\btok_[0-9a-f]{8}_[a-z0-9_]{1,30}\b/g; +export const TOKEN_REGEX = /«[A-Z][A-Z_]*_\d+»/g; -/** Cheap detector: does this string look like one of our tokens? Used - * by Slice 2's inbound-restore + hallucination re-scan. */ +/** Cheap detector: does this string look like one of our tokens? */ export function isToken(s: string): boolean { - return /^tok_[0-9a-f]{8}_[a-z0-9_]{1,30}$/.test(s); + return /^«[A-Z][A-Z_]*_\d+»$/.test(s); } /** - * Slice 2.2: derive a short, LLM-readable suffix from a detector hit - * type. `pii.email` → `email`, `pii.credit_card` → `credit_card`, - * `business.contract_clause` → `contract_clause`. Unknown / falsy - * types collapse to `value`. + * Map a detector hit type (e.g. `pii.name`, `pii.credit_card`, + * `business.contract_clause`) to the uppercase display name that + * appears inside the token wrapper. + * + * Known PII classes are mapped to short, memorable names (PERSON, + * EMAIL, …). Unknown types collapse to a cleaned uppercase form of + * their namespace tail, which keeps audit information legible while + * staying within the regex grammar. * - * Length is capped at 20 chars; the regex enforces a 30-char ceiling - * but staying well under that keeps surface text readable. + * Length is capped at 30 chars so the token wrapper stays compact in + * Markdown-table cells. Empty / malformed input falls back to `PII`. */ -export function sanitizeTypeHint(typeHint: string | undefined): string { - if (typeHint === undefined || typeHint.length === 0) return 'value'; - // Strip the namespace prefix (everything up to and including the - // first `.`). Then lowercase and keep only [a-z0-9_]. +export function displayTypeFor(typeHint: string | undefined): string { + if (typeHint === undefined || typeHint.length === 0) return 'PII'; + + // Strip namespace prefix: keep only the tail after the first `.`. const dot = typeHint.indexOf('.'); const tail = dot >= 0 ? typeHint.slice(dot + 1) : typeHint; - const cleaned = tail.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, ''); - if (cleaned.length === 0) return 'value'; - return cleaned.slice(0, 20); + + const cleaned = tail + .toUpperCase() + .replace(/[^A-Z0-9_]+/g, '_') + .replace(/^_+|_+$/g, ''); + if (cleaned.length === 0) return 'PII'; + + const mapped = KNOWN_TYPE_DISPLAY[cleaned] ?? cleaned; + return mapped.slice(0, 30); } +/** Canonical short display names for known PII classes. Unknown + * classes pass through as their cleaned-uppercase form. */ +const KNOWN_TYPE_DISPLAY: Readonly<Record<string, string>> = { + NAME: 'PERSON', + PERSON: 'PERSON', + EMAIL: 'EMAIL', + EMAIL_ADDRESS: 'EMAIL', + PHONE: 'PHONE', + PHONE_DE: 'PHONE', + PHONE_NUMBER: 'PHONE', + IBAN: 'IBAN', + IBAN_CODE: 'IBAN', + CREDIT_CARD: 'CARD', + CARD: 'CARD', + ADDRESS: 'ADDRESS', + LOCATION: 'ADDRESS', + GPE: 'ADDRESS', + ORGANIZATION: 'ORG', + ORG: 'ORG', + API_KEY: 'APIKEY', + APIKEY: 'APIKEY', + IP_ADDRESS: 'IP', + IP: 'IP', + CRYPTO_ADDRESS: 'CRYPTO', + CRYPTO: 'CRYPTO', + SSN: 'SSN', +}; + class InMemoryTokenizeMap implements TokenizeMap { private readonly forward = new Map<string, string>(); private readonly reverse = new Map<string, string>(); + /** Per-display-type fortlaufende counter. `«PERSON_1»`, `«PERSON_2»`, + * but `«EMAIL_1»` starts independently — readability over global + * ordering. */ + private readonly counters = new Map<string, number>(); tokenFor(value: string, typeHint?: string): string { const existing = this.forward.get(value); if (existing !== undefined) return existing; - const token = mintToken(typeHint); + const type = displayTypeFor(typeHint); + const next = (this.counters.get(type) ?? 0) + 1; + this.counters.set(type, next); + const token = `«${type}_${String(next)}»`; this.forward.set(value, token); this.reverse.set(token, value); return token; @@ -107,9 +172,14 @@ class InMemoryTokenizeMap implements TokenizeMap { return this.reverse.get(token); } + hasOriginalValue(value: string): boolean { + return this.forward.has(value); + } + clear(): void { this.forward.clear(); this.reverse.clear(); + this.counters.clear(); } get size(): number { @@ -120,8 +190,3 @@ class InMemoryTokenizeMap implements TokenizeMap { export function createTokenizeMap(): TokenizeMap { return new InMemoryTokenizeMap(); } - -function mintToken(typeHint?: string): string { - const suffix = sanitizeTypeHint(typeHint); - return `tok_${randomBytes(4).toString('hex')}_${suffix}`; -} diff --git a/middleware/packages/harness-plugin-quality-guard/manifest.yaml b/middleware/packages/harness-plugin-quality-guard/manifest.yaml index d4b45f291..ebf114172 100644 --- a/middleware/packages/harness-plugin-quality-guard/manifest.yaml +++ b/middleware/packages/harness-plugin-quality-guard/manifest.yaml @@ -9,8 +9,8 @@ identity: description: "Response-quality core plugin. Publishes the `responseGuard@1` capability with sycophancy-levels (off/low/medium/high) and a boundary-preset library that splices guardrails into the system prompt ahead of body prose." authors: - name: "byte5 GmbH" - email: "dev@byte5.de" - url: "https://byte5.de" + email: "info@omadia.ai" + url: "https://omadia.ai" license: "MIT" categories: - "quality" diff --git a/middleware/packages/harness-plugin-web-search/manifest.yaml b/middleware/packages/harness-plugin-web-search/manifest.yaml index 7f4870cc7..0996c9390 100644 --- a/middleware/packages/harness-plugin-web-search/manifest.yaml +++ b/middleware/packages/harness-plugin-web-search/manifest.yaml @@ -9,8 +9,8 @@ identity: description: "Live web-search core plugin (Tavily / Brave). Publishes the `webSearch@1` capability and a `web_search` native tool with structured citation objects." authors: - name: "byte5 GmbH" - email: "dev@byte5.de" - url: "https://byte5.de" + email: "info@omadia.ai" + url: "https://omadia.ai" license: "MIT" categories: - "search" diff --git a/middleware/packages/harness-plugin-web-search/src/searchTool.ts b/middleware/packages/harness-plugin-web-search/src/searchTool.ts index 2e9f87e72..c0b493045 100644 --- a/middleware/packages/harness-plugin-web-search/src/searchTool.ts +++ b/middleware/packages/harness-plugin-web-search/src/searchTool.ts @@ -78,7 +78,7 @@ export const searchToolSpec: NativeToolSpec = { site: { type: 'string', description: - 'Restrict results to a single domain, e.g. `byte5.de`. Translates to a `site:` filter when the provider lacks a native flag.', + 'Restrict results to a single domain, e.g. `omadia.ai`. Translates to a `site:` filter when the provider lacks a native flag.', }, include_content: { type: 'boolean', diff --git a/middleware/packages/harness-ui-helpers/README.md b/middleware/packages/harness-ui-helpers/README.md new file mode 100644 index 000000000..da1ce0182 --- /dev/null +++ b/middleware/packages/harness-ui-helpers/README.md @@ -0,0 +1,139 @@ +# `@omadia/plugin-ui-helpers` + +> Minimal SSR-Helper, damit Plugins eigenes HTML unter +> `/p/<pluginId>/...` ausliefern können — **ohne** React, **ohne** +> Build-Step, **ohne** Asset-Bundling. Tagged-Template-Literal + +> Tailwind-CDN + iframe-safe CSP. + +Plugin-Autoren brauchen drei Dinge: + +| Export | Zweck | +|---|---| +| `html` | Tagged Template Literal. Interpolationen werden HTML-escaped (Default-XSS-defense). Verschachtelte `html\`...\`` und `safe(...)` Fragments bleiben unescaped. | +| `htmlDoc({title, body, refreshSeconds?, ...})` | Wraps ein `HtmlFragment` in vollständiges HTML5-Doc mit Tailwind-CDN. `refreshSeconds` triggert `<meta http-equiv="refresh">` — der einfachste „Self-Filling-Tab"-Pfad. | +| `renderRoute(handler)` | Adapter von `(ctx) → HTML-string` zu Express-`RequestHandler`. Setzt iframe-safe CSP-Header (`frame-ancestors *.teams.microsoft.com / *.office.com`) und `X-Content-Type-Options: nosniff`. | + +## Minimal-Beispiel + +```ts +import { Router } from 'express'; +import { html, htmlDoc, renderRoute, safe } from '@omadia/plugin-ui-helpers'; + +export function createDashboardRouter(opts: { notes: NotesStore }): Router { + const router = Router(); + + router.get( + '/dashboard', + renderRoute(async () => { + const notes = await opts.notes.list(); + return htmlDoc({ + title: 'My Plugin', + refreshSeconds: 30, // Self-Filling: auto-reload alle 30s + body: html` + <main class="max-w-2xl mx-auto p-6 space-y-4"> + <h1 class="text-2xl font-semibold">Notes (${notes.length})</h1> + ${notes.length === 0 + ? safe('<p class="text-sm text-slate-500">No notes yet.</p>') + : html` + <ul class="space-y-2"> + ${notes.map( + (n) => html`<li class="border p-2 rounded">${n.body}</li>`, + )} + </ul> + `} + </main> + `, + }); + }), + ); + + return router; +} +``` + +In `activate()`: + +```ts +const dashRouter = createDashboardRouter({ notes }); +const disposeDash = ctx.routes.register('/p/my-plugin', dashRouter); +ctx.uiRoutes.register({ + routeId: 'dashboard', + path: '/dashboard', + title: 'My Plugin — Dashboard', + order: 50, +}); +``` + +Resultierende URL: `https://<harness>/p/my-plugin/dashboard`. + +## XSS-Defense (Default-on) + +`html\`\`` behandelt **jede** Interpolation als untrusted Text und HTML- +escaped sie. Beispiel: + +```ts +html`<div>${userInput}</div>` +// userInput = '<script>alert(1)</script>' +// → "<div><script>alert(1)</script></div>" +``` + +Opt-out via `safe(rawHtml)` wenn du nested-fragment-Output einbettest, der +bereits eskapt ist (z.B. von einem anderen `html\`\``-Call): + +```ts +const inner = html`<em>bold</em>`; +html`<p>${inner}</p>` // works — inner ist HtmlFragment, kein String +html`<p>${safe('<em>bold</em>')}</p>` // explicit opt-out, vorsicht +``` + +## iframe-Safe CSP + +`renderRoute()` setzt automatisch: + +``` +Content-Security-Policy: + default-src 'self' https: data: blob:; + img-src 'self' https: data: blob:; + style-src 'self' 'unsafe-inline' https:; + script-src 'self' 'unsafe-inline' https:; + frame-ancestors 'self' + https://*.teams.microsoft.com + https://teams.microsoft.com + https://*.office.com + https://*.microsoft365.com +X-Content-Type-Options: nosniff +``` + +`'unsafe-inline'` in `style-src` ist nötig, weil Tailwind via CDN-Script +JIT-CSS-Klassen zur Laufzeit emittiert. Für Production mit gebautem +Tailwind-Bundle kann man später strikter werden. + +## Self-Filling-Tabs + +`refreshSeconds: 30` emittiert einen `<meta http-equiv="refresh">`-Tag. +Der Browser reloadet die Seite alle N Sekunden — der SSR-Handler läuft +frisch gegen die aktuelle Datenlage, kein Client-JS nötig. + +Reicht für **„zeig mir die neuesten Daten"**-Use-Cases. Für richer +Scenarios (Sub-Sekunde-Updates, Form-Inputs überleben, kein +Scroll-Reset) später Polling-Fetch + DOM-Swap einbauen — aber für eine +MVP-Tab-Surface ist die 1-Zeilen-Lösung perfekt. + +## Was NICHT drin ist + +- Keine Komponenten-Library (Card, Button, Table) — Plugins composen aus + Tailwind-Klassen direkt. Wenn ein gemeinsamer Bedarf entsteht: separates + `@omadia/plugin-ui-components`-Paket dazu. +- Kein Client-Side-State / Reactivity. Pure SSR. Interaktion via Forms + + Server-Side-Handler. +- Kein i18n. Plugins können das selbst lösen (z.B. `next-intl`-style mit + einem `t(key)`-Helper aus dem ctx). +- Kein Auth innerhalb des Helpers. Plugin-Routes laufen ohne Session-Cookie + (siehe `requireAuth` publicPaths in Notion-Doku 14); wer sensible Daten + ausgibt, validiert den Teams-SSO-Token im eigenen Handler. + +## Versionierung + +Backwards-compatible Add-ons (neue optionale `htmlDoc`-Option, neuer Export) +sind Patch-Bumps. Breaking changes an der `renderRoute`/`html`-Surface +würden Minor-Bumps. Major bleibt für API-Reshape (z.B. Wechsel auf JSX/React). diff --git a/middleware/packages/harness-ui-helpers/package.json b/middleware/packages/harness-ui-helpers/package.json new file mode 100644 index 000000000..b95bee9d8 --- /dev/null +++ b/middleware/packages/harness-ui-helpers/package.json @@ -0,0 +1,34 @@ +{ + "name": "@omadia/plugin-ui-helpers", + "version": "0.2.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "description": "UI-helpers for plugin-served HTML/React routes. Tailwind-CDN HTML wrapper + iframe-safe CSP + library templates (list-card, kpi-tiles) + React-SSR helper.", + "license": "MIT", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "peerDependencies": { + "express": "^5.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "peerDependenciesMeta": { + "react": { "optional": true }, + "react-dom": { "optional": true } + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "engines": { + "node": ">=20" + } +} diff --git a/middleware/packages/harness-ui-helpers/src/document.ts b/middleware/packages/harness-ui-helpers/src/document.ts new file mode 100644 index 000000000..5eab600b8 --- /dev/null +++ b/middleware/packages/harness-ui-helpers/src/document.ts @@ -0,0 +1,66 @@ +import { escapeHtml, type HtmlFragment } from './html.js'; + +export interface HtmlDocOptions { + title: string; + body: HtmlFragment; + lang?: string; + /** Inline <style> block injected into <head>, after Tailwind. */ + inlineCss?: string; + /** Tailwind delivery strategy. CDN is fine for SSR-only sketch phase; + * swap to a built bundle for production. */ + tailwind?: 'cdn' | 'none'; + /** + * If set, emits a `<meta http-equiv="refresh" content="N">` so the + * browser re-fetches the page every N seconds. This is the simplest + * "self-filling tab" mechanism — the SSR handler renders fresh data + * each request, so a periodic reload surfaces new state without any + * client-side JS. Set to a positive integer (seconds); omit for + * static pages. + * + * Note: meta-refresh resets scroll position and any client form + * state. For richer scenarios swap in a polling fetch+swap pattern + * later — but for an MVP "show me the latest data" Tab this is + * one line and zero deps. + */ + refreshSeconds?: number; +} + +const TAILWIND_CDN_URL = 'https://cdn.tailwindcss.com'; + +/** + * Wraps a body fragment in a minimal HTML5 document. Tailwind CDN is loaded + * by default so plugin authors can sketch UIs with utility classes without + * a per-plugin build step. For production, switch `tailwind: 'none'` and + * provide a built CSS bundle via inlineCss or a separate route. + */ +export function htmlDoc(options: HtmlDocOptions): string { + const lang = options.lang ?? 'en'; + const title = escapeHtml(options.title); + const tailwindTag = + options.tailwind === 'none' + ? '' + : `<script src="${TAILWIND_CDN_URL}"></script>`; + const inlineCssTag = options.inlineCss + ? `<style>${options.inlineCss}</style>` + : ''; + const refresh = + typeof options.refreshSeconds === 'number' && options.refreshSeconds > 0 + ? `<meta http-equiv="refresh" content="${Math.floor(options.refreshSeconds)}">` + : ''; + return [ + '<!doctype html>', + `<html lang="${escapeHtml(lang)}">`, + '<head>', + '<meta charset="utf-8">', + '<meta name="viewport" content="width=device-width,initial-scale=1">', + refresh, + `<title>${title}`, + tailwindTag, + inlineCssTag, + '', + '', + options.body.value, + '', + '', + ].join(''); +} diff --git a/middleware/packages/harness-ui-helpers/src/html.ts b/middleware/packages/harness-ui-helpers/src/html.ts new file mode 100644 index 000000000..92d6d031b --- /dev/null +++ b/middleware/packages/harness-ui-helpers/src/html.ts @@ -0,0 +1,63 @@ +declare const _htmlFragmentBrand: unique symbol; + +export interface HtmlFragment { + readonly __brand: typeof _htmlFragmentBrand; + readonly value: string; +} + +const ESCAPE_MAP: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +export function escapeHtml(input: unknown): string { + if (input === null || input === undefined) return ''; + const str = typeof input === 'string' ? input : String(input); + return str.replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch] ?? ch); +} + +/** Mark a string as pre-escaped HTML that html`` should NOT re-escape. */ +export function safe(value: string): HtmlFragment { + return { value } as HtmlFragment; +} + +function isFragment(value: unknown): value is HtmlFragment { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { value?: unknown }).value === 'string' && + Object.keys(value as Record).length === 1 + ); +} + +/** + * Tagged template literal that produces an HtmlFragment. All interpolations + * are HTML-escaped by default; wrap in `safe()` to opt out (e.g. for nested + * fragments produced by other html`` calls). + * + * Arrays of fragments/strings are joined without separators — convenient + * for mapping over data. + */ +export function html( + strings: TemplateStringsArray, + ...values: unknown[] +): HtmlFragment { + const out: string[] = []; + for (let i = 0; i < strings.length; i += 1) { + out.push(strings[i] ?? ''); + if (i < values.length) { + out.push(renderValue(values[i])); + } + } + return safe(out.join('')); +} + +function renderValue(value: unknown): string { + if (value === null || value === undefined || value === false) return ''; + if (isFragment(value)) return value.value; + if (Array.isArray(value)) return value.map(renderValue).join(''); + return escapeHtml(value); +} diff --git a/middleware/packages/harness-ui-helpers/src/index.ts b/middleware/packages/harness-ui-helpers/src/index.ts new file mode 100644 index 000000000..50bb712e6 --- /dev/null +++ b/middleware/packages/harness-ui-helpers/src/index.ts @@ -0,0 +1,20 @@ +export { html, escapeHtml, safe } from './html.js'; +export type { HtmlFragment } from './html.js'; +export { htmlDoc } from './document.js'; +export type { HtmlDocOptions } from './document.js'; +export { renderRoute, withIframeSafeHeaders } from './route.js'; +export type { RouteHandler, RouteContext } from './route.js'; + +// B.12 — library-mode templates for codegen-generated UiRouters. +export { renderListCard, unwrapItems } from './templates/listCard.js'; +export type { ListCardOptions, ListCardItemTemplate } from './templates/listCard.js'; +export { renderKpiTiles } from './templates/kpiTiles.js'; +export type { KpiTilesOptions, KpiTile } from './templates/kpiTiles.js'; +export { interpolate, resolveExpression } from './templates/interpolate.js'; +export type { InterpolateOptions } from './templates/interpolate.js'; + +// B.12-4 — React-SSR helper for codegen-generated react-ssr UiRouters. +// React + react-dom are optional peer-dependencies; the import below is +// only evaluated when a plugin actually uses this helper. +export { renderReactRoute, wrapInHtmlDocument } from './react/renderReactRoute.js'; +export type { RenderReactRouteOptions } from './react/renderReactRoute.js'; diff --git a/middleware/packages/harness-ui-helpers/src/react/renderReactRoute.ts b/middleware/packages/harness-ui-helpers/src/react/renderReactRoute.ts new file mode 100644 index 000000000..2198de0e3 --- /dev/null +++ b/middleware/packages/harness-ui-helpers/src/react/renderReactRoute.ts @@ -0,0 +1,250 @@ +/** + * renderReactRoute (B.12-4) — React-SSR helper for plugin UI-routes. + * + * Bridges the React-Component world to the Express-Plugin world. Plugin + * authors write a TSX component (typed Props in, JSX out); this helper + * renders it to a complete HTML document via `react-dom/server`'s + * `renderToString`, wraps in the same iframe-safe Tailwind shell that + * `renderRoute` uses for HTML pages, and sets the same CSP headers. + * + * Pilot-phase choices (consistent with SPIKE-2026-05-15 Finding 7): + * - `renderToString` (sync). Acceptable for Pilot. Phase 2 migrates to + * `renderToPipeableStream` if concurrent-tab-load latency becomes + * measurable. + * - Tailwind via CDN — same as library/free-form-html modes. Phase 2 + * adds a per-plugin PostCSS-build that purges + bundles, served as + * `/p//static/.css`. The schema is forward-compatible. + * - No client-side hydration (B.12 enforces `interactive=false`). + * `renderToString` output is statically delivered; B.13 will swap in + * hydratable output when a use-case lands. + * + * The factory signature returns an Express `RouteHandler` so the codegen- + * emitted UiRouter can wire it straight into `router.get(...)` — symmetric + * to `renderRoute()` for html-string handlers. + */ + +import type { ComponentType, ReactElement } from 'react'; +import type * as ReactNS from 'react'; +import type * as ReactDOMServerNS from 'react-dom/server'; + +import { withIframeSafeHeaders, type RouteHandler } from '../route.js'; +import { escapeHtml } from '../html.js'; + +// B.12-4 / B.13 — react + react-dom are OPTIONAL peerDependencies (see +// package.json peerDependenciesMeta). Plugins without react-ssr ui_routes +// never call renderReactRoute(), so they should never trigger the react +// resolution. Importing `react` at the top of this module would fail at +// MODULE-LOAD time for those plugins (the helper's index.js re-exports +// renderReactRoute and that re-export triggers evaluation of this file). +// +// To keep the no-react path zero-cost we defer the react imports until +// renderReactRoute() is actually invoked. Plugins that DO use react-ssr +// have react/react-dom@^18 declared in their codegen-emitted package.json +// peerDeps, and the host's BUILD_TIME_ONLY_DEPS provisions them at install +// time so this dynamic import succeeds in production. +let reactModule: typeof ReactNS | null = null; +let reactDomServerModule: typeof ReactDOMServerNS | null = null; + +async function loadReact(): Promise<{ + createElement: (typeof ReactNS)['createElement']; + renderToString: (typeof ReactDOMServerNS)['renderToString']; +}> { + if (!reactModule) reactModule = await import('react'); + if (!reactDomServerModule) reactDomServerModule = await import('react-dom/server'); + return { + createElement: reactModule.createElement, + renderToString: reactDomServerModule.renderToString, + }; +} + +const TAILWIND_CDN_URL = 'https://cdn.tailwindcss.com'; + +export interface RenderReactRouteOptions

    { + /** Props passed to the Component when SSR renders. */ + readonly props: P; + /** `` content + page heading. */ + readonly pageTitle: string; + /** Auto-refresh interval (seconds). 0 disables. Maps to a + * `<meta http-equiv="refresh">` — same simple full-reload mechanism + * as `htmlDoc({ refreshSeconds })`. */ + readonly refreshSeconds?: number; + /** Optional external stylesheet URL — when Phase 2 adds a built + * Tailwind bundle per plugin, codegen passes the static-asset URL + * here and `tailwind: 'none'` to drop the CDN. */ + readonly cssHref?: string; + /** Tailwind delivery. Default: 'cdn'. */ + readonly tailwind?: 'cdn' | 'none'; + /** HTML <html lang="…"> attribute. Default 'en'. */ + readonly lang?: string; + /** B.13 — Client-Side-Hydration. When set, the SSR output gets an + * importmap (esm.sh → React + ReactDOM/client) plus a module-script + * that imports the component from `componentUrl` and calls + * `hydrateRoot(...)` against the SSR'd DOM. Without this, the page + * is SSR-only and `interactive=false` in the spec stays in effect. + * + * Constraints: + * - `componentUrl` MUST be an absolute path served by the plugin + * (e.g. `/p/de.byte5.agent.foo/static/components/inboxPage.js`). + * The codegen wires `express.static(...)` to make this URL live. + * - The component's default export must accept `props` as its + * first argument — same signature as the SSR call. Hydration + * passes a JSON-roundtrip of the SSR props so server + client + * Component see the same input. + * - React 18 + react-dom@18 are loaded via esm.sh CDN (matches + * the plugin's peerDep range). No per-plugin bundler step. + */ + readonly hydration?: { + /** Stable id for the hydration root container. Matches the + * `data-omadia-page` attribute that codegen adds at the component + * root — used by the client script to find the mount node. */ + readonly pageId: string; + /** Absolute URL where the plugin serves the compiled component + * module (default-export the React component). */ + readonly componentUrl: string; + /** React version pinned in the importmap. Default '18.3.1'. */ + readonly reactVersion?: string; + }; +} + +/** + * Render a React component to a complete HTML document, suitable as the + * return value of an Express `router.get(...)` handler. + * + * Returns a `RouteHandler` (the same type `renderRoute` produces) so the + * codegen-emitted UiRouter wires it identically: `router.get(path, + * renderReactRoute(Page, opts))`. + */ +export function renderReactRoute<P>( + Component: ComponentType<P>, + opts: RenderReactRouteOptions<P>, +): RouteHandler { + return async ({ res }) => { + withIframeSafeHeaders(res); + const { createElement, renderToString } = await loadReact(); + const element: ReactElement = createElement( + Component as ComponentType<unknown>, + opts.props as unknown as Record<string, unknown>, + ); + const rendered = renderToString(element); + const wrapped = wrapInHtmlDocument(rendered, opts); + // Write the response here instead of relying on the caller. The codegen- + // emitted route handler (`routes/<id>UiRouter.tsx`) does + // `await renderReactRoute(...)({ req, res, params, query })` + // and treats the returned value as unused — without an explicit `res.send` + // the Express response would never end and the client would hang until + // its timeout fired (verified live: SSR completed in 2ms, proxy still + // hung for the full 30s). `renderRoute` (the library/free-form-html + // wrapper) does the same dance — `res.type('html').send(result)` after + // the handler returns — so this aligns the two render modes. + // Defensive: if the operator's component (via a nested handler call) + // already wrote to res, don't double-send. + if (!res.headersSent && !res.writableEnded) { + res.type('html').send(wrapped); + } + return wrapped; + }; +} + +const DEFAULT_REACT_VERSION = '18.3.1'; + +/** + * Builds the importmap + module-script block that turns an SSR'd page + * into a hydrated one. Exported for tests; renderReactRoute calls this + * internally when `opts.hydration` is set. + * + * Security note: `props` are JSON-stringified into a `<script type= + * "application/json">` block. `JSON.stringify` with no replacer is + * safe from `</script>` injection only when we additionally escape the + * literal `</` sequence. We do that here so even an operator-controlled + * string field can't break out of the script tag. + */ +export function buildHydrationScripts<P>( + hydration: NonNullable<RenderReactRouteOptions<P>['hydration']>, + props: P, +): string { + const reactVersion = hydration.reactVersion ?? DEFAULT_REACT_VERSION; + // Escape both `<` and `>` in the JSON-block: even with the JSON content- + // type, browser-parsers historically treat `</` as an early script-tag + // close. Belt-and-braces — the same `<` → `<` escape is also + // standard for `<script type="application/json">` blocks. + const propsJson = JSON.stringify(props ?? {}) + .replace(/</g, '\\u003c') + .replace(/>/g, '\\u003e'); + return [ + `<script type="application/json" id="__OMADIA_PROPS_${escapeHtml(hydration.pageId)}">${propsJson}</script>`, + '<script type="importmap">', + JSON.stringify({ + imports: { + react: `https://esm.sh/react@${reactVersion}`, + 'react/jsx-runtime': `https://esm.sh/react@${reactVersion}/jsx-runtime`, + 'react-dom/client': `https://esm.sh/react-dom@${reactVersion}/client`, + }, + }), + '</script>', + `<script type="module">`, + `Promise.all([`, + ` import('react'),`, + ` import('react-dom/client'),`, + ` import(${JSON.stringify(hydration.componentUrl)}),`, + `]).then(([React, ReactDOMClient, PageModule]) => {`, + ` const propsEl = document.getElementById('__OMADIA_PROPS_${hydration.pageId}');`, + ` const props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {};`, + ` const root = document.querySelector('[data-omadia-page=' + JSON.stringify(${JSON.stringify(hydration.pageId)}) + ']');`, + ` if (root) {`, + ` ReactDOMClient.hydrateRoot(root, React.createElement(PageModule.default, props));`, + ` }`, + `}).catch((err) => {`, + ` console.error('[omadia] hydration failed', err);`, + `});`, + `</script>`, + ].join(''); +} + +/** + * Exported for tests + advanced callers that want the assembled HTML + * without going through Express (e.g. building static snapshots). The + * runtime path uses `renderReactRoute` above which calls this internally. + */ +export function wrapInHtmlDocument<P>( + innerHtml: string, + opts: RenderReactRouteOptions<P>, +): string { + const lang = opts.lang ?? 'en'; + const title = escapeHtml(opts.pageTitle); + // B.13 — meta-refresh and client-side hydration are mutually exclusive: + // a meta-refresh blows away any hydrated React state every N seconds. + // When `hydration` is set, the client owns refresh semantics (SWR / + // event-driven re-render). The refresh setting is silently dropped. + const refresh = + !opts.hydration && + typeof opts.refreshSeconds === 'number' && + opts.refreshSeconds > 0 + ? `<meta http-equiv="refresh" content="${Math.floor(opts.refreshSeconds)}">` + : ''; + const tailwind = opts.tailwind ?? 'cdn'; + const tailwindTag = + tailwind === 'cdn' ? `<script src="${TAILWIND_CDN_URL}"></script>` : ''; + const cssLink = opts.cssHref + ? `<link rel="stylesheet" href="${escapeHtml(opts.cssHref)}">` + : ''; + const hydrationBlock = opts.hydration + ? buildHydrationScripts(opts.hydration, opts.props) + : ''; + return [ + '<!doctype html>', + `<html lang="${escapeHtml(lang)}">`, + '<head>', + '<meta charset="utf-8">', + '<meta name="viewport" content="width=device-width,initial-scale=1">', + refresh, + `<title>${title}`, + tailwindTag, + cssLink, + '', + '', + innerHtml, + hydrationBlock, + '', + '', + ].join(''); +} diff --git a/middleware/packages/harness-ui-helpers/src/route.ts b/middleware/packages/harness-ui-helpers/src/route.ts new file mode 100644 index 000000000..422b888e6 --- /dev/null +++ b/middleware/packages/harness-ui-helpers/src/route.ts @@ -0,0 +1,69 @@ +import type { Request, RequestHandler, Response } from 'express'; + +export interface RouteContext { + readonly req: Request; + readonly res: Response; + readonly params: Request['params']; + readonly query: Request['query']; +} + +export type RouteHandler = ( + ctx: RouteContext, +) => Promise | string | Promise | void; + +/** + * Adapts a UI route handler that returns a complete HTML string into an + * Express request handler. Sets iframe-safe headers automatically — plugin + * UIs are designed to be embedded inside Teams Tabs. + * + * If the handler returns void/undefined it MUST have written the response + * itself (e.g. res.redirect / res.status(...).send(...)). + */ +export function renderRoute(handler: RouteHandler): RequestHandler { + return async (req, res, next) => { + try { + withIframeSafeHeaders(res); + const result = await handler({ req, res, params: req.params, query: req.query }); + if (res.headersSent || res.writableEnded) return; + if (typeof result !== 'string') { + res.status(204).end(); + return; + } + res.type('html').send(result); + } catch (err) { + next(err); + } + }; +} + +const TEAMS_FRAME_ANCESTORS = [ + "'self'", + 'https://*.teams.microsoft.com', + 'https://teams.microsoft.com', + 'https://*.office.com', + 'https://*.microsoft365.com', +]; + +/** + * Sets headers required for safe iframe embedding inside Microsoft Teams + * (and Office host apps). Idempotent — calling twice is a no-op. + * + * - CSP `frame-ancestors` is the modern replacement for X-Frame-Options + * when the embedding origin is known. Both are emitted because some + * legacy proxies still honor only X-Frame-Options. + * - Tailwind's CDN-injected styles need `'unsafe-inline'` because it + * writes a