diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0049af5d..8842547e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,14 +123,15 @@ jobs: # every publishable member without uploading. Catches publishability # regressions (missing version reqs, packaging-excluded files, broken # inter-crate deps) on every push/PR, long before a release tag. Mirrors - # `make publish-dry` and the release workflow's dry-run. The two - # `publish = false` FFI crates are excluded (not part of the registry set). + # `make publish-dry` and the release workflow's dry-run. The + # `publish = false` crates are excluded (not part of the registry set): + # the two FFI crates and the tutorial companion crate. - name: cargo package (no upload) run: | # Drop any pre-seeded registry index/cache so cargo fetches fresh and # --locked resolves against a clean index (see note above). rm -rf ~/.cargo/registry/index ~/.cargo/registry/cache - cargo package --workspace --locked --exclude cpex-ffi --exclude cpex-demo-ffi + cargo package --workspace --locked --exclude cpex-ffi --exclude cpex-demo-ffi --exclude cpex-tutorial docs: name: Docs Build diff --git a/.github/workflows/docs-build.yaml b/.github/workflows/docs-build.yaml index bae66818..694d04bf 100644 --- a/.github/workflows/docs-build.yaml +++ b/.github/workflows/docs-build.yaml @@ -41,6 +41,16 @@ jobs: hugo-version: "latest" extended: true + # Build with a root-relative baseURL so the link checker can resolve + # internal links against the built files rather than the live site. - name: Build docs working-directory: docs - run: hugo --minify + run: hugo --minify --baseURL "/" + + - name: Check links + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2 + with: + args: --no-progress --root-dir "${{ github.workspace }}/docs/public" "docs/public/**/*.html" + fail: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bd59efdd..a946e39f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -110,6 +110,7 @@ jobs: cpex-plugin-identity-jwt cpex-plugin-delegator-oauth cpex-plugin-delegator-biscuit + cpex-plugin-elicitation-ciba cpex-pdp-cedar-direct cpex-pdp-cel cpex-session-valkey @@ -118,7 +119,22 @@ jobs: ) for c in "${crates[@]}"; do echo "::group::publish $c" - cargo publish -p "$c" --locked + # Idempotent: a prior run can publish a leading prefix of this + # list and then fail further down (e.g. a downstream crate gained + # a new dependency that was not yet in the list). crates.io rejects + # re-publishing an existing version, so treat "already uploaded" as + # success and continue — this lets a re-run of the same tag finish + # the remaining crates instead of aborting on the first done one. + if out="$(cargo publish -p "$c" --locked 2>&1)"; then + printf '%s\n' "$out" + else + printf '%s\n' "$out" + if printf '%s' "$out" | grep -qiE 'already (exists|uploaded)|is already uploaded'; then + echo "::notice::$c already published at this version — skipping" + else + exit 1 + fi + fi echo "::endgroup::" sleep 15 done diff --git a/.github/workflows/tutorial.yaml b/.github/workflows/tutorial.yaml new file mode 100644 index 00000000..79420bd7 --- /dev/null +++ b/.github/workflows/tutorial.yaml @@ -0,0 +1,40 @@ +# Tutorial checks — runs the runnable tutorial modules end to end. +# +# Split out from ci.yml because the IdP-backed modules need a live Keycloak +# (a Docker service), which the main gate does not. The no-IdP modules and +# the policy unit tests already run in ci.yml via `make examples-build` and +# `cargo test --workspace`; this workflow adds the identity-backed path: +# boot the tutorial Keycloak with docker compose, run every module's +# `--check`, and tear it down. +name: Tutorial + +on: + push: + branches: [main] + paths: + - "examples/tutorial/**" + - "crates/**" + - "builtins/**" + - ".github/workflows/tutorial.yaml" + pull_request: + paths: + - "examples/tutorial/**" + - ".github/workflows/tutorial.yaml" + +jobs: + tutorial-check: + name: Tutorial modules (with Keycloak) + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + - name: Install Rust 1.96.0 + uses: dtolnay/rust-toolchain@1.96.0 + - uses: Swatinem/rust-cache@v2 + # `make tutorial-check` brings the Keycloak compose stack up, runs the + # no-IdP and IdP-backed module checks, then tears the stack down. + - name: make tutorial-check + run: make tutorial-check diff --git a/.lycheeignore b/.lycheeignore new file mode 100644 index 00000000..cf1102e9 --- /dev/null +++ b/.lycheeignore @@ -0,0 +1,13 @@ +# Link-checker ignore patterns (lychee). One regex per line, matched against URLs. + +# Example / placeholder hosts used in tutorial and config docs; not live endpoints. +localhost +127\.0\.0\.1 +.*\.example +.*\.test +example\.com + +# The repo's own source links (github.com/contextforge-org/cpex/tree|blob/...) only +# resolve once merged to the default branch, so checking them on a feature branch +# produces false failures. The repository structure itself validates these paths. +github\.com/contextforge-org/cpex diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2de9b52..73f24404 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,16 @@ Each source file should carry an Apache-2.0 SPDX header. For Rust: // Authors: Your Name ``` +## Documentation conventions + +The docs site is Hugo (in [`docs/`](docs)). A few conventions keep it consistent: + +- **Route syntax:** write the flat list form the runtime parses, `routes:` with `- tool: ` entries. The map-keyed form is `apl-core`'s standalone/test surface only; see [Configuration](https://contextforge-org.github.io/cpex/docs/configuration/). +- **Terminology:** "Quick Start" (two words) in prose; `builtin` (one word) for the bundled extensions; "allow/deny" for outcomes. Expand `APL` (Authorization Policy Language), `A2A` (agent-to-agent), `IdP` (identity provider), `PDP`, `CMF`, and `CIBA` on first use in a page. +- **Images:** reference them relative (`images/foo.png`), not with a hardcoded base path (`/cpex/images/...`). The site render hook resolves them against `baseURL`, so previews and alternate deployments work. Put the file under `docs/static/images/`. +- **Runnable examples:** prefer linking the tutorial crate (`examples/tutorial`) over maintaining a second partial example; its modules and tests are CI-run. +- **Voice:** direct and concise; avoid em dashes. + ## crates.io publishing The library crates publish to crates.io from the `release.yaml` workflow on a diff --git a/Cargo.lock b/Cargo.lock index 3520353f..9a49b7d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,6 +123,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "ascii-canvas" version = "4.0.0" @@ -645,6 +651,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "cmake" version = "0.1.58" @@ -957,6 +969,20 @@ dependencies = [ "url", ] +[[package]] +name = "cpex-tutorial" +version = "0.2.2" +dependencies = [ + "async-trait", + "cpex", + "cpex-core", + "cpex-sdk", + "reqwest", + "serde_json", + "tiny_http", + "tokio", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -3830,6 +3856,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index cc9dfeb9..11f2bd51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "builtins/pdps/cel", "builtins/session/valkey", "examples/go-demo/ffi", + "examples/tutorial", # PyO3 bindings — excluded from default-members so plain `cargo build` # stays libpython-independent (KD3). Use `cargo build -p cpex-python` # or `maturin develop` to build this crate explicitly. @@ -61,6 +62,7 @@ default-members = [ "builtins/pdps/cedar-direct", "builtins/pdps/cel", "examples/go-demo/ffi", + "examples/tutorial", ] [workspace.package] diff --git a/Makefile b/Makefile index 74978762..ed7d5b04 100644 --- a/Makefile +++ b/Makefile @@ -281,13 +281,55 @@ examples-build: rust-examples-build go-examples-build @echo "✅ All examples built" .PHONY: examples-run -examples-run: examples-build +examples-run: examples-build tutorial-check-local @$(CARGO) run --example plugin_demo -p cpex-core --quiet >/dev/null @$(CARGO) run --example cmf_capabilities_demo -p cpex-core --quiet >/dev/null @cd $(GO_EXAMPLES_DIR) && $(GO) run . >/dev/null @cd $(GO_EXAMPLES_DIR) && $(GO) run ./cmd/cmf-demo >/dev/null @echo "✅ All examples ran successfully" +# ============================================================================= +# Tutorial (examples/tutorial) +# ============================================================================= +# +# The tutorial ships one runnable binary per module, each with a `--check` +# mode that asserts its scripted scenario. `tutorial-check-local` runs the +# modules that need no infrastructure; `tutorial-check` additionally brings +# up the tutorial Keycloak (docker compose) and runs the IdP-backed modules, +# tearing the stack down afterward. CI runs `tutorial-check`. + +TUTORIAL_IDP_COMPOSE = examples/tutorial/idp/docker-compose.yml +TUTORIAL_NOIDP_MODULES = m01_hello m03_shaping m04_effects m09_custom_plugin m10_testing +TUTORIAL_IDP_MODULES = m02_identity m05_pdp m06_delegation m07_tainting m08_elicitation capstone + +.PHONY: tutorial-check-local +tutorial-check-local: + @for m in $(TUTORIAL_NOIDP_MODULES); do \ + echo "→ tutorial $$m --check"; \ + $(CARGO) run -q -p cpex-tutorial --example $$m -- --check >/dev/null || exit 1; \ + done + @echo "✅ Tutorial (no-IdP) checks passed" + +.PHONY: tutorial-check +tutorial-check: tutorial-check-local + @echo "→ starting tutorial IdP" + @docker compose -f $(TUTORIAL_IDP_COMPOSE) up -d + @echo "→ waiting for Keycloak realm to be ready" + @$(CARGO) run -q -p cpex-tutorial --example wait_for_idp || { \ + docker compose -f $(TUTORIAL_IDP_COMPOSE) down; exit 1; } + @for m in $(TUTORIAL_IDP_MODULES); do \ + echo "→ tutorial $$m --check"; \ + $(CARGO) run -q -p cpex-tutorial --example $$m -- --check || { \ + docker compose -f $(TUTORIAL_IDP_COMPOSE) down; exit 1; }; \ + done + @docker compose -f $(TUTORIAL_IDP_COMPOSE) down + @echo "✅ Tutorial checks passed (incl. IdP-backed modules)" + +.PHONY: tutorial-recordings +tutorial-recordings: + @examples/tutorial/recordings/record.sh + @echo "Upload each cast to asciinema.org, then embed per examples/tutorial/recordings/README.md" + # ============================================================================= # CI gate # ============================================================================= diff --git a/crates/apl-core/Cargo.toml b/crates/apl-core/Cargo.toml index 22ae1358..d5e2e401 100644 --- a/crates/apl-core/Cargo.toml +++ b/crates/apl-core/Cargo.toml @@ -19,6 +19,11 @@ keywords.workspace = true categories.workspace = true rust-version.workspace = true +[package.metadata.cargo-machete] +# serde is used through its derive macros (Serialize/Deserialize), which +# cargo-machete's source scan does not reliably detect. It is a real dependency. +ignored = ["serde"] + [lib] # Plain rlib; APL is consumed by other workspace crates (apl-cmf, apl-cpex) # and does not need cdylib for FFI. diff --git a/docs/content/_index.md b/docs/content/_index.md index 6d27df19..4ef0cdb6 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -12,7 +12,7 @@ CPEX is a deterministic reference monitor between an agent and every capability CPEX composes authorization, delegation, redaction, information-flow tracking, and auditing into a single policy-defined pipeline. Each capability an agent can invoke defines its own enforcement pipeline; APL is the configuration that defines it, executed in two phases: before the operation and after its result. -![CPEX mediates every operation an untrusted LLM triggers, evaluating APL policy against identity, delegation, taint, and audit state the model cannot forge](/cpex/images/cpex_overview.png) +![CPEX mediates every operation an untrusted LLM triggers, evaluating APL policy against identity, delegation, taint, and audit state the model cannot forge](images/cpex_overview.png) Existing authorization systems (RBAC, ABAC, Cedar, OPA, AuthZEN) answer whether a request should be allowed. CPEX answers a broader question: what security pipeline should execute for this agent operation. It invokes those engines for the decision and enforces the result. diff --git a/docs/content/docs/0.1.x/package-integrity.md b/docs/content/docs/0.1.x/package-integrity.md index e775f837..adcc52c5 100644 --- a/docs/content/docs/0.1.x/package-integrity.md +++ b/docs/content/docs/0.1.x/package-integrity.md @@ -314,7 +314,6 @@ See [`cpex.tools.integrity`](../api-reference/#cpextoolsintegrity) for detailed - [CLI Reference](../cli/) - Command-line usage - [Configuration](../configuration/) - General configuration options -- [Security Best Practices](../security/) - Comprehensive security guide ## Changelog diff --git a/docs/content/docs/0.1.x/vision.md b/docs/content/docs/0.1.x/vision.md index 0671d6b4..1c73ea59 100644 --- a/docs/content/docs/0.1.x/vision.md +++ b/docs/content/docs/0.1.x/vision.md @@ -17,7 +17,7 @@ Hooks are standardized interception points placed at every boundary where an age This architecture deploys identically across the stack, inside LLM proxies, agent frameworks, and gateways. Each layer runs its own plugins. Prompt injection detection at the proxy. Tool authorization at the gateway. Data loss prevention at the agent. -![CPEX hooks deployed across the agent stack](/cpex/images/distributed_hooks_control_plane.png) +![CPEX hooks deployed across the agent stack](images/distributed_hooks_control_plane.png) --- @@ -31,7 +31,7 @@ Enforcement is a three-layer problem. | **CMF** (Common Message Format) | What you evaluate. A protocol-agnostic context envelope carrying identity, security labels, delegation chains, and content. | | **APL** (Authorization Policy Language) | How you define policy. Declarative, attribute-based rules with explicit effects. | -![Hooks, CMF, and APL form a unified enforcement stack](/cpex/images/overview_vision.png) +![Hooks, CMF, and APL form a unified enforcement stack](images/overview_vision.png) Hooks make enforcement **possible**. Policy makes it **usable**. Context makes it **correct**. @@ -41,7 +41,7 @@ Hooks make enforcement **possible**. Policy makes it **usable**. Context makes i Different policy types require different enforcement points. CPEX provides hooks at every layer, from soft stylistic policies enforced at the prompt level to hard compliance requirements enforced at infrastructure boundaries. -![Policy spectrum: each policy type maps to a different enforcement point](/cpex/images/policy_spectrum.png) +![Policy spectrum: each policy type maps to a different enforcement point](images/policy_spectrum.png) --- @@ -49,7 +49,7 @@ Different policy types require different enforcement points. CPEX provides hooks An application or framework invokes a hook at a critical operation boundary. The plugin manager dispatches registered plugins (sequentially, concurrently, or fire-and-forget) and returns a result. Plugins can **allow** execution to continue, **block** it with a violation, or **modify** the payload using copy-on-write isolation. -![Plugin execution model: agent → middleware → hook → manager → plugins](/cpex/images/integration_execution_model.png) +![Plugin execution model: agent → middleware → hook → manager → plugins](images/integration_execution_model.png) The plugin manager handles registration, ordering, timeouts, error isolation, and payload chaining. You get a deterministic enforcement pipeline with no surprises. diff --git a/docs/content/docs/_index.md b/docs/content/docs/_index.md index bd7f8748..9f584362 100644 --- a/docs/content/docs/_index.md +++ b/docs/content/docs/_index.md @@ -8,6 +8,6 @@ bookFlatSection: true CPEX is a policy enforcement runtime for AI agents: a deterministic reference monitor that mediates every operation an untrusted LLM triggers. Each capability an agent can invoke defines its own enforcement pipeline (authorization, delegation, redaction, information-flow control, audit), configured in APL and run deterministically at the boundary. -Start with the [Vision]({{< relref "/docs/vision" >}}) for the reference-monitor model, the [Quick Start]({{< relref "/docs/quickstart" >}}) to stand up an enforcement point, or [APL]({{< relref "/docs/apl" >}}) to write policy. +Start with the [Vision]({{< relref "/docs/vision" >}}) for the reference-monitor model, the [Threat Model]({{< relref "/docs/threat-model" >}}) for what CPEX defends against at each placement, or the [Use Cases]({{< relref "/docs/use-cases" >}}) for the controls running end-to-end behind a real gateway. Then the [Quick Start]({{< relref "/docs/quickstart" >}}) stands up an enforcement point, and [APL]({{< relref "/docs/apl" >}}) is where you write policy. Prefer to learn by doing? The [Tutorial]({{< relref "/docs/tutorial" >}}) builds a working enforcement point one capability at a time, with runnable code you can edit and re-run. Using the Python 0.1.x line? Its docs are preserved under [0.1.x (Legacy)]({{< relref "/docs/0.1.x" >}}). diff --git a/docs/content/docs/apl/_index.md b/docs/content/docs/apl/_index.md index 7d997eb4..adace6f6 100644 --- a/docs/content/docs/apl/_index.md +++ b/docs/content/docs/apl/_index.md @@ -7,7 +7,7 @@ weight: 30 APL is the declarative configuration that defines a CPEX enforcement pipeline. Each capability an agent can invoke (a tool, resource, prompt, or A2A method) defines its own pipeline through a **route** that sequences the controls protecting it, evaluated at the boundary. You describe the conditions and the effects; you do not write enforcement logic in application code. -![An APL config: plugins and global settings, then per-entity routes with a pre-invocation flow (require, PDP, delegate, run) and post-invocation result handling (taint, redact), plus session tainting across entities](/cpex/images/apl_overview.png) +![An APL config: plugins and global settings, then per-entity routes with a pre-invocation flow (require, PDP, delegate, run) and post-invocation result handling (taint, redact), plus session tainting across entities](images/apl_overview.png) This page covers the configuration: routes, phases, predicates, rules, and field pipelines. The rest of this section goes deeper on each kind of policy: @@ -22,10 +22,7 @@ This page covers the configuration: routes, phases, predicates, rules, and field Policy is organized by **route**: an operation CPEX mediates, identified by the tool, A2A method, or other interface it governs. Each route runs through four phases, in order: -```mermaid -flowchart LR - ARGS["args
validate / transform input"] --> POL["authorization.pre_invocation
authorize"] --> RES["result
transform output"] --> POST["authorization.post_invocation
audit / final checks"] -``` +![The four route phases in order: args validates and transforms input, authorization.pre_invocation authorizes, result redacts and masks output, and authorization.post_invocation runs audit and final checks; the first deny in any phase halts that phase and every later one](images/apl_phases.png) - **args**: validate and transform request inputs before the operation runs. - **authorization.pre_invocation**: authorize the operation. Predicates, PDP calls, delegation, tainting. diff --git a/docs/content/docs/apl/delegation.md b/docs/content/docs/apl/delegation.md index cfec18e1..349ea7be 100644 --- a/docs/content/docs/apl/delegation.md +++ b/docs/content/docs/apl/delegation.md @@ -25,14 +25,7 @@ authorization: The order matters. The `require` gate runs first, so a credential is only minted for a caller who passed authorization. After the exchange, a post-check verifies the credential actually carries the scope requested, and denies the operation if the IdP returned less. -```mermaid -flowchart LR - IN["caller's verified token
(audience: agent)"] --> DEL["delegate(workday-oauth)"] - DEL -->|"RFC 8693 token exchange"| IDP["IdP token endpoint"] - IDP --> OUT["downstream token
(audience: workday-api
scope: read_compensation)"] - OUT --> BE["backend"] - CHK["delegation.granted.permissions
verified before forward"] -.-> OUT -``` +![The delegation flow: the caller's verified token enters delegate(workday-oauth), which performs an RFC 8693 exchange at the IdP token endpoint; the resulting downstream token is audience- and scope-limited, delegation.granted.permissions is verified before forward, and only the minted token reaches the backend](images/apl_delegation_flow.png) ## The delegator plugin diff --git a/docs/content/docs/apl/elicitation.md b/docs/content/docs/apl/elicitation.md index 6da3246f..fc917ff2 100644 --- a/docs/content/docs/apl/elicitation.md +++ b/docs/content/docs/apl/elicitation.md @@ -23,11 +23,12 @@ plugins: routes: - tool: approve_raise - pre_invocation: - - "require(authenticated)" - - when: "args.amount > 10000" - do: - - "require_approval(manager-approver, from: claim.manager, channel: \"ciba\", scope: \"args.amount <= 25000\", purpose: \"Approve raise\")" + authorization: + pre_invocation: + - "require(authenticated)" + - when: "args.amount > 10000" + do: + - "require_approval(manager-approver, from: claim.manager, channel: \"ciba\", scope: \"args.amount <= 25000\", purpose: \"Approve raise\")" ``` - `from` is **who to ask** — an attribute reference resolved against the request bag (here `claim.manager`, the requester's manager, who differs from the subject). An attribute `from` that doesn't resolve fails closed rather than dispatching to a bogus identity. @@ -46,17 +47,7 @@ An elicitation has three short, synchronous touch-points. The hours-long human g While pending, the phase **suspends** rather than denies. The decision stays `Allow`, but a pending marker rides alongside it, and the host maps that to JSON-RPC **`-32120`** ("not complete — retry echoing this id"). The forward rule is one clause: *forward only when the decision is `Allow` and nothing is pending.* Expiry, channel error, a genuine denial, or a failed validation all fail closed (default `on_error: deny`). -```mermaid -flowchart LR - REQ["agent request"] --> DISP["require_approval(manager-approver)"] - DISP -->|"dispatch: open backchannel"| OP["Keycloak CIBA"] - DISP -->|"-32120 + elicitation id"| REQ - REQ -->|"retry, echo id"| CHK["check status"] - CHK -->|"pending"| REQ - CHK -->|"resolved"| VAL["validate genuineness
+ scope over live args"] - VAL -->|"approved & sufficient"| TOOL["forward to tool"] - VAL -->|"denied / expired / invalid"| DENY["deny (fail closed)"] -``` +![The elicitation suspend-and-resume flow: an agent request hits require_approval, which opens a Keycloak CIBA backchannel and returns -32120 with an elicitation id; agent retries hit a non-blocking status check that keeps returning pending until the channel resolves; validate then verifies genuineness and scope over the live args, forwarding to the tool when approved and sufficient, and failing closed on denial, expiry, or invalid responses](images/apl_elicitation_flow.png) ## The CIBA channel plugin diff --git a/docs/content/docs/apl/tainting.md b/docs/content/docs/apl/tainting.md index b9d0a539..fd2025c1 100644 --- a/docs/content/docs/apl/tainting.md +++ b/docs/content/docs/apl/tainting.md @@ -46,14 +46,7 @@ routes: - "security.labels contains \"secret\": deny('session touched secret data', 'session_tainted')" ``` -```mermaid -flowchart LR - R1["get_compensation"] -->|"taint(secret, session)"| S["session labels:
{ secret }"] - R2["send_email
(clean body)"] --> CHK{"labels contains
secret?"} - S -.-> CHK - CHK -->|yes| DENY["deny
session_tainted"] - CHK -->|no| OK["allow"] -``` +![The taint produce-and-consume flow: get_compensation runs taint(secret, session), writing the secret label into session state; later in the same session, send_email with a clean body is checked against that CPEX-owned state and denied with session_tainted when the label is present, allowed otherwise](images/apl_tainting_flow.png) The email is denied because the session is tainted, not because of anything in its body. The decision is made from CPEX-owned state, so the model cannot route around it by rewording the email. diff --git a/docs/content/docs/configuration.md b/docs/content/docs/configuration.md index bc09dd9a..b33296e8 100644 --- a/docs/content/docs/configuration.md +++ b/docs/content/docs/configuration.md @@ -23,9 +23,9 @@ global: # cross-cutting resolvers and stores session_store: kind: ... -routes: # APL policy, keyed by operation - : - authentication: [ ... ] # identity-resolution plugins +routes: # APL policy, a list of operations + - tool: # or resource: / prompt: / llm: + authentication: [ ... ] # identity-resolution plugins args: { ... } authorization: pre_invocation: [ ... ] @@ -88,11 +88,11 @@ global: ## Routes -Routes carry the APL policy. The map-keyed form (keyed by route name) is the canonical form for configs loaded into the runtime: +Routes carry the APL policy. The runtime loads routes as a **list**, one entry per operation, matched by `tool:` (or `resource:` / `prompt:` / `llm:`): ```yaml routes: - get_compensation: + - tool: get_compensation authorization: pre_invocation: - "require(role.hr)" @@ -103,8 +103,61 @@ routes: ssn: "str | redact(!perm.view_ssn)" ``` -The two authorization phases may also be written flat — `pre_invocation:` / `post_invocation:` directly on the route — which is equivalent to nesting them under `authorization:`. +Within a route, the two authorization phases may be written nested under `authorization:` (as above) or flat, as `pre_invocation:` / `post_invocation:` directly on the route; the two are equivalent. -Deployment integrations that wrap CPEX (a gateway or sidecar) often express routes as a list of `- tool:` entries instead; that form carries the same `authorization`/`args`/`result` blocks. See [Deployment]({{< relref "/docs/deployment" >}}) for that variant, and [APL]({{< relref "/docs/apl" >}}) for the policy syntax itself. +> **Runtime config vs. apl-core.** The `apl-core` crate also accepts a map-keyed `routes:` form (keyed by route name) through its standalone `compile_config` entry point, used mainly in tests. The runtime host path (`load_config_yaml`) does not: it parses the list form shown here. Write the list form for anything you load into a running CPEX. See [APL]({{< relref "/docs/apl" >}}) for the policy syntax itself. Route-level overrides can adjust a plugin's `capabilities` or `config` for a specific operation, so a scanner can be granted `read_labels` on one sensitive route without widening its access everywhere. + +## Global settings and defaults + +Every top-level section (`plugins`, `global`, `routes`, `plugin_dirs`, `plugin_settings`) is optional. `plugin_settings` controls runtime behavior: + +| Setting | Default | Meaning | +|---------|---------|---------| +| `routing_enabled` | `false` | `false`: each plugin self-selects via its own `conditions:`. `true`: `routes:` / `global:` drive selection and per-plugin `conditions:` are ignored. Route-based configs set this `true`. | +| `plugin_timeout` | `30` | Per-plugin timeout, in seconds. | +| `short_circuit_on_deny` | `true` | Stop a hook's remaining plugins once one denies. | +| `fail_on_plugin_error` | `false` | Whether a plugin error fails the request (see also per-plugin `on_error`). | +| `parallel_execution_within_band` | `false` | Run same-priority plugins concurrently. | +| `route_cache_max_entries` | `10000` | Dispatch-plan cache size. | + +Per-plugin fields default to `mode: sequential`, `on_error: fail`, and a `priority` that orders plugins within a hook (lower runs first). + +## Secrets and key material + +CPEX does not interpolate environment variables into arbitrary config values; there is no `${ENV}` substitution of config fields. Secrets are injected through typed source enums on the plugins that need them. + +OAuth and CIBA client secrets use `client_secret_source`: + +```yaml +client_secret_source: { kind: env_var, name: OAUTH_CLIENT_SECRET } # production-friendly +client_secret_source: { kind: file, path: /run/secrets/oauth } # mounted secret +client_secret_source: { kind: literal, secret: dev-only } # never in production +``` + +JWT signing material uses `decoding_key` on each `identity/jwt` trusted issuer: `jwks_url` (fetched and cached; `refresh_secs` default 600), `pem`, `pem_file`, `jwk`, or `secret` (HMAC). For a `jwks_url`, `insecure_http` defaults to `false`; set it `true` only to allow `http://` on localhost, never in production. + +(The request-time templates like `${args.X}` used inside PDP and predicate steps are a separate mechanism, evaluated per request against the attribute bag, not config interpolation.) + +## Resolution order + +With `routing_enabled: true`, the plugins that run for an operation are assembled and de-duplicated in this order, with later layers winning on conflict: + +1. the `all` policy group, +2. the entity `defaults`, +3. policy groups whose tags match the operation, +4. the route itself. + +Identity (`authentication:`) plugins stack global → tag bundles → route, with `replace_inherited` to drop inherited layers when a route needs a clean set. + +## Validation + +`load_config_yaml` validates on load and fails with an operator-facing message rather than starting in a bad state. Common errors: + +- a duplicate plugin `name`; +- a route with no entity matcher, or with more than one (for example both `tool:` and `resource:`); +- a route or policy group that references an unknown plugin name; +- the renamed key `identity:` (use `authentication:`). + +There is no hot reload or config versioning: load a changed config by rebuilding the manager. diff --git a/docs/content/docs/deployment.md b/docs/content/docs/deployment.md index c9c11696..ff01f4f7 100644 --- a/docs/content/docs/deployment.md +++ b/docs/content/docs/deployment.md @@ -23,13 +23,13 @@ routes: ssn: "str | redact(!perm.view_ssn)" ``` -![CPEX enforcing the same policy at three settings: as a gateway in front of the tool server, as an egress sidecar beside the agent, and in-framework inside the agent runtime](/cpex/images/deployment.png) +![CPEX enforcing the same policy at three settings: as a gateway in front of the tool server, as an egress sidecar beside the agent, and in-framework inside the agent runtime](images/deployment.png) As a **gateway**, CPEX sits in front of the tool server and enforces on inbound calls: every request to the backend passes through it. As an **egress sidecar**, CPEX sits beside the agent and enforces on the agent's outbound calls: the agent's tool invocations leave through the sidecar's proxy. **In-framework**, CPEX runs inside the agent runtime and enforces operations as the runtime issues them. The enforcement point moves; the route above runs unchanged in all three. ## Route forms -A deployment integration usually expresses routes as a list of `- tool:` entries, with the `authorization`, `args`, and `result` blocks directly under each. This is the same policy you would write in the map-keyed form (see [Configuration]({{< relref "/docs/configuration" >}})); the wrapping differs, the rules do not. Pick one form per deployment and keep it consistent. +Routes are a list of `- tool:` entries (or `resource:` / `prompt:` / `llm:`), with the `authorization`, `args`, and `result` blocks under each. This is the form the runtime loads at every placement; the enforcement point changes, the config shape does not. See [Configuration]({{< relref "/docs/configuration" >}}) for the full structure. ## Placement guidance @@ -39,7 +39,7 @@ A deployment integration usually expresses routes as a list of `- tool:` entries | Egress sidecar (outbound) | every call an agent makes, to any backend | you own the agent and want to guard what it can reach | | In-framework | operations as the agent runtime issues them | you control the runtime and want enforcement inline | -The decision is about which boundary you control and trust, not about policy capability. Identity resolution, PDP calls, delegation, redaction, and tainting all work the same at each. +The decision is about which boundary you control and trust, not about policy capability. Identity resolution, PDP calls, delegation, redaction, and tainting all work the same at each. For what each placement does and does not defend against, see the [Threat Model]({{< relref "/docs/threat-model#where-the-boundary-sits-and-what-each-placement-covers" >}}). ## Inference traffic diff --git a/docs/content/docs/overview.md b/docs/content/docs/overview.md index 58b9def2..06f87730 100644 --- a/docs/content/docs/overview.md +++ b/docs/content/docs/overview.md @@ -9,13 +9,9 @@ weight: 10 Picture one agent serving several people. It answers questions by calling tools (an HR records service, a code repository, an email sender), invoking other agents over A2A, running inference, and fetching prompts and resources. The backends are shared. The callers are not: an HR analyst, an engineer, and a support rep each drive the same agent with different identities and different entitlements. -![One agent serves three users across HR, repo, and email backends; CPEX policy produces a different outcome per identity](/cpex/images/demo_scenario.png) +![One agent serves three users across HR, repo, and email backends; CPEX policy produces a different outcome per identity](images/demo_scenario.png) -The agent's LLM decides which operation to run. It is untrusted. CPEX sits between it and every capability, and decides what actually happens. - -![CPEX mediates every operation an untrusted LLM triggers, evaluating APL policy against identity, delegation, taint, and audit state the model cannot forge](/cpex/images/cpex_overview.png) - -For each operation, CPEX resolves the caller's identity, evaluates the APL policy attached to that operation, and applies the resulting effects before anything reaches the backend. The same four phases run every time: validate arguments, evaluate policy, transform the result, run post-policy checks. +The agent's LLM decides which operation to run. It is untrusted. CPEX sits between it and every capability, and decides what actually happens. For each operation, CPEX resolves the caller's identity, evaluates the APL policy attached to that operation, and applies the resulting effects before anything reaches the backend. The same four phases run every time: validate arguments, evaluate policy, transform the result, run post-policy checks. ## Same request, different data @@ -35,13 +31,7 @@ routes: - An HR analyst without `view_ssn` gets the same record with the SSN redacted before it leaves CPEX. The backend never sees the difference; the redaction happens at the boundary. - An engineer is denied at `require(role.hr)`. The call never reaches the backend. -```mermaid -flowchart LR - REQ["get_compensation
(identical request)"] --> CPEX{{"APL policy"}} - CPEX -->|"HR + view_ssn"| FULL["full record"] - CPEX -->|"HR, no view_ssn"| RED["record, SSN redacted"] - CPEX -->|"not HR"| DENY["denied"] -``` +![Three identical get_compensation requests pass through CPEX APL policy and produce three outcomes by identity: the full record for HR with view_ssn, the record with SSN redacted for HR without view_ssn, and a deny for non-HR callers](images/overview_outcomes.png) No application code changed between the three outcomes. The policy did. diff --git a/docs/content/docs/patterns.md b/docs/content/docs/patterns.md index 3717d0af..77d8b5ee 100644 --- a/docs/content/docs/patterns.md +++ b/docs/content/docs/patterns.md @@ -5,11 +5,11 @@ weight: 100 # Patterns -Production patterns for writing and rolling out CPEX policy. Each is expressed in APL and builds on the concepts in the earlier pages. +Production patterns for writing and rolling out CPEX policy. Each is expressed in APL and builds on the concepts in the earlier pages. The [Tutorial]({{< relref "/docs/tutorial" >}}) builds most of these hands-on; this page is the condensed reference for authoring and rollout. ## Layered enforcement -Order effects cheapest-gate-first so expensive work only runs for requests that survive the early checks. Attribute gates, then a PDP call, then delegation: +Order effects cheapest-gate-first so expensive work only runs for requests that survive the early checks. Attribute gates, then a PDP call, then delegation (built up in tutorial modules [4]({{< relref "/docs/tutorial/04-effects" >}}), [5]({{< relref "/docs/tutorial/05-pdp" >}}), and [6]({{< relref "/docs/tutorial/06-delegation" >}})): ```yaml authorization: @@ -39,11 +39,11 @@ plugins: ## Input and output guardrails -Validate and transform on the way in with `args`, redact on the way out with `result`. The two phases bracket the operation: +Validate and transform on the way in with `args`, redact on the way out with `result`. The two phases bracket the operation (redaction is tutorial [module 3]({{< relref "/docs/tutorial/03-shaping" >}})): ```yaml routes: - get_employee: + - tool: get_employee args: employee_id: "str | regex(\"^[0-9]{6}$\")" # reject malformed input result: @@ -52,14 +52,16 @@ routes: ## Cross-request information flow -Taint a session when it touches sensitive data, then gate later operations on the label. The control spans requests and the model cannot route around it (see [Session Tainting]({{< relref "/docs/apl/tainting" >}})): +Taint a session when it touches sensitive data, then gate later operations on the label. The control spans requests and the model cannot route around it (see [Session Tainting]({{< relref "/docs/apl/tainting" >}}) and tutorial [module 7]({{< relref "/docs/tutorial/07-tainting" >}})): ```yaml routes: - get_compensation: + - tool: get_compensation authorization: - pre_invocation: [ "require(role.hr)", "taint(secret, session)" ] - send_email: + pre_invocation: + - "require(role.hr)" + - "taint(secret, session)" + - tool: send_email authorization: pre_invocation: - "require(perm.email_send)" @@ -68,13 +70,13 @@ routes: ## Least-privilege effects -Declare the narrowest capabilities each plugin needs, and scope delegated tokens to the minimum. A scanner that reads content does not get identity; a downstream token gets only the scope the operation requires, verified after the exchange: +Declare the narrowest capabilities each plugin needs, and scope delegated tokens to the minimum. A scanner that reads content does not get identity; a downstream token gets only the scope the operation requires, verified after the exchange (deny if the grant is missing): ```yaml authorization: pre_invocation: - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" - - "delegation.granted.permissions contains 'read_compensation': allow" # verify least privilege + - "!(delegation.granted.permissions contains 'read_compensation'): deny" # fail closed if not granted ``` ## Defense in depth diff --git a/docs/content/docs/quickstart.md b/docs/content/docs/quickstart.md index b497baaa..a397b0c0 100644 --- a/docs/content/docs/quickstart.md +++ b/docs/content/docs/quickstart.md @@ -31,11 +31,11 @@ After this, the manager knows every builtin `kind` your features enabled, and AP ## 3. Write the policy -APL configs loaded into the manager use the map-keyed `routes:` form, keyed by route name. This route authorizes by role and redacts on the wire by permission: +`routes:` is a list, one entry per operation. This route matches the `get_employee` tool, authorizes by role, and redacts on the wire by permission: ```yaml routes: - get_employee: + - tool: get_employee args: employee_id: "str" authorization: @@ -52,9 +52,32 @@ The `require(authenticated)` and `require(role.hr)` predicates read attributes r ## 4. Run it -Load the config into the manager and dispatch operations through it. The four phases run automatically: `args` validates `employee_id`, `authorization.pre_invocation` authorizes, `result` redacts. See [`crates/cpex-core/examples`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-core/examples) for runnable end-to-end programs that load a config and invoke a route. +The fastest way to see CPEX actually run is the tutorial's first module, a complete program you can execute now: -The outcome matches the scenario: +```bash +cargo run -p cpex-tutorial --example m01_hello +``` + +It builds a `PluginManager`, installs the builtins, loads a policy, and dispatches two operations. The setup is the four lines a host writes: + +```rust +let mgr = Arc::new(PluginManager::default()); +cpex::install_builtins(&mgr); +mgr.load_config_yaml(policy).unwrap(); +mgr.initialize().await.unwrap(); +``` + +Expected output: + +``` +▸ anonymous → get_compensation (route requires authentication) + ✗ DENIED [routes.tool:get_compensation.apl.pre_invocation[0]] access denied + +▸ anonymous → search_repos (route has no rule) + ✓ ALLOWED {"visibility":"public","repositories":[{"name":"brand-site","visibility":"public"}]} +``` + +The `get_employee` policy above follows the same model. Once a caller has an identity (tutorial [module 2]({{< relref "/docs/tutorial/02-identity" >}})), its `result` pipeline produces the redaction outcomes (tutorial [module 3]({{< relref "/docs/tutorial/03-shaping" >}})): - An HR caller with `view_ssn` receives the full record. - An HR caller without `view_ssn` receives the record with `ssn` redacted before it leaves CPEX. @@ -62,6 +85,8 @@ The outcome matches the scenario: ## Next +- [Tutorial]({{< relref "/docs/tutorial" >}}): build this up hands-on, one capability per module, with runnable code you can edit and re-run. +- [Use Cases]({{< relref "/docs/use-cases" >}}): the full set of controls running end-to-end behind a real gateway. - [APL]({{< relref "/docs/apl" >}}): the full language: predicates, effects, field pipelines, phases. - [Identity]({{< relref "/docs/apl/identity" >}}): resolving callers into the attributes policy reads. - [PDP Integration]({{< relref "/docs/apl/pdp" >}}): delegating decisions to Cedar, CEL, or an external engine. diff --git a/docs/content/docs/testing.md b/docs/content/docs/testing.md index 2802f297..3f20c27d 100644 --- a/docs/content/docs/testing.md +++ b/docs/content/docs/testing.md @@ -5,7 +5,7 @@ weight: 110 # Testing Policy -Policy is code, and it deserves tests. The behaviors worth covering are the ones the scenario demonstrates: a route allows the right callers, denies the wrong ones, and redacts the right fields. Because APL is declarative and evaluated by `apl-core`, you can test a route by evaluating it against fixture identities and asserting the outcome, without standing up a live backend. +Policy is code, and it deserves tests. The behaviors worth covering are the ones the scenario demonstrates: a route allows the right callers, denies the wrong ones, redacts the right fields, and carries taint across a session. Because APL is declarative and evaluated by the runtime, you can test a route by loading a policy and driving operations through it, asserting the outcome, without standing up a live backend. ## What to test @@ -17,15 +17,48 @@ For each route, cover the outcomes its policy produces: - **Information flow**: a session that acquired a taint label is blocked on a later operation that gates on it. - **Delegation**: a passing caller mints a token with the requested scope, and a post-check denies when the granted scope is short. -## Evaluating a route in a test +## A table-driven policy test -Compile the config and evaluate a route against an attribute bag standing in for a caller. Assert the decision and the transformed payload. The `apl-core` and `apl-cpex` crates expose the evaluator used by the runtime; their test suites (for example `crates/apl-core/tests`) are the working reference for the exact entry points and fixtures. +Load a policy into a manager, drive routes through it with a fake backend, and assert the outcome. The tutorial ships this as a working template you can copy: [`examples/tutorial/tests/policy_tests.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/tests/policy_tests.rs). The setup is a small helper: -A redaction test, in shape: +```rust +async fn manager_with(policy: &str) -> Arc { + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(policy).expect("policy should load"); + mgr.initialize().await.expect("initialize"); + mgr +} +``` + +Then a table keeps the allow/deny matrix readable, one row per case: + +```rust +#[tokio::test] +async fn external_email_denied_with_custom_code() { + let mgr = manager_with(POLICY).await; + let outcome = mediate( + &mgr, + &Caller::anonymous(), + "send_email", + json!({ "to": "x@evil.example", "external": true }), + |args| backends::dispatch("send_email", args), + ) + .await; + assert!(matches!( + outcome, + Outcome::Denied { code, .. } if code == "email.external_blocked" + )); +} +``` + +`mediate()` here is the tutorial's harness wrapper around the host dispatch loop, not a CPEX API; in your own host you would drive the same route through your own loop and assert on the result. Anonymous callers are enough to exercise structural rules (authentication gates, argument guards, `result` pipelines) with no IdP. For identity-dependent rules, mint a token the way the tutorial's `idp` helper does. + +A stateful taint test follows the same shape but shares one session id across two calls: read a sensitive route, then assert a later `send_email` on the same session is denied on the taint label. Tutorial [module 7]({{< relref "/docs/tutorial/07-tainting" >}}) is the worked example; [module 10]({{< relref "/docs/tutorial/10-testing" >}}) walks through the test file above. + +## Scenario checks -- build a bag for an HR caller **with** `perm.view_ssn`, evaluate `get_employee`, assert `ssn` is present; -- build a bag for an HR caller **without** `perm.view_ssn`, evaluate the same route, assert `ssn` is redacted; -- build a bag for a non-HR caller, evaluate, assert deny at `require(role.hr)`. +Beyond unit tests, each tutorial module binary supports a `--check` flag that runs its scripted scenario and exits non-zero if the outcome drifts. `make tutorial-check` boots the tutorial IdP, runs every module's check, and tears it down. This is a lightweight way to pin end-to-end behavior (including the identity- and delegation-backed paths) in CI. ## Integration coverage @@ -34,7 +67,8 @@ Unit-evaluating a route proves the policy logic. It does not prove the plugins i ## Running ```bash -cargo test --workspace +cargo test -p cpex-tutorial # the policy tests above +cargo test --workspace # everything, including the runtime and APL suites ``` -See [`crates/cpex-core/examples`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-core/examples) for runnable programs that load a config and invoke routes, which double as a starting point for integration tests against your own policy. +Copy [`examples/tutorial/tests/policy_tests.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/tests/policy_tests.rs) as the starting point for tests against your own policy. diff --git a/docs/content/docs/threat-model.md b/docs/content/docs/threat-model.md new file mode 100644 index 00000000..f8035114 --- /dev/null +++ b/docs/content/docs/threat-model.md @@ -0,0 +1,122 @@ +--- +title: "Threat Model" +weight: 15 +--- + +# Threat Model + +> This threat model defines the adversary CPEX assumes, the boundary it enforces, and what each deployment placement does and does not cover. See [Vision]({{< relref "/docs/vision" >}}) for the underlying model. + +## The adversary + +CPEX assumes the LLM driving an agent is compromised, or close enough that the difference does not matter. Three things make it untrusted: + +- **Its inputs are attacker-reachable.** Prompt injection can arrive through any content the model reads: user messages, tool results, fetched resources, other agents' replies. +- **Its outputs are attacker-shaped.** An injected instruction becomes a tool call, an argument value, an email body. The model is a confused deputy: it acts with the agent's authority on whoever's behalf the text says. +- **It cannot keep secrets or enforce rules.** Anything in the context window can be exfiltrated through an allowed output channel, and any instruction in the prompt can be overridden by a later one. + +The assets at stake sit behind the agent: backend data (records, code, mail), the credentials the agent holds, tools with side effects (payments, writes, messages), and the integrity of the audit trail itself. + +The consequence is the reference-monitor rule: authorization, delegation, and information-flow decisions cannot live in the model, in the prompt, or in agent code the model steers. They live at a boundary the model's output must cross, evaluated against state the model cannot see or forge. + +## The trust boundary + +CPEX draws that boundary. Every operation the agent attempts crosses it; nothing the model emits reaches a capability directly. + +![The CPEX trust boundary: an untrusted caller and agent on one side, mediated capabilities on the other, with the CPEX reference monitor between them evaluating APL policy against identity, delegation, taint, and audit state the model cannot forge, fed by an IdP and a PDP](images/threat_model.png) + +Everything to the left of the monitor is assumed hostile, and nothing the policy reads comes from there: verified tokens come from the IdP (identity provider), decisions from the PDP, taint labels from the session store, and the delegation and audit state is CPEX's own. The model can ask for anything; it can influence none of the state the answer depends on. + +## Threats and controls + +| Threat | Without mediation | CPEX control | +|---|---|---| +| Prompt-injection-driven tool misuse | injected text becomes an executed tool call | `require(...)` attribute gates and `args` validation run before dispatch; a PDP decides relationship questions ([Effects]({{< relref "/docs/apl/effects" >}}), [PDP]({{< relref "/docs/apl/pdp" >}})) | +| Confused deputy / privilege escalation | the agent acts with one blanket identity for all callers | identity resolved per caller from verified tokens; entitlements differ per subject, not per prompt ([Identity]({{< relref "/docs/apl/identity" >}})) | +| Cross-request data exfiltration (write-down) | data read in one call leaves through a later, innocent-looking call | `taint(...)` labels the session in CPEX-owned state; later operations deny on the label even with clean payloads ([Session Tainting]({{< relref "/docs/apl/tainting" >}})) | +| Credential exposure and over-broad tokens | backends receive the caller's raw IdP credential | `delegate(...)` exchanges it for a fresh audience-scoped token (RFC 8693); the granted scope is verified before use ([Delegation]({{< relref "/docs/apl/delegation" >}})) | +| PII disclosure | sensitive values flow into arguments and out in results | PII scanning on `args`, field-level `redact`/`mask` pipelines on `result` ([Builtins]({{< relref "/docs/builtins" >}})) | +| Unauthorized high-impact actions | the model triggers irreversible operations on its own authority | `require_approval(...)` suspends the call for out-of-band human sign-off ([Elicitation]({{< relref "/docs/apl/elicitation" >}})) | +| Approval replay | one sign-off is reused for a larger or different action | approvals are scope-bound to the live arguments and validated on resume ([Elicitation]({{< relref "/docs/apl/elicitation" >}})) | +| Unaccountable actions | no trustworthy record of what the agent did | an audit plugin emits an append-only record per decision, including denied attempts ([Patterns]({{< relref "/docs/patterns" >}})) | + +No single row is load-bearing alone. The [defense-in-depth pattern]({{< relref "/docs/patterns#defense-in-depth" >}}) composes them in one route. + +## Where the boundary sits, and what each placement covers + +CPEX is direction-agnostic: the same APL policy enforces at any placement (see [Deployment]({{< relref "/docs/deployment" >}})). The placement decides which traffic is mediated, which is the threat-model question: an enforcement point only stops what crosses it. + +### Proxy / gateway (inbound) + +CPEX fronts a tool server. Every request to that backend crosses the boundary, whichever agent or client sent it. + +![CPEX as a gateway: agents and direct clients all pass through the CPEX gateway before reaching the tool server](images/threat_model_gateway.png) + +**Covers** + +- Every caller of the protected backend, including agents you do not operate and callers that bypass the "official" agent. +- On-the-wire transformation: the backend never sees redacted values, and never sees the caller's raw IdP credential when delegation mints a scoped token. +- A single audit chokepoint for the resource. + +**Does not cover** + +- Anything the agent does that never touches this backend: other tools, other APIs, side channels. +- Agent-internal context. The gateway sees requests, not the conversation, so per-turn or lineage-based policy has less to read. + +This is the placement in the end-to-end [Praxis demo]({{< relref "/docs/use-cases" >}}). + +### Endpoint / workload sidecar (outbound) + +CPEX sits beside one agent and mediates its egress. Everything that agent emits crosses the boundary, whatever it targets. + +![CPEX as an egress sidecar: all egress from the agent workload passes through the CPEX sidecar on its way to internal tools, third-party APIs, and other agents](images/threat_model_sidecar.png) + +**Covers** + +- The complete outbound surface of the workload, including third-party APIs you do not control and could never gateway. +- Workload identity: the `workload.*` attributes carry attested identity (SPIFFE / mTLS), so policy can bind decisions to which workload is calling, not just which user. +- Exfiltration control for a specific agent: taint follows the session across every backend the agent reaches. + +**Does not cover** + +- Other paths to the same backends. The sidecar protects the world from this agent, not the backend from other callers. +- Traffic that escapes the sidecar's capture. Egress must be forced through it at the network level, or the guarantee is advisory. +- Inference traffic, unless you deliberately route it through the policy path (see [Deployment]({{< relref "/docs/deployment#inference-traffic" >}})). + +### Agent / agentic framework (in-process) + +CPEX runs inside the agent runtime and mediates operations as the framework issues them. + +![CPEX in-process: inside the agent runtime the LLM drives the framework, whose operations pass through in-process CPEX before reaching tools and backends](images/threat_model_inframework.png) + +**Covers** + +- The richest policy context: `agent.*` (session, conversation, turn, lineage) and `framework.*` (node and graph ids) attributes let policy reason about where in the agent's execution an operation originates. +- Enforcement before any network hop, including on operations that never leave the process. +- Human-in-the-loop and taint decisions at the reasoning step, closest to the intent. + +**Does not cover** + +- Process-level compromise. CPEX shares the agent's process; code execution in the runtime can bypass an in-process monitor. This placement hardens the agent's decisions, not its host. +- Traffic from anything other than this runtime. + +### Compose them + +The placements are not alternatives. The strongest posture pairs an in-framework monitor (rich context, soft and enforcement-tier policy) with a gateway or sidecar at an infrastructure boundary the agent process cannot rewrite (hard-tier policy: identity, information flow, audit). That is the policy spectrum from [Vision]({{< relref "/docs/vision#the-policy-spectrum" >}}): the same APL object at every point, so layering placements costs no policy duplication. + +## Out of scope + +CPEX is a reference monitor, not a model alignment technique. Explicitly outside this threat model: + +- **Model-internal behavior.** CPEX constrains what the model's decisions can do, not what the model decides. A jailbroken model behind CPEX still only reaches what policy allows. +- **Covert channels inside allowed traffic.** If policy permits an output channel, a determined model can encode data into it. Taint narrows this (deny the channel once secrets are touched); it does not eliminate encoding within permitted flows. +- **Compromise of the CPEX host.** The monitor's guarantees hold only as long as the process and state stores it depends on are intact. Run hard-tier enforcement on infrastructure with a smaller attack surface than the agent it guards. +- **Backends themselves.** CPEX decides what reaches a tool and what comes back; it does not vouch for what the tool does internally. + +Believe you have found a policy-enforcement bypass? That is our critical severity class. Report it privately via the process in [SECURITY.md](https://github.com/contextforge-org/cpex/blob/main/SECURITY.md). + +## What to read next + +- [Use Cases]({{< relref "/docs/use-cases" >}}): the controls above running end-to-end in a gateway deployment. +- [Deployment]({{< relref "/docs/deployment" >}}): choosing and wiring a placement. +- [Patterns]({{< relref "/docs/patterns" >}}): layering the controls in production policy. diff --git a/docs/content/docs/tutorial/00-setup.md b/docs/content/docs/tutorial/00-setup.md new file mode 100644 index 00000000..c8454c13 --- /dev/null +++ b/docs/content/docs/tutorial/00-setup.md @@ -0,0 +1,58 @@ +--- +title: "Setup & orientation" +weight: 1 +--- + +# Module 0: Setup & orientation + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module gets you oriented and checks your setup. It changes nothing on your system. + +**Goal:** understand what CPEX is, confirm your toolchain, and know which modules need the IdP. + +## What CPEX is + +CPEX is a policy enforcement runtime for AI agents. It is a deterministic reference monitor between an agent and every capability it invokes: tools, prompts, resources, inference providers. Each capability defines its own enforcement pipeline (authorization, delegation, redaction, information-flow control, audit), written declaratively in [APL]({{< relref "/docs/apl" >}}) and run at the boundary. + +You are the host: the process that embeds CPEX and drives the loop of resolving identity, running policy, calling the backend, and running policy again on the result. The tutorial harness wraps that loop in a single `mediate()` call so you can focus on policy. Module 9 opens `mediate()` up and shows the real dispatch API underneath. + +## Check your setup + +```bash +cargo run -p cpex-tutorial --example m00_setup +``` + +The crate builds, and you are told whether the IdP is up: + +``` +=== Module 0: Setup & orientation === +... +Checking the tutorial IdP (Keycloak) ... not running +Modules 0–1 work without it. For module 2 onward, start it: + docker compose -f examples/tutorial/idp/docker-compose.yml up -d +``` + +## The IdP + +From [module 2]({{< relref "02-identity" >}}) on, the tutorial resolves real JWTs from a Keycloak realm. Identity, token exchange, and JWKS validation are core to what CPEX enforces, so mocking them would teach a fake shape of the problem. Starting it is one command: + +```bash +docker compose -f examples/tutorial/idp/docker-compose.yml up -d # about 30s on first boot +``` + +The realm is imported on start and lives only in the container. `docker compose ... down` wipes it, which is also your reset button. Every credential in it is tutorial-only. Never reuse them. See [`idp/README.md`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/idp) for the personas. + +You do not need it yet. Modules 0 and 1 run on Rust alone. + +## Checkpoint + +{{< details "Who runs the enforcement loop, CPEX or your application?" >}} +Your application (the host). CPEX is a library you embed at the boundary. It evaluates and enforces policy, but the host owns the loop that calls it. The tutorial's `mediate()` is that loop. +{{< /details >}} + +{{< details "Why a real Keycloak instead of a mock?" >}} +Identity resolution, token exchange, and JWKS-based validation are central to what CPEX does. A mock would let you write policy against a shape that does not match reality. The realm is small and disposable, so the cost is one `docker compose up`. +{{< /details >}} + +## Next + +[Module 1: Hello, enforcement]({{< relref "01-hello" >}}): the smallest CPEX host, and your first allow and deny. diff --git a/docs/content/docs/tutorial/01-hello.md b/docs/content/docs/tutorial/01-hello.md new file mode 100644 index 00000000..e276dad4 --- /dev/null +++ b/docs/content/docs/tutorial/01-hello.md @@ -0,0 +1,85 @@ +--- +title: "Hello, enforcement" +weight: 2 +--- + +# Module 1: Hello, enforcement + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). No IdP needed for this module. + +**Goal:** stand up the smallest possible CPEX enforcement point and see a route allow one call and deny another, with no application logic making the decision. + +## The problem + +You have tools an agent can call. Some should be gated, some open. You do not want that decision scattered through handler code, where it drifts and is hard to audit. You want it in one place, declarative, at the boundary. + +## Build it + +The host is three lines plus loading a policy. From [`examples/tutorial/examples/m01_hello.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/examples/m01_hello.rs): + +```rust +let mgr = Arc::new(PluginManager::default()); +cpex::install_builtins(&mgr); // register the bundled plugins + APL visitor +mgr.load_config_yaml(POLICY).unwrap(); // load policies/m01.yaml +mgr.initialize().await.unwrap(); +``` + +The policy ([`policies/m01.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m01.yaml)) defines two routes: + +```yaml +routes: + - tool: get_compensation + authorization: + pre_invocation: + - "require(authenticated)" # denies an anonymous caller + - tool: search_repos + authorization: + pre_invocation: [] # no rule, so open +``` + +Each call goes through `mediate()`, the harness wrapper around the loop a host owns: resolve identity, run policy, call the backend, run policy on the result. It is harness code, not a CPEX API. Module 9 opens it up. + +## Run it + +```bash +cargo run -p cpex-tutorial --example m01_hello +``` + +``` +▸ anonymous → get_compensation (route requires authentication) + ✗ DENIED [routes.tool:get_compensation.apl.pre_invocation[0]] access denied + +▸ anonymous → search_repos (route has no rule) + ✓ ALLOWED {"visibility":"public","repositories":[{"name":"brand-site","visibility":"public"}]} +``` + +Same anonymous caller, same host code. The route decided the outcome, and the denial names the exact rule that failed. + +{{< asciinema cast="https://asciinema.org/a/GWQ1rUgWufRxMUcE.cast" poster="npt:0:03" >}} + +## Try it + +1. Change the failing predicate. In `policies/m01.yaml`, change `require(authenticated)` to `require(role.hr)` and re-run. Expect: `get_compensation` still denies, but the reason points at the new rule, since nobody has a role yet. +2. Open the gated route. Delete the `require(authenticated)` line (leave `pre_invocation: []`) and re-run. Expect: both calls allow. +3. Gate the open route. Add `- "require(authenticated)"` under `search_repos` and re-run. Expect: both calls deny. + +Reset any time with `git checkout -- examples/tutorial/policies`. + +## Checkpoint + +{{< details "Why did get_compensation deny when the Rust code never checked anything?" >}} +The route's `require(authenticated)` predicate failed, because the caller is anonymous. The decision lives entirely in policy. `mediate()` just reports what the route decided. +{{< /details >}} + +{{< details "What makes search_repos allow?" >}} +It has no denying rule. A route with an empty or absent `pre_invocation` is open by default. CPEX blocks only what policy tells it to. +{{< /details >}} + +## Go deeper + +- [APL: routes and phases]({{< relref "/docs/apl" >}}) for the full route model. +- [Quick Start]({{< relref "/docs/quickstart" >}}) for the same shape in prose. + +## Next + +[Module 2: Who's calling?]({{< relref "02-identity" >}}): give callers a real identity so `require(role.hr)` has something to check. Start the IdP first. diff --git a/docs/content/docs/tutorial/02-identity.md b/docs/content/docs/tutorial/02-identity.md new file mode 100644 index 00000000..cd62476a --- /dev/null +++ b/docs/content/docs/tutorial/02-identity.md @@ -0,0 +1,93 @@ +--- +title: "Who's calling?" +weight: 3 +--- + +# Module 2: Who's calling? (Identity) + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. Start it first: +> `docker compose -f examples/tutorial/idp/docker-compose.yml up -d` + +**Goal:** resolve a real bearer token into a subject (an id, roles, and permissions) so authorization predicates have something to read. + +## The problem + +In module 1, `require(role.hr)` denied everyone because nobody had a role. Roles and permissions come from identity: a verified token the caller presents. CPEX turns that token into attributes policy can gate on, and it must do so without trusting anything the caller could forge. + +## Build it + +Add an identity plugin and reference it from the route. From [`policies/m02.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m02.yaml): + +```yaml +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + config: + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true # localhost speaks http; never in production + leeway_seconds: 60 + +routes: + - tool: get_compensation + authentication: + - keycloak + authorization: + pre_invocation: + - "require(authenticated)" + - "require(role.hr)" +``` + +The plugin validates the JWT offline against the realm's signing keys, fetched once from the JWKS url and cached. It checks issuer, audience, expiry, and signature. A token that fails any check is rejected before any authorization rule runs. The realm emits flat `roles` and `permissions` claims (see [`idp/README.md`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/idp)). The `standard` mapper turns `roles: ["hr"]` into `role.hr = true` and `permissions: ["view_ssn"]` into `perm.view_ssn = true`. + +The tutorial personas: alice (hr and `view_ssn`), dana (hr, no `view_ssn`), evan (engineer), sam (security). + +## Run it + +```bash +cargo run -p cpex-tutorial --example m02_identity +``` + +``` +▸ alice (hr) → get_compensation + ✓ ALLOWED { ... } + +▸ evan (engineer) → get_compensation (fails require(role.hr)) + ✗ DENIED [...] access denied + +▸ garbage token → get_compensation (rejected at validation) + ✗ DENIED [auth.malformed_header] ... +``` + +The harness mints each persona's token from Keycloak with a password grant (see `src/idp.rs`), then calls the same route. Identity, not code, splits the outcomes. + +## Try it + +1. Swap personas. In `m02_identity.rs`, mint `dana` instead of `evan`. Expect: dana (also hr) is allowed. The `view_ssn` difference does not matter until module 3. +2. Break the audience. In the policy, change `audiences: [cpex-tutorial]` to `[some-other-api]` and re-run. Expect: every call denies with `auth.audience_mismatch`, because validation fails before authorization. +3. Inspect a token. Mint one by hand (`idp/README.md` has the curl) and decode it to see the `roles`, `permissions`, and `aud` claims the mappers produced. + +## Checkpoint + +{{< details "Does CPEX call Keycloak on every request?" >}} +No. The plugin fetches the realm's public signing keys once and refreshes them periodically, then validates every token offline. Keycloak is on the token-minting path, not the per-request enforcement path. +{{< /details >}} + +{{< details "Why is the garbage token denied with an auth code, not an authorization code?" >}} +Identity resolution runs before authorization. An unverifiable token never produces a subject, so the request is rejected at validation. The `require(role.hr)` rule is never reached. +{{< /details >}} + +## Go deeper + +- [Identity & IdP]({{< relref "/docs/apl/identity" >}}) for resolvers, the attribute bag, and claim mapping. + +## Next + +[Module 3: Shaping data]({{< relref "03-shaping" >}}): now that callers differ by permission, return a different view of the same record to each. diff --git a/docs/content/docs/tutorial/03-shaping.md b/docs/content/docs/tutorial/03-shaping.md new file mode 100644 index 00000000..86991501 --- /dev/null +++ b/docs/content/docs/tutorial/03-shaping.md @@ -0,0 +1,72 @@ +--- +title: "Shaping data" +weight: 4 +--- + +# Module 3: Shaping data + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). Runs without the IdP (redaction fires for anonymous callers). The full contrast needs it. + +**Goal:** return a different view of the same backend record per caller, by transforming the result on the way out with redact and mask, gated by permission. + +## The problem + +An HR analyst with clearance should see an employee's SSN. One without should get the record with the SSN removed. Not a different endpoint, not a second query: the same call with a field stripped. The backend returns the full record, so policy must shape it before it leaves the boundary. + +## Build it + +The route allows the call, then a `result:` field pipeline transforms the response. From [`policies/m03.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m03.yaml): + +```yaml +routes: + - tool: get_compensation + authorization: + pre_invocation: [] + result: + ssn: "str | redact(!perm.view_ssn)" + salary: "int | redact(!role.hr)" + employee_id: "str | mask(4)" +``` + +Each entry reads `: " | ()"`. The op runs only when its predicate holds. `redact(!perm.view_ssn)` means redact when the caller does not have `view_ssn`. `mask(4)` always keeps the last four characters. This runs in the Post phase, after the backend returns and before the caller sees the response. + +## Run it + +Without a token, no permissions are set, so both redactions fire: + +```bash +cargo run -p cpex-tutorial --example m03_shaping +``` + +``` +▸ anonymous → get_compensation (result pipeline redacts ssn + salary, masks id) + ✓ ALLOWED {"employee_id":"**1001","name":"Alice Okafor","title":"Staff Engineer","salary":"[REDACTED]","ssn":"[REDACTED]"} +``` + +The call is allowed, so the record still comes back, but `ssn` and `salary` are redacted and `employee_id` is masked. The backend returned all of it. Policy shaped it. + +{{< asciinema cast="https://asciinema.org/a/FJscsrCUbnazbPSl.cast" poster="npt:0:03" >}} + +## Try it + +1. See the full record. Start the IdP and change the example to mint `alice` (hr and `view_ssn`). Expect: `ssn` and `salary` come back in full, `employee_id` still masked. Same policy, different caller. +2. Split the two fields. Mint `dana` (hr, no `view_ssn`). Expect: `salary` is visible (she has `role.hr`) but `ssn` is redacted (she lacks `view_ssn`). The two redactions gate independently. +3. Change the mask width. Set `employee_id: "str | mask(2)"`. Expect: only the last two characters survive. + +## Checkpoint + +{{< details "Was the call allowed or denied?" >}} +Allowed. Redaction is not denial. The operation runs and returns, and the result pipeline transforms specific fields on the way out. Denial (module 1) stops the call entirely. +{{< /details >}} + +{{< details "Where does the redaction happen, before or after the backend?" >}} +After. The `result:` pipeline runs in the Post phase, on the backend's response. The backend always returns the full record, and the boundary decides what leaves. +{{< /details >}} + +## Go deeper + +- [APL: field pipelines]({{< relref "/docs/apl" >}}) and [Effects]({{< relref "/docs/apl/effects" >}}). + +## Next + +[Module 4: Effects & sequencing]({{< relref "04-effects" >}}): compose multiple effects in order, add auditing, and write your own denial codes. diff --git a/docs/content/docs/tutorial/04-effects.md b/docs/content/docs/tutorial/04-effects.md new file mode 100644 index 00000000..b940d068 --- /dev/null +++ b/docs/content/docs/tutorial/04-effects.md @@ -0,0 +1,84 @@ +--- +title: "Effects & sequencing" +weight: 5 +--- + +# Module 4: Effects & sequencing + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). Runs without the IdP. + +**Goal:** compose several effects in an ordered pipeline that halts on the first denial, run a side-effecting plugin (audit), and emit your own machine-readable denial code. + +## The problem + +Real routes do more than one thing. They audit the attempt, apply a business rule, then check authorization, in a specific order, where a denial stops everything after it but not the side effects before it. You need to see and control that ordering. + +## Build it + +`pre_invocation` is an ordered list. From [`policies/m04.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m04.yaml): + +```yaml +plugins: + - name: audit-log + kind: audit/logger + hooks: [cmf.tool_pre_invoke] + capabilities: [read_subject, read_meta] + config: { destination: stderr } + +routes: + - tool: send_email + authorization: + pre_invocation: + - "run(audit-log)" + - "args.external == true: deny('outbound email to external recipients is blocked', 'email.external_blocked')" + - "require(authenticated)" +``` + +Three effects, top to bottom: + +1. `run(audit-log)` records the attempt and always allows, so the pipeline continues. This is a side effect. +2. A guard, `: deny('', '')`. `deny(...)` produces a denial with a code you choose, so downstream systems can branch on it. +3. `require(authenticated)` is reached only if the guard passed. Halt-on-deny means a denial at step 2 skips step 3. + +## Run it + +```bash +cargo run -p cpex-tutorial --example m04_effects +``` + +``` +▸ send_email to an external recipient (custom deny guard halts) +{"ts":"...","plugin":"audit-log","entity":{"type":"tool","name":"send_email"}, ...} + ✗ DENIED [email.external_blocked] outbound email to external recipients is blocked + +▸ send_email to an internal recipient (guard passes, auth check halts) +{"ts":"...","plugin":"audit-log", ...} + ✗ DENIED [routes.tool:send_email.apl.pre_invocation[2]] access denied +``` + +The audit line (stderr) fires on both calls, because it runs before the denials. The external call halts at the custom guard with `email.external_blocked`. The internal call gets past the guard to `require(authenticated)` and halts there. Same route, different stopping point, because the order is policy. + +## Try it + +1. Reorder. Move `require(authenticated)` to the top and re-run. Expect: both calls deny with the authentication code, and the external-recipient guard never runs. Ordering changed the outcome and the reason. +2. Change the code. Edit the `deny(...)` code string to `email.blocked_v2`. Expect: the external call's reason code changes, nothing else does. +3. Drop audit. Remove the `run(audit-log)` line and re-run. Expect: same allow and deny outcomes, but no audit line on stderr. The side effect is gone. + +## Checkpoint + +{{< details "Why does the audit line appear even when the call is denied?" >}} +Because `run(audit-log)` sits before the denying effects. Halt-on-deny stops effects after a denial, not before it. Side effects that already ran still happened. +{{< /details >}} + +{{< details "What decides the denial's reason code?" >}} +For a hand-written guard, the second argument to `deny('reason', 'code')`. For a `require(...)` that fails, CPEX derives a code from the rule's position. Custom codes let downstream systems branch on why something was denied. +{{< /details >}} + +## Go deeper + +- [Effects & Sequencing]({{< relref "/docs/apl/effects" >}}) for the full effect set, halt-on-deny, and `on_allow`/`on_deny` reactions. +- [Builtins]({{< relref "/docs/builtins" >}}) for the audit logger and other bundled plugins. + +## Next + +Modules 5 through 10 continue with PDPs, scoped credentials, information flow, human-in-the-loop, custom plugins, and testing, ending in the capstone that reassembles the whole three-backend scenario. diff --git a/docs/content/docs/tutorial/05-pdp.md b/docs/content/docs/tutorial/05-pdp.md new file mode 100644 index 00000000..adf40bff --- /dev/null +++ b/docs/content/docs/tutorial/05-pdp.md @@ -0,0 +1,82 @@ +--- +title: "Delegating decisions (PDP)" +weight: 6 +--- + +# Module 5: Delegating decisions (PDP) + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. + +**Goal:** hand an authorization decision to a policy engine (a PDP) instead of writing it as a list of `require()` predicates, then let CPEX enforce the engine's verdict. + +## The problem + +Some rules mix several inputs at once: "engineers may search internal repos only; security may search anything." Expressing that as separate `require()` lines is awkward and easy to get wrong. A rule engine expression states it directly. + +## Build it + +Declare a PDP once, then reference it from a step. From [`policies/m05.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m05.yaml): + +```yaml +global: + apl: + pdp: + - kind: cel + +routes: + - tool: search_repos + authentication: + - keycloak + authorization: + pre_invocation: + - "require(authenticated)" + - cel: + expr: "(has(role.engineer) && role.engineer && args.visibility == 'internal') || (has(role.security) && role.security)" + on_deny: + - "deny('engineers read internal only; security reads any', 'repo.policy_denied')" +``` + +The `cel:` step evaluates a boolean expression over the same attributes predicates see: roles and request arguments. `true` allows, `false` runs `on_deny`. Guard optional attributes with `has(...)`, because referencing an unset attribute is an error, and CEL errors fail closed. Cedar is available too (`kind: cedar-direct`) when you want policy as data rather than an expression. + +## Run it + +```bash +cargo run -p cpex-tutorial --example m05_pdp +``` + +``` +▸ evan (engineer) → search_repos internal (CEL allows) + ✓ ALLOWED { ... internal repos ... } + +▸ evan (engineer) → search_repos public (CEL denies: engineers internal-only) + ✗ DENIED [repo.policy_denied] engineers read internal only; security reads any + +▸ sam (security) → search_repos public (CEL allows: security reads any) + ✓ ALLOWED { ... public repos ... } +``` + +One expression captured a rule that mixes role and argument. The PDP decided, and CPEX enforced. + +## Try it + +1. Loosen the engineer rule. Change the expression to also allow engineers to read public repos. Re-run and confirm evan's public search now allows. +2. Break a guard. Remove a `has(...)` guard and run as sam. Expect: sam is denied, because the expression errors on the unset `role.engineer` and fails closed. +3. Change the deny code. Edit the `on_deny` code and confirm the reason code changes. + +## Checkpoint + +{{< details "Who makes the decision, CPEX or the PDP?" >}} +The PDP. CPEX gathers the attributes, calls the engine, and enforces the verdict. The engine is the source of truth for that decision. +{{< /details >}} + +{{< details "Why did an unguarded expression fail closed?" >}} +Referencing an attribute that is not set is a CEL error. CPEX treats an errored PDP as a deny, so a typo or a missing attribute never accidentally allows. +{{< /details >}} + +## Go deeper + +- [PDP Integration]({{< relref "/docs/apl/pdp" >}}) for CEL, Cedar, and external engines. + +## Next + +[Module 6: Scoped credentials]({{< relref "06-delegation" >}}): mint a downstream-scoped token with a real token exchange. diff --git a/docs/content/docs/tutorial/06-delegation.md b/docs/content/docs/tutorial/06-delegation.md new file mode 100644 index 00000000..c3f1131e --- /dev/null +++ b/docs/content/docs/tutorial/06-delegation.md @@ -0,0 +1,92 @@ +--- +title: "Scoped credentials (Delegation)" +weight: 7 +--- + +# Module 6: Scoped credentials (Delegation) + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. + +**Goal:** mint a narrow, downstream-scoped credential for a call with a real OAuth 2.0 token exchange (RFC 8693), instead of forwarding the caller's full token. + +## The problem + +When your agent calls a downstream API, handing it the caller's original token is over-broad: that token works everywhere, for everything the caller can do. You want a token minted for this one downstream call, scoped to a single audience, so a leak is contained. Token exchange does that, and CPEX makes it a policy step rather than integration code. + +## Build it + +Add a delegator plugin and a `delegate(...)` step. From [`policies/m06.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m06.yaml): + +```yaml +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + config: { ... as in module 2 ... } + + - name: workday-oauth + kind: delegator/oauth + hooks: [token.delegate] + capabilities: [read_inbound_credentials, write_delegated_tokens] + config: + token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token + client_id: cpex-gateway + client_secret_source: { kind: literal, secret: gateway-dev-secret } + insecure_http: true + +routes: + - tool: get_compensation + authentication: [keycloak] + authorization: + pre_invocation: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, audience: workday-api)" + - "require(delegation.granted)" +``` + +The `delegate(...)` step exchanges the caller's token for one scoped to the `workday-api` audience, against the real Keycloak token endpoint. `require(delegation.granted)` then proceeds only if the exchange succeeded. + +Two things are load-bearing: + +- The plugin declares `capabilities: [read_inbound_credentials, write_delegated_tokens]`. Without them, the caller's inbound token is filtered out before the exchange runs, and delegation fails with an empty token. Capabilities scope what each step may touch. +- The gateway client in Keycloak must be allowed to exchange for the target audience. In the tutorial realm, `cpex-gateway` sets `standard.token.exchange.audiences: workday-api,github-api` and carries audience mappers for those clients (see [`idp/realm-export.json`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/idp)). + +## Run it + +```bash +cargo run -p cpex-tutorial --example m06_delegation +``` + +``` +▸ alice (hr) → get_compensation (delegate mints a workday-api token, then allow) + ✓ ALLOWED { ... } + +▸ evan (engineer) → get_compensation (denied at require(role.hr), no delegation) + ✗ DENIED [...] access denied +``` + +alice's call runs a real token exchange and gets a `workday-api`-scoped token before the backend call. evan never reaches delegation, because `require(role.hr)` stops him first. Delegation is cheap to skip when it is not needed. + +## Try it + +1. Drop the capabilities. Remove the `capabilities:` line from the plugin and re-run. Expect: alice is denied with `delegation.bad_request` (empty token), because the inbound credential was filtered out. +2. Wrong audience. Change `audience:` to a client the gateway may not target and re-run. Expect: the exchange is rejected by Keycloak and the step denies. +3. Inspect the exchange. Add a `permissions: [read_compensation]` arg and set up the matching client scope in the realm to see a scoped-down token (this is how you narrow the downstream grant). + +## Checkpoint + +{{< details "Why does the plugin need capabilities to succeed?" >}} +The inbound token is sensitive, so CPEX filters it out at the boundary unless a step declares `read_inbound_credentials`. Declaring the capability is how a delegator opts in to reading it, and `write_delegated_tokens` lets it record the minted token. +{{< /details >}} + +{{< details "Where does the scoping happen, CPEX or Keycloak?" >}} +Keycloak mints the scoped token during the exchange, constrained by the requested audience and the gateway client's allowed targets. CPEX drives the exchange as a policy step and enforces that it succeeded. +{{< /details >}} + +## Go deeper + +- [Delegation]({{< relref "/docs/apl/delegation" >}}) for token exchange, capability reduction, and downstream verification. + +## Next + +[Module 8: Human in the loop]({{< relref "08-elicitation" >}}) (or the capstone, which reassembles all three backends). diff --git a/docs/content/docs/tutorial/07-tainting.md b/docs/content/docs/tutorial/07-tainting.md new file mode 100644 index 00000000..554b7b0b --- /dev/null +++ b/docs/content/docs/tutorial/07-tainting.md @@ -0,0 +1,82 @@ +--- +title: "Information flow (Tainting)" +weight: 8 +--- + +# Module 7: Information flow (Tainting) + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. + +**Goal:** carry security state across a session so a later request can be denied because of what an earlier one did. This is how CPEX stops write-down. + +## The problem + +Most authorization systems judge each request alone. That misses a whole class of leaks: a caller reads sensitive data, then sends it out through a channel that looks harmless on its own. You want the second call blocked because of the first, even though nothing about the second call is suspicious in isolation. + +## Build it + +One route taints the session; another refuses when the taint is present. From [`policies/m07.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m07.yaml): + +```yaml +routes: + - tool: get_compensation + authentication: [keycloak] + authorization: + pre_invocation: + - "require(role.hr)" + - "taint(secret, session)" # mark the session as having read secret data + + - tool: send_email + authentication: [keycloak] + authorization: + pre_invocation: + - "require(authenticated)" + - "security.labels contains \"secret\": deny('write-down blocked: this session read secret data', 'session_tainted')" +``` + +`taint(secret, session)` records the `secret` label on the session, and it persists in the session store. `send_email` reads `security.labels`, the accumulated session state, not just this request's arguments. The label outlives the request that set it. + +## Run it + +```bash +cargo run -p cpex-tutorial --example m07_tainting +``` + +``` +▸ alice, fresh session → send_email (nothing tainted yet) + ✓ ALLOWED {"sent":true, ...} + +▸ alice, session-hr-work → get_compensation (taints session 'secret') + ✓ ALLOWED { ... } + +▸ alice, session-hr-work → send_email (write-down blocked) + ✗ DENIED [session_tainted] write-down blocked: this session read secret data +``` + +The same `send_email` call is allowed in a clean session and denied in the session that read compensation. The email's content is irrelevant. The session's history blocks it. + +{{< asciinema cast="https://asciinema.org/a/MCo8BWT3DvW7OH8d.cast" poster="npt:0:03" >}} + +## Try it + +1. Separate the sessions. Give the third call a new session id. Expect: it allows again, because taint is per session. +2. Taint from elsewhere. Add `taint(secret, session)` to a different route and confirm reading through that route also blocks later email. +3. Persist across restarts. Start Valkey (`docker compose -f examples/tutorial/idp/docker-compose.yml --profile valkey up -d`) and point the session store at it, so taint survives a process restart. The in-memory store used here resets when the program exits. + +## Checkpoint + +{{< details "Why is the email denied when its own content is harmless?" >}} +The denial is based on session state, not the email. `get_compensation` tainted the session with `secret`, and `send_email` refuses whenever that label is present. Information flow is tracked across the whole session. +{{< /details >}} + +{{< details "What ties the two calls together?" >}} +A shared session id plus the same authenticated subject. That pair keys the session store, so the taint set by the first call is visible to the second. +{{< /details >}} + +## Go deeper + +- [Session Tainting]({{< relref "/docs/apl/tainting" >}}) and [Patterns: cross-request information flow]({{< relref "/docs/patterns" >}}). + +## Next + +[Module 8: Human in the loop]({{< relref "08-elicitation" >}}): suspend an operation until a human approves it. diff --git a/docs/content/docs/tutorial/08-elicitation.md b/docs/content/docs/tutorial/08-elicitation.md new file mode 100644 index 00000000..6a065fa6 --- /dev/null +++ b/docs/content/docs/tutorial/08-elicitation.md @@ -0,0 +1,102 @@ +--- +title: "Human in the loop" +weight: 9 +--- + +# Module 8: Human in the loop (Elicitation) + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. + +**Goal:** suspend a sensitive operation until a human approves it, then resume it. The agent cannot proceed on its own. + +## The problem + +Some actions are too consequential to run on the agent's say-so: a large transfer, an irreversible change, an outbound message to a client. You want policy to pause the call, ask a human, and only continue once they approve. That means the operation must be able to suspend and resume, not just allow or deny. + +## Build it + +Add an elicitation plugin on the `elicit` hook and a `require_approval(...)` step. From [`policies/m08.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m08.yaml): + +```yaml +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + config: { ... as in module 2 ... } + - name: manager-approval + kind: approval-channel + hooks: [elicit] + +routes: + - tool: send_email + authentication: [keycloak] + authorization: + pre_invocation: + - "require(authenticated)" + - "require_approval(manager-approval, from: claim.manager, purpose: \"Approve outbound email\")" +``` + +The first time a caller hits this route, the approval is pending, so policy suspends the call and returns an elicitation id. A human approves out of band. The caller retries with the id, and now the approval is resolved, so the call proceeds. `from: claim.manager` resolves to the caller's manager from their token (evan's manager is mona). + +The approval plugin ([`examples/m08_elicitation.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/examples/m08_elicitation.rs)) implements the `elicit` hook's three operations against a tiny approval channel: + +```rust +match payload.operation() { + ElicitationOp::Dispatch => { /* open a pending request, return its id */ } + ElicitationOp::Check => { /* report Pending / Resolved{Approved|Denied} */ } + ElicitationOp::Validate => { /* confirm the approver */ } +} +``` + +The channel is served over HTTP so a human can approve with `curl`. In production this would be an OIDC CIBA backchannel or a push to the approver's phone. The point here is CPEX's suspend and resume model, not the notification transport. + +## Run it + +```bash +cargo run -p cpex-tutorial --example m08_elicitation +``` + +The first attempt suspends: + +``` +▸ evan → send_email (first attempt: suspends for manager approval) + ⏸ PENDING awaiting mona's approval (id elic-mona) + + Approve it from another terminal with: + curl -X POST localhost:8090/approvals/elic-mona/approve +``` + +Run that curl in a second terminal. The program is polling; once you approve, it retries and the call runs: + +``` +▸ evan → send_email (retry with the approval: resumes and runs) + ✓ ALLOWED {"sent":true, ...} +``` + +Run with `-- --check` to have it approve itself and exercise the whole path unattended. + +{{< asciinema cast="https://asciinema.org/a/xjfOzQwrEnrLLCp8.cast" poster="npt:0:04" >}} + +## Try it + +1. Deny instead. Use `curl -X POST localhost:8090/approvals/elic-mona/deny`. Expect: the retry is denied, not allowed. +2. List pending. `curl localhost:8090/approvals` shows the open request while the program waits. +3. Retry without approving. Resume before approving and confirm the call is still pending, not allowed. + +## Checkpoint + +{{< details "What is different about pending versus denied?" >}} +A denial ends the call. Pending suspends it: the operation has not run and can still proceed later, once a human approves and the caller retries with the elicitation id. The agent cannot resolve it alone. +{{< /details >}} + +{{< details "How does the retry reach the same approval?" >}} +The first attempt returns an elicitation id. The caller echoes it on retry (the harness sends it as a resume header), so policy checks that existing approval instead of opening a new one. +{{< /details >}} + +## Go deeper + +- [Human-in-the-Loop Elicitation]({{< relref "/docs/apl/elicitation" >}}) for the suspend/resume model, CIBA, and genuineness. + +## Next + +The capstone reassembles all three backends and every control into the full scenario. diff --git a/docs/content/docs/tutorial/09-custom-plugin.md b/docs/content/docs/tutorial/09-custom-plugin.md new file mode 100644 index 00000000..64f60c4c --- /dev/null +++ b/docs/content/docs/tutorial/09-custom-plugin.md @@ -0,0 +1,96 @@ +--- +title: "Write your own plugin" +weight: 10 +--- + +# Module 9: Write your own plugin + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). Runs without the IdP. + +**Goal:** write a plugin for a check the builtins do not ship, register it, and reference it from policy by name, exactly like a builtin. + +## The problem + +The bundled plugins cover common needs, but your domain has its own rules. You need a way to drop custom logic into the pipeline without forking CPEX, and to wire it from policy the same way you wire a builtin. + +## Build it + +A plugin is a Rust type implementing a hook handler. This one, a business-hours guard, reads the request's `hour` argument and its own open/close config, and denies calls outside the window. From [`examples/m09_custom_plugin.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/examples/m09_custom_plugin.rs): + +```rust +impl HookHandler for BusinessHours { + async fn handle(&self, payload: &MessagePayload, _ext: &Extensions, _ctx: &mut PluginContext) + -> PluginResult + { + let hour = payload.message.get_tool_calls().into_iter().next() + .and_then(|tc| tc.arguments.get("hour")).and_then(|v| v.as_u64()); + match hour { + Some(h) if h >= self.open_hour && h < self.close_hour => PluginResult::allow(), + Some(h) => PluginResult::deny(PluginViolation::new("office.closed", + format!("requested at hour {h}, outside {}-{}", self.open_hour, self.close_hour))), + None => PluginResult::deny(PluginViolation::new("office.no_hour", "no `hour` argument")), + } + } +} +``` + +A factory builds it from config and wires the handler onto a hook. You register the factory before loading the policy, then reference the plugin by name. The traits come from `cpex-sdk`; the factory and handler-adapter plumbing come from `cpex-core`, the same pattern every builtin uses. + +```rust +mgr.register_factory("business-hours", Box::new(BusinessHoursFactory)); +cpex::install_builtins(&mgr); +mgr.load_config_yaml(POLICY).unwrap(); +``` + +The policy references it like any builtin ([`policies/m09.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m09.yaml)): + +```yaml +plugins: + - name: business-hours + kind: business-hours + hooks: [cmf.tool_pre_invoke] + config: { open_hour: 9, close_hour: 17 } +routes: + - tool: get_compensation + authorization: + pre_invocation: + - "run(business-hours)" +``` + +## Run it + +```bash +cargo run -p cpex-tutorial --example m09_custom_plugin +``` + +``` +▸ get_compensation at 10:00 (within 9-17 window) + ✓ ALLOWED { ... } + +▸ get_compensation at 22:00 (outside the window) + ✗ DENIED [office.closed] requested at hour 22, outside business hours 9-17 +``` + +## Try it + +1. Change the window. Set `close_hour: 23` in the policy and confirm the 22:00 call now allows. Config feeds the plugin. +2. Add a reason. Return a richer `PluginViolation` with extra detail and see it in the outcome. +3. Gate a different tool. Add `run(business-hours)` to another route and confirm the same plugin guards it. + +## Checkpoint + +{{< details "How does policy find your plugin?" >}} +You register a factory under a `kind`, and the plugin block in policy names that `kind`. The APL visitor resolves it at load time, so `run(business-hours)` dispatches to your handler. +{{< /details >}} + +{{< details "What decides allow vs. deny?" >}} +Your handler returns `PluginResult::allow()` or `PluginResult::deny(violation)`. The violation carries the code and reason the caller sees, just like a builtin. +{{< /details >}} + +## Go deeper + +- [Plugins & the Execution Pipeline]({{< relref "/docs/pipeline" >}}) and [Extensions & Capability-Gating]({{< relref "/docs/extensions" >}}). + +## Next + +The capstone reassembles the full three-backend scenario using the builtins and everything you have written. diff --git a/docs/content/docs/tutorial/10-testing.md b/docs/content/docs/tutorial/10-testing.md new file mode 100644 index 00000000..5e8c1df2 --- /dev/null +++ b/docs/content/docs/tutorial/10-testing.md @@ -0,0 +1,81 @@ +--- +title: "Testing your policy" +weight: 11 +--- + +# Module 10: Testing your policy + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). Runs without the IdP. + +**Goal:** test policy the way you test code. Load a policy, drive routes with a fake backend, and assert the outcome, so a policy change that breaks a rule fails CI. + +## The problem + +Policy decides who sees what. A careless edit can silently open a route or over-redact a field. You want the allow and deny matrix pinned by tests that run on every change, without standing up an IdP or a real backend for the cases that do not need one. + +## Build it + +A test loads a policy into a manager and calls routes through `mediate()` with a fake backend, then asserts. Table-driven cases keep the matrix readable. From [`tests/policy_tests.rs`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/tests/policy_tests.rs): + +```rust +async fn manager_with(policy: &str) -> Arc { + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(policy).expect("policy should load"); + mgr.initialize().await.expect("initialize"); + mgr +} + +#[tokio::test] +async fn module4_external_email_denied_with_custom_code() { + let mgr = manager_with(M04).await; + let outcome = mediate(&mgr, &Caller::anonymous(), "send_email", + json!({ "to": "x@evil.example", "external": true }), + |a| backends::dispatch("send_email", a)).await; + assert!(matches!(outcome, Outcome::Denied { code, .. } if code == "email.external_blocked")); +} +``` + +Anonymous callers exercise structural rules (authentication gates, argument guards, result pipelines) with no Keycloak. For identity-dependent rules, mint tokens the way the module binaries do. + +## Run it + +```bash +cargo test -p cpex-tutorial +``` + +``` +running 2 tests +test module1_gates_by_authentication ... ok +test module4_external_email_denied_with_custom_code ... ok +``` + +The example binary runs the same idea in the tutorial's output format: + +```bash +cargo run -p cpex-tutorial --example m10_testing +``` + +## Try it + +1. Break a policy. Edit `policies/m04.yaml` to drop the external-recipient guard, then run `cargo test -p cpex-tutorial`. Expect: the test fails, catching the regression. +2. Add a case. Add a row asserting an allowed call and confirm it passes. +3. Wire it into CI. `make test` runs the whole workspace test suite, including these. + +## Checkpoint + +{{< details "Do these tests need Keycloak?" >}} +No. They use anonymous callers to exercise structural rules, so they run in plain CI. Identity-dependent tests would mint tokens, which needs the IdP. +{{< /details >}} + +{{< details "What does a test actually assert on?" >}} +The `Outcome` from `mediate()`: allowed or denied, and for denials the reason code. That is the same value your host branches on in production. +{{< /details >}} + +## Go deeper + +- [Testing Policy]({{< relref "/docs/testing" >}}) and [Patterns: shadow rollout with audit mode]({{< relref "/docs/patterns" >}}). + +## Next + +The capstone reassembles the full three-backend scenario. Modules 6, 8, and 9 (delegation, elicitation, custom plugins) round out the set. diff --git a/docs/content/docs/tutorial/_index.md b/docs/content/docs/tutorial/_index.md new file mode 100644 index 00000000..71c57fc1 --- /dev/null +++ b/docs/content/docs/tutorial/_index.md @@ -0,0 +1,48 @@ +--- +title: "Tutorial" +weight: 25 +bookCollapseSection: true +--- + +# CPEX Tutorial + +A self-paced tutorial. You build a policy enforcement point in front of an agent's tools, one capability at a time, and watch policy decide every outcome. Every module is a runnable program you can edit, break, and re-run. + +By the end you can put CPEX in front of your own tools, write APL for them, extend it with a custom plugin, and test your policy. + +## What you'll build + +The running example is the scenario from the [Overview]({{< relref "/docs/overview" >}}): one agent, three backends (HR records, source repos, outbound email), and three callers whose requests get different treatment. The docs describe that scenario. Here you build it, finishing with a capstone that reconstructs it end to end. + +One idea repeats in every module: the application never changes. Only the policy changes. + +## Before you start + +- Rust 1.96 or newer, and Cargo. +- A container runtime with compose (Rancher Desktop, Podman, or Docker Desktop). You need it from module 2 on, where a real Keycloak resolves tokens. Modules 0 and 1 need only Rust. +- The code lives in [`examples/tutorial`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial). Run a module with `cargo run -p cpex-tutorial --example m01_hello`. + +Budget about 3 to 4 hours total, 15 to 25 minutes per module. + +## Modules + +| # | Module | You'll learn | IdP? | +|---|--------|--------------|------| +| 0 | [Setup & orientation]({{< relref "00-setup" >}}) | What CPEX is; verify your setup | no | +| 1 | [Hello, enforcement]({{< relref "01-hello" >}}) | Stand up an enforcement point; a route with `require()`; allow vs. deny | no | +| 2 | [Who's calling? (Identity)]({{< relref "02-identity" >}}) | Resolve real JWTs from Keycloak into roles and permissions | yes | +| 3 | [Shaping data]({{< relref "03-shaping" >}}) | `result:` field pipelines that redact and mask per permission | yes | +| 4 | [Effects & sequencing]({{< relref "04-effects" >}}) | Ordered effects, halt-on-deny, custom deny codes, auditing | yes | +| 5 | [Delegating decisions (PDP)]({{< relref "05-pdp" >}}) | Hand a decision to CEL or Cedar; CPEX enforces the verdict | yes | +| 6 | [Scoped credentials (Delegation)]({{< relref "06-delegation" >}}) | Mint a downstream-scoped token via RFC 8693 exchange | yes | +| 7 | [Information flow (Tainting)]({{< relref "07-tainting" >}}) | Carry session state across requests; block write-down | yes | +| 8 | [Human in the loop]({{< relref "08-elicitation" >}}) | Suspend an operation for human approval, then resume | yes | +| 9 | [Write your own plugin]({{< relref "09-custom-plugin" >}}) | Build a custom plugin with the SDK; reference it from policy | no | +| 10 | [Testing your policy]({{< relref "10-testing" >}}) | Table-driven allow/deny tests that run in CI | no | +| C | [Capstone: the three-backend agent]({{< relref "capstone" >}}) | Assemble every control into the full Overview scenario | yes | + +Start at [module 0]({{< relref "00-setup" >}}). + +## How each module is structured + +Every module page follows the same shape. **Goal** states what you can do after it in one sentence. **The problem** shows a concrete failure. **Build it** shows the policy change. **Run it** gives the command and expected output. **Try it** lists guided edits, each with the outcome to expect. **Checkpoint** is a short self-check. **Go deeper** links to the reference. diff --git a/docs/content/docs/tutorial/capstone.md b/docs/content/docs/tutorial/capstone.md new file mode 100644 index 00000000..19995723 --- /dev/null +++ b/docs/content/docs/tutorial/capstone.md @@ -0,0 +1,69 @@ +--- +title: "Capstone" +weight: 12 +--- + +# Capstone: the three-backend agent + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This needs the IdP. + +**Goal:** assemble everything into the full scenario from the [Overview]({{< relref "/docs/overview" >}}): one agent, three backends, three callers, one policy. See identity, permission, delegation, redaction, information flow, and audit compose. + +## The scenario + +One policy ([`policies/capstone.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/capstone.yaml)) defines three routes: + +- `get_compensation` (HR): authorize by role, mint a scoped downstream token, taint the session as having read secret data, audit, and redact fields by permission on the way out. +- `search_repos` (repos): hand the decision to CEL: engineers read internal only, security reads any. +- `send_email` (email): refuse if the session already touched secret data, and audit every attempt. + +## Run it + +```bash +cargo run -p cpex-tutorial --example capstone +``` + +Two behaviors carry the whole idea. + +**Same request, different result.** + +``` +▸ alice (hr, view_ssn) → get_compensation + ✓ ALLOWED { ... "ssn":"521-38-7710" ... } + +▸ dana (hr, no view_ssn) → get_compensation (SSN redacted) + ✓ ALLOWED { ... "ssn":"[REDACTED]" ... } + +▸ evan (engineer) → get_compensation (denied, not HR) + ✗ DENIED [...] access denied +``` + +**Information follows the session.** + +``` +▸ dana, fresh session → send_email (allowed: nothing read yet) + ✓ ALLOWED {"sent":true, ...} + +▸ dana, same session as her HR read → send_email (write-down blocked) + ✗ DENIED [session_tainted] write-down blocked: this session read secret data +``` + +The application code treated every call the same. Identity, permission, and session history produced every outcome: behavior lives in policy, not in the application. + +If you skipped module 6, run the variant that drops delegation: + +```bash +cargo run -p cpex-tutorial --example capstone -- --no-delegation +``` + +## Compare to the shipped policy + +This is the same policy the README and Overview describe. Read [`capstone.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/capstone.yaml) top to bottom: every step is one you built in an earlier module. Nothing new was introduced to assemble the whole. + +## Where to go next + +- [Patterns]({{< relref "/docs/patterns" >}}): layered enforcement, shadow rollout, defense in depth. +- [Deployment]({{< relref "/docs/deployment" >}}): where to run the enforcement point (gateway, sidecar, in-process). +- [Builtins]({{< relref "/docs/builtins" >}}): the full bundled plugin and PDP set. +- [Go bindings](https://github.com/contextforge-org/cpex/tree/main/go/cpex): drive CPEX from Go over the FFI. +- Write and contribute a plugin: you already did the hard part in [module 9]({{< relref "09-custom-plugin" >}}). diff --git a/docs/content/docs/use-cases.md b/docs/content/docs/use-cases.md new file mode 100644 index 00000000..75a30849 --- /dev/null +++ b/docs/content/docs/use-cases.md @@ -0,0 +1,189 @@ +--- +title: "Use Cases" +weight: 18 +--- + +# Use Cases + +> Each use case below runs end-to-end in the [Praxis demo](https://github.com/praxis-proxy/demos/tree/main/demos/cpex): CPEX as the policy engine inside a real AI gateway, backed by a real IdP and a mock MCP backend. Every snippet is quoted from the demo's live config, and every scenario is a script you can run. + +The demo realizes the [running scenario]({{< relref "/docs/overview" >}}): one agent, three callers, three kinds of backend reached over MCP. Identity decides the outcome. + +| Persona | Identity | Result | +|---|---|---| +| Bob | HR, `view_ssn` | Full compensation record, SSN included | +| Eve | HR, no `view_ssn` | Same record, SSN redacted on the wire | +| Alice | Engineering | Denied HR tools; allowed internal repos, denied external | + +[Praxis](https://github.com/praxis-proxy/praxis) is an AI-native proxy built around a filter chain. CPEX ships as its `policy` filter (the `cpex-policy-engine` feature), so the gateway parses MCP JSON-RPC, runs the full policy pass, and only then forwards a scoped request upstream: + +![The Praxis demo topology: a chat agent calls the Praxis gateway over MCP, where the mcp, policy (CPEX), and router filters run in sequence before forwarding to the hr-mcp server; the policy filter is configured by cpex.yaml and talks to Keycloak for identity, token exchange, and CIBA, and to Valkey for session taint, while Keycloak pushes CIBA approvals to the auth-channel UI](images/use_cases_topology.png) + +The wiring is two files: [`praxis.yaml`](https://github.com/praxis-proxy/demos/blob/main/demos/cpex/praxis.yaml) declares the listener and filter chain, and [`cpex.yaml`](https://github.com/praxis-proxy/demos/blob/main/demos/cpex/cpex.yaml) holds everything CPEX: identity plugins, delegators, validators, routes, and the PDP policy. The use cases below are that one config, taken apart. + +## Watch it run + +The recording below shows an interactive session against the gateway, driven by an LLM agent, covering an allow with token exchange, on-the-wire redaction, session taint, a CEL policy decision, and a human-in-the-loop manager approval, with the governing policy shown alongside each step. + +{{< asciinema cast="https://asciinema.org/a/NsnafpaR7xzyjm7a.cast" poster="npt:0:03" >}} + +## 1. Identity-aware tool access + +Who may call this tool at all? The `get_compensation` route opens with an attribute gate, and the attributes come from verified JWTs, not from anything the agent claims: + +```yaml +routes: + - tool: get_compensation + pre_invocation: + - "require(role.hr)" +``` + +Bob and Eve pass (HR role in their tokens). Alice is denied with a JSON-RPC error envelope before the request ever leaves the gateway; the backend never sees it. The demo resolves two identities per request, the human (`X-User-Token`) and the client (`Authorization`), each validated by its own `identity/jwt` plugin against Keycloak. + +Run it: [`scenarios/01-bob-allow.sh`](https://github.com/praxis-proxy/demos/blob/main/demos/cpex/scenarios/01-bob-allow.sh), [`scenarios/02-alice-deny.sh`](https://github.com/praxis-proxy/demos/tree/main/demos/cpex/scenarios). More: [Identity]({{< relref "/docs/apl/identity" >}}). + +## 2. On-the-wire redaction + +This is the [same-request-different-data]({{< relref "/docs/overview#same-request-different-data" >}}) outcome from the Overview, now running in a real gateway. Bob and Eve send the byte-for-byte same request; the field pipeline rewrites Eve's response body inside the proxy, after the tool returns and before the agent sees it: + +```yaml + result: + ssn: "str | redact(!perm.view_ssn)" +``` + +The tool does not implement this, cannot get it wrong, and cannot be talked out of it. The novelty here over the Overview is where it happens: in the proxy's response path, so an unmodified MCP backend gets per-caller redaction for free. + +Run it: [`scenarios/03-eve-redact.sh`](https://github.com/praxis-proxy/demos/tree/main/demos/cpex/scenarios). More: field pipelines in [Effects]({{< relref "/docs/apl/effects" >}}). + +## 3. Credential custody and token exchange + +The backend should never hold or even see the user's IdP credential. Before forwarding, the route exchanges Bob's token for a fresh one scoped to exactly this backend (RFC 8693, via Keycloak): + +```yaml + - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" +``` + +What reaches `workday-api` is a short-lived token with `audience: workday-api` and only `read_compensation`. A leak at the backend leaks that, not Bob's session. The `search_repos` route goes one step further and verifies the grant before trusting it: + +```yaml + - "!(delegation.granted.permissions contains 'repo:read:internal'): deny" +``` + +Run it: [`scenarios/01-bob-allow.sh`](https://github.com/praxis-proxy/demos/blob/main/demos/cpex/scenarios/01-bob-allow.sh) plus [`verify-token-exchange.sh`](https://github.com/praxis-proxy/demos/blob/main/demos/cpex/verify-token-exchange.sh). More: [Delegation]({{< relref "/docs/apl/delegation" >}}). + +## 4. Cross-tool data-flow control + +The classic exfiltration path: read something sensitive, then send it somewhere. Content filters miss it when the outbound message is clean. CPEX instead taints the session at the read and gates the send on the label: + +```yaml + # get_compensation + - "taint(secret, session)" + + # send_email + - "security.labels contains \"secret\": deny('external email blocked: this session accessed secret data', 'session_tainted_secret')" +``` + +Once a session touches compensation data, its emails are blocked, even with a spotless body. The label lives in CPEX's session store (Valkey in the demo), keyed by a hash of subject and session id, so it survives gateway restarts and cannot cross principals: Eve tainting a session id does not poison Bob's use of the same id. + +Run it: [`scenarios/08-bob-taint-deny.sh`](https://github.com/praxis-proxy/demos/tree/main/demos/cpex/scenarios), [`scenarios/09-cross-principal-taint-isolation.sh`](https://github.com/praxis-proxy/demos/tree/main/demos/cpex/scenarios). More: [Session Tainting]({{< relref "/docs/apl/tainting" >}}). + +## 5. PII guardrails on arguments + +Session taint is state-based; this control is content-based, and they complement each other. A validator plugin scans tool arguments for sensitive patterns and denies before dispatch: + +```yaml + - name: pii-scan + kind: validator/pii-scan + hooks: [cmf.tool_pre_invoke] + config: + detect: + - { kind: ssn } + - { kind: credit_card } + mode: deny +``` + +Bob pasting an SSN into an email body gets a deny, and the audit record of the attempt is still written: the route runs `run(audit-log)` before `run(pii-scan)`, so observation happens before the gate blocks. Flip `mode: deny` to `audit` to shadow-test the scanner against real traffic first (see [Patterns]({{< relref "/docs/patterns#shadow-rollout-with-audit-mode" >}})). + +Run it: [`scenarios/07-bob-pii-deny.sh`](https://github.com/praxis-proxy/demos/tree/main/demos/cpex/scenarios). More: [Builtins]({{< relref "/docs/builtins" >}}). + +## 6. Human-in-the-loop approval + +Some actions should not happen on the caller's authority alone. `adjust_compensation` changes a salary, so anything over $10,000 requires the requester's manager to sign off, out-of-band, before the tool runs: + +```yaml + - tool: adjust_compensation + pre_invocation: + - "require(role.hr)" + - when: "args.amount > 10000" + do: + - "require_approval(manager-approver, from: claim.manager, channel: \"ciba\", scope: \"args.amount <= 25000\", purpose: \"Approve a compensation adjustment\", timeout: 24h)" + - "run(audit-log)" +``` + +The gateway never blocks. It suspends the call, answers the agent with JSON-RPC `-32120` and an elicitation id, and drives an OIDC CIBA backchannel request to Keycloak, which pushes the prompt to the manager's device. The `scope` binds the approval to the live amount, so a sign-off cannot be replayed against a larger change. + +![The approval sequence: the agent sends adjust_compensation for $25k and the gateway answers -32120 pending with an elicitation id while firing a CIBA backchannel request to Keycloak, which pushes the prompt to the manager's device; after the manager approves, the agent's peek returns -32121 approved, and re-sending with X-Policy-Elicitation-Id applies the change with a 200](images/use_cases_hil_sequence.png) + +The agent needs no approval protocol; it sees "retry later" and, later, a result. In the demo's chat client the conversation simply continues until the approval lands and the result cuts back in. + +Run it: [`scenarios/10-bob-adjust-under-threshold.sh`](https://github.com/praxis-proxy/demos/tree/main/demos/cpex/scenarios), [`scenarios/11-bob-adjust-approval.sh`](https://github.com/praxis-proxy/demos/tree/main/demos/cpex/scenarios). More: [Elicitation]({{< relref "/docs/apl/elicitation" >}}). + +## 7. Pluggable policy decisions + +Attribute gates answer "does the caller have this role?". Relationship questions ("may this principal read this resource?") go to a PDP, and the demo ships the same decision in two dialects. Cedar, as a versioned policy set: + +```yaml + - cedar: + action: 'Action::"read"' + resource: + type: Repo + id: ${args.repo_name} + attributes: + visibility: ${args.visibility} +``` + +```cedar +@id("engineering-internal-repos") +permit( + principal, + action == Action::"read", + resource is Repo +) when { + principal.roles.contains("engineer") && + resource.visibility == "internal" +}; +``` + +CEL, as an inline predicate on the route: + +```yaml + - cel: + expr: | + (has(role.engineer) && role.engineer && args.visibility == "internal") + || (has(role.security) && role.security) + on_deny: + - "deny('engineering may read internal repos only; security may read any', 'cel.policy_denied')" +``` + +Same route, same outcome, different authoring model: Cedar suits versioned or signed policy sets with an entity model; CEL suits a self-contained predicate with no external policy store. Both backends compile into one binary; the config selects which runs. + +Run it: [`scenarios/04-alice-internal-allow.sh`](https://github.com/praxis-proxy/demos/tree/main/demos/cpex/scenarios), [`scenarios/05-alice-external-cedar-deny.sh`](https://github.com/praxis-proxy/demos/tree/main/demos/cpex/scenarios), then again with `GATEWAY_CONFIG=praxis-cel.yaml`. More: [PDP Integration]({{< relref "/docs/apl/pdp" >}}). + +## Run it yourself + +The whole demo is one command from [`demos/cpex`](https://github.com/praxis-proxy/demos/tree/main/demos/cpex) (Docker plus a Rust toolchain): + +```bash +./restart.sh # build the gateway, bring up Keycloak + backend + valkey +./walkthrough.sh # narrated tour of the core scenarios +``` + +Eleven scripted scenarios cover every control above, and `agent/chat.py --persona bob` drives the same gateway from an LLM chat agent, approvals included. + +Prefer the library-embedded flavor? The in-repo [Tutorial]({{< relref "/docs/tutorial" >}}) builds the same scenario with CPEX as a Rust crate inside the host process, one capability per module. + +## What to read next + +- [Threat Model]({{< relref "/docs/threat-model" >}}): what these controls defend against, and what each placement covers. +- [Quick Start]({{< relref "/docs/quickstart" >}}): stand up your own enforcement point in ten minutes. +- [Deployment]({{< relref "/docs/deployment" >}}): gateway, sidecar, and in-framework placements. diff --git a/docs/content/docs/vision.md b/docs/content/docs/vision.md index 0c4890b7..54b141eb 100644 --- a/docs/content/docs/vision.md +++ b/docs/content/docs/vision.md @@ -5,7 +5,7 @@ weight: 5 # A Reference Monitor for Agents -An agent backed by an LLM acts across trust domains. It calls tools, invokes other agents over A2A, runs inference, and fetches prompts and resources. The model deciding which operation to run is untrusted: it can be steered by injected content, confused by tool output, or simply wrong. Authorization, delegation, and information-flow control cannot live inside that model. +An agent backed by an LLM acts across trust domains. It calls tools, invokes other agents over A2A (agent-to-agent), runs inference, and fetches prompts and resources. The model deciding which operation to run is untrusted: it can be steered by injected content, confused by tool output, or simply wrong. Authorization, delegation, and information-flow control cannot live inside that model. CPEX puts them at the boundary. It is a deterministic reference monitor between the untrusted LLM and the capabilities it invokes. Every operation passes through CPEX, which decides what happens using state the model cannot see or forge. @@ -22,7 +22,7 @@ The LLM never sees these and cannot rewrite them. That is what makes CPEX a refe ## Policy is configuration -You do not write enforcement logic in application code. You write **APL**: the declarative configuration that defines each operation's enforcement pipeline, attaching its conditions and effects to the operation they govern. +You do not write enforcement logic in application code. You write **APL** (Authorization Policy Language): the declarative configuration that defines each operation's enforcement pipeline, attaching its conditions and effects to the operation they govern. ```yaml routes: @@ -54,13 +54,10 @@ APL leads. CMF gives policy a uniform thing to evaluate across tools, A2A, infer Different controls belong at different points. CPEX runs the same way at each of them, so you place a policy where its enforcement point is, not where the framework forces it. -```mermaid -flowchart LR - soft["soft
prompt-level
style · tone · refusals"] --> mid["enforcement
tool / A2A authorization
redaction · delegation"] --> hard["hard
infra boundary
identity · info-flow · audit"] -``` +![The policy spectrum: soft prompt-level controls (style, tone, refusals), enforcement-tier tool and A2A authorization (redaction, delegation), and hard infrastructure-boundary controls (identity, info-flow, audit), on an axis from advisory to enforced at the boundary](images/vision_policy_spectrum.png) A style guardrail at the prompt level and a hard information-flow control at an infrastructure boundary are the same kind of object: an APL policy evaluated by a CPEX reference monitor. Only the placement changes. ## Where CPEX runs -CPEX is direction-agnostic. It enforces the same policy whether it sits in front of a tool server as a gateway, beside an agent as an egress sidecar, or inside an agent framework. See [Deployment]({{< relref "/docs/deployment" >}}) for the placements and [Overview]({{< relref "/docs/overview" >}}) for the model in motion. +CPEX is direction-agnostic. It enforces the same policy whether it sits in front of a tool server as a gateway, beside an agent as an egress sidecar, or inside an agent framework. See [Deployment]({{< relref "/docs/deployment" >}}) for the placements, the [Threat Model]({{< relref "/docs/threat-model" >}}) for what each placement covers against an untrusted model, and [Overview]({{< relref "/docs/overview" >}}) for the model in motion. diff --git a/docs/layouts/_markup/render-image.html b/docs/layouts/_markup/render-image.html new file mode 100644 index 00000000..e4f18163 --- /dev/null +++ b/docs/layouts/_markup/render-image.html @@ -0,0 +1,14 @@ +{{- /* + Site-level image render hook. Resolves *relative* image destinations + (e.g. images/foo.png) through relURL so they respect the site baseURL and + do not hardcode a base path. Absolute-path destinations (/cpex/images/..., + used by the legacy 0.1.x pages) and remote/fragment URLs pass through + unchanged, so relURL is never applied twice. +*/ -}} +{{- $destination := .Destination -}} +{{- $u := urls.Parse .Destination -}} +{{- if and (not $u.IsAbs) (not (strings.HasPrefix .Destination "/")) (not (strings.HasPrefix .Destination "#")) -}} + {{- $destination = .Destination | relURL -}} +{{- end -}} +{{ .Text }} +{{- /**/ -}} diff --git a/docs/static/images/apl_delegation_flow.png b/docs/static/images/apl_delegation_flow.png new file mode 100644 index 00000000..c3acfc86 Binary files /dev/null and b/docs/static/images/apl_delegation_flow.png differ diff --git a/docs/static/images/apl_delegation_flow.svg b/docs/static/images/apl_delegation_flow.svg new file mode 100644 index 00000000..35edd7fc --- /dev/null +++ b/docs/static/images/apl_delegation_flow.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + caller's verified token + audience: agent + + + + + delegate(workday-oauth) + runs only after the gates pass + + + RFC 8693 + + + IdP + token endpoint + + + + + downstream token + audience: workday-api + scope: read_compensation + + + + + backend + + + + delegation.granted.permissions + verified before forward; less than requested denies + + + + the caller's raw IdP credential never leaves CPEX + diff --git a/docs/static/images/apl_elicitation_flow.png b/docs/static/images/apl_elicitation_flow.png new file mode 100644 index 00000000..9298e050 Binary files /dev/null and b/docs/static/images/apl_elicitation_flow.png differ diff --git a/docs/static/images/apl_elicitation_flow.svg b/docs/static/images/apl_elicitation_flow.svg new file mode 100644 index 00000000..530f65f6 --- /dev/null +++ b/docs/static/images/apl_elicitation_flow.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + channel: Keycloak CIBA + the approver acts here, out-of-band, hours later if need be + + + open backchannel + + + status + + + + agent + + + request + + + require_approval(…) + dispatch: register the intent + + + on retry + + + check status + non-blocking + + + resolved + + + validate + genuineness + scope over live args + + + approved & sufficient + + + forward to tool + + + + denied · expired · invalid + + + deny + fail closed + + + + -32120 + elicitation id + + + pending: agent retries, echoing the id + diff --git a/docs/static/images/apl_overview.png b/docs/static/images/apl_overview.png index 640ed30e..c8c1747d 100644 Binary files a/docs/static/images/apl_overview.png and b/docs/static/images/apl_overview.png differ diff --git a/docs/static/images/apl_overview.svg b/docs/static/images/apl_overview.svg new file mode 100644 index 00000000..b10a5946 --- /dev/null +++ b/docs/static/images/apl_overview.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + plugins: … + global: + authentication: [jwt-user] + routes: + - tool: get_compensation + authorization: + pre_invocation: + - require(role.hr) + - cedar: {…} + - delegate(workday-oauth, + permissions: + [read_compensation]) + - taint(secret, session) + result: + ssn: str | redact(!perm.view_ssn) + - tool: send_email + authorization: + pre_invocation: + - require(perm.email_send) + - security.labels contains + "secret": deny + + + + + + + + + + + + + + + + + + + + + Configure plugins + Global policy and settings + Per-route policy definition + pre-invocation flow + post-invocation (output shaping) + Session tainting + + + + + + require(pred) # Boolean predicates + pdp: {…} # PDP decision (Cedar, CEL, OPA) + delegate(…) # token exchange (IdP) + require_approval(…) # HIL approval (CIBA) + run(plugin) # block, transform, observe + taint(label, scope) # info-flow labels + + diff --git a/docs/static/images/apl_phases.png b/docs/static/images/apl_phases.png new file mode 100644 index 00000000..95cd0ce6 Binary files /dev/null and b/docs/static/images/apl_phases.png differ diff --git a/docs/static/images/apl_phases.svg b/docs/static/images/apl_phases.svg new file mode 100644 index 00000000..bd64eab6 --- /dev/null +++ b/docs/static/images/apl_phases.svg @@ -0,0 +1,32 @@ + + + + + + + + + + args + validate · transform input + + + + + authorization.pre_invocation + authorize · PDP · delegate · taint + + + + + result + redact · mask output + + + + + authorization.post_invocation + audit · final checks + + the first deny in any phase halts that phase and every later one + diff --git a/docs/static/images/apl_tainting_flow.png b/docs/static/images/apl_tainting_flow.png new file mode 100644 index 00000000..d5e24248 Binary files /dev/null and b/docs/static/images/apl_tainting_flow.png differ diff --git a/docs/static/images/apl_tainting_flow.svg b/docs/static/images/apl_tainting_flow.svg new file mode 100644 index 00000000..a4fbb7fa --- /dev/null +++ b/docs/static/images/apl_tainting_flow.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + get_compensation + + + taint(secret, session) + + + session labels + { secret } + + later, same session + + + + send_email + clean body + + + + + labels contains "secret"? + read from CPEX-owned state + + + + + + yes + + + no + + + deny + session_tainted + + + allow + diff --git a/docs/static/images/cpex_overview.png b/docs/static/images/cpex_overview.png index bb776ac0..31091293 100644 Binary files a/docs/static/images/cpex_overview.png and b/docs/static/images/cpex_overview.png differ diff --git a/docs/static/images/demo_scenario.png b/docs/static/images/demo_scenario.png index 0c49e150..ce3f6c03 100644 Binary files a/docs/static/images/demo_scenario.png and b/docs/static/images/demo_scenario.png differ diff --git a/docs/static/images/deployment.png b/docs/static/images/deployment.png index a33044a7..2b492ce5 100644 Binary files a/docs/static/images/deployment.png and b/docs/static/images/deployment.png differ diff --git a/docs/static/images/overview_outcomes.png b/docs/static/images/overview_outcomes.png new file mode 100644 index 00000000..80502519 Binary files /dev/null and b/docs/static/images/overview_outcomes.png differ diff --git a/docs/static/images/overview_outcomes.svg b/docs/static/images/overview_outcomes.svg new file mode 100644 index 00000000..3d23e403 --- /dev/null +++ b/docs/static/images/overview_outcomes.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + get_compensation + identical request + + + + + + CPEX + APL policy + + + + HR + view_ssn + + + HR, no view_ssn + + + not HR + + + full record + + + record, SSN redacted + + + denied + diff --git a/docs/static/images/threat_model.png b/docs/static/images/threat_model.png new file mode 100644 index 00000000..5a588b5e Binary files /dev/null and b/docs/static/images/threat_model.png differ diff --git a/docs/static/images/threat_model.svg b/docs/static/images/threat_model.svg new file mode 100644 index 00000000..03767bf9 --- /dev/null +++ b/docs/static/images/threat_model.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + untrusted + + + caller + + + + + agent + LLM decides + + + + every operation + + + + CPEX + reference monitor + + + APL policy + + + state the model cannot forge + identity · delegation · + taint · audit + + + + scoped · transformed + or denied + + + + mediated capabilities + + + tools · backends + + + other agents (A2A) + + + prompts · resources + · inference + + + + + + + IdP + verified tokens + + + PDP + decisions + diff --git a/docs/static/images/threat_model_gateway.png b/docs/static/images/threat_model_gateway.png new file mode 100644 index 00000000..8c438eb9 Binary files /dev/null and b/docs/static/images/threat_model_gateway.png differ diff --git a/docs/static/images/threat_model_gateway.svg b/docs/static/images/threat_model_gateway.svg new file mode 100644 index 00000000..67d1ac22 --- /dev/null +++ b/docs/static/images/threat_model_gateway.svg @@ -0,0 +1,30 @@ + + + + + + + + + + agent A + + + agent B + + + direct client + + + + + + + CPEX + gateway + + + + + tool server + diff --git a/docs/static/images/threat_model_inframework.png b/docs/static/images/threat_model_inframework.png new file mode 100644 index 00000000..56f2a250 Binary files /dev/null and b/docs/static/images/threat_model_inframework.png differ diff --git a/docs/static/images/threat_model_inframework.svg b/docs/static/images/threat_model_inframework.svg new file mode 100644 index 00000000..382609b6 --- /dev/null +++ b/docs/static/images/threat_model_inframework.svg @@ -0,0 +1,30 @@ + + + + + + + + + + agent runtime + + + LLM + + + + + framework + + + + + CPEX + in-process + + + + + tools / backends + diff --git a/docs/static/images/threat_model_sidecar.png b/docs/static/images/threat_model_sidecar.png new file mode 100644 index 00000000..36ebf665 Binary files /dev/null and b/docs/static/images/threat_model_sidecar.png differ diff --git a/docs/static/images/threat_model_sidecar.svg b/docs/static/images/threat_model_sidecar.svg new file mode 100644 index 00000000..3e0895e0 --- /dev/null +++ b/docs/static/images/threat_model_sidecar.svg @@ -0,0 +1,34 @@ + + + + + + + + + + workload + + + agent + + + all egress + + + CPEX + egress sidecar + + + + + + + internal tool + + + third-party API + + + other agents + diff --git a/docs/static/images/use_cases_hil_sequence.png b/docs/static/images/use_cases_hil_sequence.png new file mode 100644 index 00000000..8a780c33 Binary files /dev/null and b/docs/static/images/use_cases_hil_sequence.png differ diff --git a/docs/static/images/use_cases_hil_sequence.svg b/docs/static/images/use_cases_hil_sequence.svg new file mode 100644 index 00000000..cdf05cdf --- /dev/null +++ b/docs/static/images/use_cases_hil_sequence.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + agent + + + gateway (CPEX) + + + Keycloak + + + manager's device + + + + adjust_compensation (+$25k) + + + -32120 pending + elicitation_id + + + CIBA backchannel (login_hint = manager) + + + push approval prompt + + + approve + + + peek: X-Policy-Elicitation-Id + Peek: true + + + -32121 approved + + + re-send with X-Policy-Elicitation-Id + + + 200 · change applied + diff --git a/docs/static/images/use_cases_topology.png b/docs/static/images/use_cases_topology.png new file mode 100644 index 00000000..dde1a2cc Binary files /dev/null and b/docs/static/images/use_cases_topology.png differ diff --git a/docs/static/images/use_cases_topology.svg b/docs/static/images/use_cases_topology.svg new file mode 100644 index 00000000..5a6e22b0 --- /dev/null +++ b/docs/static/images/use_cases_topology.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + chat agent + / curl + + + MCP + + + + Praxis gateway + :8090 + + + cpex.yaml + + + + + mcp + parse JSON-RPC + + + + + policy (CPEX) + identity · APL · PDP · delegation + PII · taint · elicitation · audit + + + + + router + + + + forward + + + hr-mcp server + :9100 + + + + + + + + Keycloak :8081 + IdP · token exchange (RFC 8693) · CIBA + + + Valkey :6379 + session taint store + + + auth-channel :5001 + approval UI, “the manager’s phone” + + + + CIBA push to the approver + diff --git a/docs/static/images/vision_policy_spectrum.png b/docs/static/images/vision_policy_spectrum.png new file mode 100644 index 00000000..39b763ec Binary files /dev/null and b/docs/static/images/vision_policy_spectrum.png differ diff --git a/docs/static/images/vision_policy_spectrum.svg b/docs/static/images/vision_policy_spectrum.svg new file mode 100644 index 00000000..ab213831 --- /dev/null +++ b/docs/static/images/vision_policy_spectrum.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + soft + prompt-level + style · tone · refusals + + + + + + enforcement + tool / A2A authorization + redaction · delegation + + + + + + hard + infra boundary + identity · info-flow · audit + + + + advisory + enforced at the boundary + diff --git a/examples/tutorial/Cargo.toml b/examples/tutorial/Cargo.toml new file mode 100644 index 00000000..5fb0f157 --- /dev/null +++ b/examples/tutorial/Cargo.toml @@ -0,0 +1,43 @@ +# Location: ./examples/tutorial/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Fred Araujo +# +# cpex-tutorial, the runnable companion to the CPEX tutorial docs +# (docs/content/docs/tutorial). One crate, one shared harness, and one +# `examples/` binary per tutorial module. Not published. +# +# cargo run -p cpex-tutorial --example m01_hello +# cargo run -p cpex-tutorial --example m02_identity -- --check +# +# The harness (`src/`) provides fake tool backends, a `mediate()` wrapper +# over the CPEX dispatch loop, a Keycloak helper, and a tiny approval +# channel. Modules 2+ expect the tutorial IdP to be running +# (`docker compose -f examples/tutorial/idp/docker-compose.yml up -d`). + +[package] +name = "cpex-tutorial" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish = false + +[dependencies] +# The host facade with the bundled builtins (jwt, oauth, pii, audit, +# cedar, cel, elicitation-ciba). Path dependency, the tutorial is not +# published, so it does not use the workspace version table. +cpex = { path = "../../crates/cpex", features = ["builtins"] } +cpex-core = { workspace = true } +cpex-sdk = { workspace = true } + +tokio = { workspace = true, features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } +async-trait = { workspace = true } +serde_json = { workspace = true } + +# Keycloak token minting (module 2+) and the approval channel (module 8). +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +tiny_http = "0.12" + +[lints] +workspace = true diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md new file mode 100644 index 00000000..95a33ed0 --- /dev/null +++ b/examples/tutorial/README.md @@ -0,0 +1,43 @@ +# CPEX Tutorial: runnable code + +This crate is the runnable companion to the [CPEX tutorial docs](../../docs/content/docs/tutorial). Each tutorial module is one binary under `examples/`; they all share the harness in `src/`. + +```bash +# From the workspace root: +cargo run -p cpex-tutorial --example m01_hello # run a module +cargo run -p cpex-tutorial --example m01_hello -- --check # scripted assertions (used by CI) +``` + +## Layout + +| Path | What it is | +|------|-----------| +| `src/mediate.rs` | `mediate()`, the enforcement loop a host owns, wrapped in one call. **Harness code, not a CPEX API** (see the note at the top of the file). | +| `src/backends.rs` | Fake HR / repo / email tools. They hold data and do no enforcement; policy in front of them decides who sees what. | +| `src/idp.rs` | Mint a token from the tutorial Keycloak realm; wait for it to be ready. | +| `src/approvals.rs` | A tiny `curl`-driven approval channel for the elicitation module. | +| `src/ui.rs` | Shared console formatting and the `--check` assertion helpers. | +| `examples/mNN_*.rs` | One binary per tutorial module. | +| `policies/mNN.yaml` | The APL each module loads. **These are yours to edit**; that is the point. Reset with `git checkout -- examples/tutorial/policies`. | +| `idp/` | `docker-compose.yml` + `realm-export.json` for the tutorial Keycloak. | + +## The IdP + +Modules 0–1 need no IdP. Module 2 onward resolve real tokens, so start Keycloak first: + +```bash +docker compose -f examples/tutorial/idp/docker-compose.yml up -d # ~30s first boot +docker compose -f examples/tutorial/idp/docker-compose.yml down # resets everything +``` + +The realm takes a few seconds to import. Wait for it before running an IdP-backed module (a call returning `auth.jwks_unavailable` means it was still booting): + +```bash +cargo run -p cpex-tutorial --example wait_for_idp # blocks until the realm is serving +``` + +`make tutorial-check` runs this probe automatically before the IdP modules. See [`idp/README.md`](idp/README.md) for the personas and how to mint a token by hand. + +## Editing policy + +For modules 2–8 you change `policies/*.yaml`, not Rust. Each policy file has a header explaining what to try. The harness reloads the file on each run (`cargo run ...`), so edit, re-run, observe. diff --git a/examples/tutorial/examples/capstone.rs b/examples/tutorial/examples/capstone.rs new file mode 100644 index 00000000..09da63f4 --- /dev/null +++ b/examples/tutorial/examples/capstone.rs @@ -0,0 +1,171 @@ +// Location: ./examples/tutorial/examples/capstone.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Tutorial capstone, the three-backend agent. +// +// Prerequisite: the tutorial IdP must be running. +// docker compose -f examples/tutorial/idp/docker-compose.yml up -d +// +// cargo run -p cpex-tutorial --example capstone +// cargo run -p cpex-tutorial --example capstone -- --check +// +// The full scenario from the Overview, assembled from every module: one +// agent, three backends (HR, repos, email), three callers, one policy. +// The two headline behaviors: +// * Same request, different result: alice (view_ssn) sees the full HR +// record; dana (no view_ssn) sees it with the SSN redacted; evan is +// denied outright. +// * Information follows the session: reading compensation taints the +// session, so a later email in that session is refused. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::idp; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller, Outcome}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/capstone.yaml"); +const POLICY_NODELEG: &str = include_str!("../policies/capstone-nodeleg.yaml"); + +async fn token(user: &str) -> String { + match idp::mint_token(user, user).await { + Ok(t) => t, + Err(e) => { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }, + } +} + +/// Does the returned record still contain the real SSN? +fn ssn_visible(outcome: &Outcome) -> bool { + matches!(outcome, Outcome::Allowed { result } + if result.get("ssn").and_then(|v| v.as_str()) == Some("521-38-7710")) +} + +#[tokio::main] +async fn main() { + ui::module_banner("Capstone: the three-backend agent"); + + // Readers who skipped module 6 (delegation) can run the variant that + // drops the delegate step: `-- --no-delegation`. + let no_deleg = std::env::args().any(|a| a == "--no-delegation"); + let policy = if no_deleg { POLICY_NODELEG } else { POLICY }; + if no_deleg { + println!("(running the no-delegation variant)\n"); + } + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(policy) + .expect("capstone policy should load"); + mgr.initialize().await.expect("initialize"); + + let alice = token("alice").await; + let dana = token("dana").await; + let evan = token("evan").await; + let sam = token("sam").await; + let mut all_passed = true; + + // --- Same request, different result --- + println!("\x1b[1mSame request, different result\x1b[0m\n"); + + ui::scenario("alice (hr, view_ssn) → get_compensation"); + let o = mediate( + &mgr, + &Caller::with_token(&alice).in_session("s-alice"), + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true) && ssn_visible(&o); + + ui::scenario("dana (hr, no view_ssn) → get_compensation (SSN redacted)"); + let o = mediate( + &mgr, + &Caller::with_token(&dana).in_session("s-dana"), + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true) && !ssn_visible(&o); + + ui::scenario("evan (engineer) → get_compensation (denied, not HR)"); + let o = mediate( + &mgr, + &Caller::with_token(&evan).in_session("s-evan"), + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + + // --- Repo search: CEL decision --- + println!("\x1b[1mRepo search decided by CEL\x1b[0m\n"); + + ui::scenario("evan (engineer) → search_repos internal (allowed)"); + let o = mediate( + &mgr, + &Caller::with_token(&evan).in_session("s-evan"), + "search_repos", + json!({ "visibility": "internal" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + ui::scenario("sam (security) → search_repos public (allowed)"); + let o = mediate( + &mgr, + &Caller::with_token(&sam).in_session("s-sam"), + "search_repos", + json!({ "visibility": "public" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + // --- Information follows the session --- + println!("\x1b[1mInformation follows the session\x1b[0m\n"); + + ui::scenario("dana, fresh session → send_email (allowed: nothing read yet)"); + let o = mediate( + &mgr, + &Caller::with_token(&dana).in_session("s-clean"), + "send_email", + json!({ "to": "team@corp.example", "subject": "sync" }), + backends::send_email, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + ui::scenario("dana, same session as her HR read → send_email (write-down blocked)"); + let o = mediate( + &mgr, + &Caller::with_token(&dana).in_session("s-dana"), + "send_email", + json!({ "to": "team@corp.example", "subject": "sync" }), + backends::send_email, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + + println!("One policy, one unchanged application. Identity, permission, and session history produced every outcome."); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m00_setup.rs b/examples/tutorial/examples/m00_setup.rs new file mode 100644 index 00000000..64bb4d75 --- /dev/null +++ b/examples/tutorial/examples/m00_setup.rs @@ -0,0 +1,47 @@ +// Location: ./examples/tutorial/examples/m00_setup.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Tutorial module 0, Setup & orientation. +// +// cargo run -p cpex-tutorial --example m00_setup +// +// A do-nothing-to-your-system check: it confirms the crate builds and +// tells you whether the tutorial IdP is reachable, so you know what you +// can run. Modules 0 and 1 need no IdP; module 2 onward do. + +use std::time::Duration; + +use cpex_tutorial::idp; +use cpex_tutorial::ui; + +#[tokio::main] +async fn main() { + ui::module_banner("Module 0: Setup & orientation"); + + println!("CPEX is a policy enforcement runtime for AI agents: a deterministic"); + println!("reference monitor that sits between an agent and every tool it calls."); + println!("In this tutorial you build that enforcement point up one capability at"); + println!("a time, changing only POLICY, never the host code.\n"); + + println!("Each module is a runnable binary:"); + println!(" cargo run -p cpex-tutorial --example m01_hello"); + println!( + " cargo run -p cpex-tutorial --example m01_hello -- --check # scripted assertions\n" + ); + + print!("Checking the tutorial IdP (Keycloak) ... "); + match idp::wait_until_ready(Duration::from_secs(2)).await { + Ok(()) => { + println!("\x1b[32mup\x1b[0m at {}", idp::issuer()); + println!("You're ready for every module."); + }, + Err(_) => { + println!("\x1b[33mnot running\x1b[0m"); + println!("Modules 0–1 work without it. For module 2 onward, start it:"); + println!(" docker compose -f examples/tutorial/idp/docker-compose.yml up -d"); + }, + } + println!("\nNext: module 1, the smallest possible enforcement point."); +} diff --git a/examples/tutorial/examples/m01_hello.rs b/examples/tutorial/examples/m01_hello.rs new file mode 100644 index 00000000..ab18456a --- /dev/null +++ b/examples/tutorial/examples/m01_hello.rs @@ -0,0 +1,76 @@ +// Location: ./examples/tutorial/examples/m01_hello.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Tutorial module 1, Hello, enforcement. +// +// cargo run -p cpex-tutorial --example m01_hello +// cargo run -p cpex-tutorial --example m01_hello -- --check +// +// The smallest possible CPEX host: build a PluginManager, install the +// builtins, load a policy, and dispatch two operations through it. No IdP +// yet, every caller is anonymous. You will see one route deny (it +// requires an authenticated caller) and one route allow (it has no rule). +// The Rust below never decides anything; the policy in policies/m01.yaml +// does. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller}; + +use serde_json::json; + +/// The policy for this module. Resolved at compile time relative to the +/// crate so the example runs from any working directory. +const POLICY: &str = include_str!("../policies/m01.yaml"); + +#[tokio::main] +async fn main() { + ui::module_banner("Module 1: Hello, enforcement"); + + // --- Set up the enforcement point (3 lines a real host also writes) --- + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m01.yaml should load"); + mgr.initialize().await.expect("initialize"); + + // Everyone is anonymous in module 1, no token. + let caller = Caller::anonymous(); + let mut all_passed = true; + + // --- Scenario 1: a gated route. require(authenticated) fails for an + // anonymous caller, so the backend never runs. --- + ui::scenario("anonymous → get_compensation (route requires authentication)"); + let outcome = mediate( + &mgr, + &caller, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&outcome); + all_passed &= ui::expect(&outcome, false); + + // --- Scenario 2: an open route. No rule blocks it, so it allows and + // returns the backend's result. --- + ui::scenario("anonymous → search_repos (route has no rule)"); + let outcome = mediate( + &mgr, + &caller, + "search_repos", + json!({ "visibility": "public" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&outcome); + all_passed &= ui::expect(&outcome, true); + + println!("The route decided the outcome, the code above treated both calls identically."); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m02_identity.rs b/examples/tutorial/examples/m02_identity.rs new file mode 100644 index 00000000..abf5d584 --- /dev/null +++ b/examples/tutorial/examples/m02_identity.rs @@ -0,0 +1,99 @@ +// Location: ./examples/tutorial/examples/m02_identity.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Tutorial module 2, Who's calling? (Identity) +// +// Prerequisite: the tutorial IdP must be running. +// docker compose -f examples/tutorial/idp/docker-compose.yml up -d +// +// cargo run -p cpex-tutorial --example m02_identity +// cargo run -p cpex-tutorial --example m02_identity -- --check +// +// We mint real JWTs from Keycloak for two personas and call the same HR +// route with each: +// * alice (hr role, view_ssn permission), ALLOWED. +// * evan (engineer role) , DENIED by require(role.hr). +// * a garbage token , DENIED at token validation, +// before any authorization rule. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::idp; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m02.yaml"); + +#[tokio::main] +async fn main() { + ui::module_banner("Module 2: Who's calling? (Identity)"); + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m02.yaml should load"); + mgr.initialize().await.expect("initialize"); + + let alice = match idp::mint_token("alice", "alice").await { + Ok(t) => Caller::with_token(t), + Err(e) => { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }, + }; + let evan = match idp::mint_token("evan", "evan").await { + Ok(t) => Caller::with_token(t), + Err(e) => { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }, + }; + + let mut all_passed = true; + + ui::scenario("alice (hr) → get_compensation"); + let outcome = mediate( + &mgr, + &alice, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&outcome); + all_passed &= ui::expect(&outcome, true); + + ui::scenario("evan (engineer) → get_compensation (fails require(role.hr))"); + let outcome = mediate( + &mgr, + &evan, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&outcome); + all_passed &= ui::expect(&outcome, false); + + ui::scenario("garbage token → get_compensation (rejected at validation)"); + let bogus = Caller::with_token("not.a.jwt"); + let outcome = mediate( + &mgr, + &bogus, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&outcome); + all_passed &= ui::expect(&outcome, false); + + println!("Same route, same code, the token the caller presented decided the outcome."); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m03_shaping.rs b/examples/tutorial/examples/m03_shaping.rs new file mode 100644 index 00000000..861d550e --- /dev/null +++ b/examples/tutorial/examples/m03_shaping.rs @@ -0,0 +1,84 @@ +// Location: ./examples/tutorial/examples/m03_shaping.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Tutorial module 3, Shaping data. +// +// cargo run -p cpex-tutorial --example m03_shaping +// cargo run -p cpex-tutorial --example m03_shaping -- --check +// +// The route allows the call, then transforms the *result* with a field +// pipeline: redact ssn without view_ssn, redact salary without the hr +// role, always mask employee_id. This module runs anonymously, so both +// redactions fire, the SSN and salary come back redacted while the rest +// of the record passes through. In module 2+ an HR caller with the right +// permission sees the full record from this same policy. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller, Outcome}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m03.yaml"); + +#[tokio::main] +async fn main() { + ui::module_banner("Module 3: Shaping data"); + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m03.yaml should load"); + mgr.initialize().await.expect("initialize"); + + let caller = Caller::anonymous(); + let mut all_passed = true; + + ui::scenario("anonymous → get_compensation (result pipeline redacts ssn + salary, masks id)"); + let outcome = mediate( + &mgr, + &caller, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&outcome); + + // The call is allowed, but the sensitive fields must be transformed. + all_passed &= ui::expect(&outcome, true); + if let Outcome::Allowed { result } = &outcome { + let ssn = result.get("ssn").and_then(|v| v.as_str()).unwrap_or(""); + let salary_present = result.get("salary").and_then(|v| v.as_i64()).is_some(); + let id = result + .get("employee_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let ssn_redacted = ssn != "521-38-7710"; + let salary_redacted = !salary_present || result.get("salary") == Some(&json!(null)); + let id_masked = id != "e-1001"; + if ssn_redacted { + println!(" ssn was transformed (no view_ssn permission) ✓"); + } else { + println!(" \x1b[33m! ssn was NOT redacted\x1b[0m"); + } + if id_masked { + println!(" employee_id was masked ✓"); + } else { + println!(" \x1b[33m! employee_id was NOT masked\x1b[0m"); + } + all_passed &= ssn_redacted && id_masked; + let _ = salary_redacted; + } + println!(); + + println!( + "Redaction happens on the way OUT, the backend returned the full record; policy shaped it." + ); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m04_effects.rs b/examples/tutorial/examples/m04_effects.rs new file mode 100644 index 00000000..e40b62f2 --- /dev/null +++ b/examples/tutorial/examples/m04_effects.rs @@ -0,0 +1,84 @@ +// Location: ./examples/tutorial/examples/m04_effects.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Tutorial module 4, Effects and sequencing. +// +// cargo run -p cpex-tutorial --example m04_effects +// cargo run -p cpex-tutorial --example m04_effects -- --check +// +// A route's pre_invocation is an ordered list of effects that halts on the +// first denial. This module sends two emails through the same route: +// * one to an external recipient, the hand-written deny() guard blocks +// it with a custom reason code, AFTER the audit plugin already ran; +// * one to an internal recipient, the guard passes, but the anonymous +// caller then fails require(authenticated). +// Watch stderr for the audit-log line: it fires on both calls because it +// sits before the denials. Effects before a deny still happen. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller, Outcome}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m04.yaml"); + +#[tokio::main] +async fn main() { + ui::module_banner("Module 4: Effects and sequencing"); + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m04.yaml should load"); + mgr.initialize().await.expect("initialize"); + + let caller = Caller::anonymous(); + let mut all_passed = true; + + // --- Scenario 1: external recipient. audit runs, then the custom + // deny() guard halts with code `email.external_blocked`. --- + ui::scenario("send_email to an external recipient (custom deny guard halts)"); + let outcome = mediate( + &mgr, + &caller, + "send_email", + json!({ "to": "attacker@evil.example", "subject": "hi", "external": true }), + backends::send_email, + ) + .await; + ui::print_outcome(&outcome); + all_passed &= ui::expect(&outcome, false); + if let Outcome::Denied { code, .. } = &outcome { + let matched = code == "email.external_blocked"; + println!( + " denial carried the custom reason code we wrote in policy: {code} {}", + if matched { "✓" } else { "(unexpected)" } + ); + all_passed &= matched; + } + println!(); + + // --- Scenario 2: internal recipient. The external guard passes, so + // the pipeline reaches require(authenticated), which denies our + // anonymous caller with a different code. Proof of ordering. --- + ui::scenario("send_email to an internal recipient (guard passes, auth check halts)"); + let outcome = mediate( + &mgr, + &caller, + "send_email", + json!({ "to": "coworker@corp.example", "subject": "hi", "external": false }), + backends::send_email, + ) + .await; + ui::print_outcome(&outcome); + all_passed &= ui::expect(&outcome, false); + + println!("The audit line above (stderr) fired on BOTH calls, effects before a deny still run."); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m05_pdp.rs b/examples/tutorial/examples/m05_pdp.rs new file mode 100644 index 00000000..e27cfff2 --- /dev/null +++ b/examples/tutorial/examples/m05_pdp.rs @@ -0,0 +1,92 @@ +// Location: ./examples/tutorial/examples/m05_pdp.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Tutorial module 5, Delegating decisions (PDP). +// +// Prerequisite: the tutorial IdP must be running. +// docker compose -f examples/tutorial/idp/docker-compose.yml up -d +// +// cargo run -p cpex-tutorial --example m05_pdp +// cargo run -p cpex-tutorial --example m05_pdp -- --check +// +// The route hands its decision to the CEL PDP. One expression captures a +// rule that would be awkward as a list of require()s: engineers may search +// internal repos only, security may search anything. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::idp; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m05.yaml"); + +async fn token(user: &str) -> Caller { + match idp::mint_token(user, user).await { + Ok(t) => Caller::with_token(t), + Err(e) => { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }, + } +} + +#[tokio::main] +async fn main() { + ui::module_banner("Module 5: Delegating decisions (PDP)"); + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m05.yaml should load"); + mgr.initialize().await.expect("initialize"); + + let evan = token("evan").await; + let sam = token("sam").await; + let mut all_passed = true; + + ui::scenario("evan (engineer) → search_repos internal (CEL allows)"); + let o = mediate( + &mgr, + &evan, + "search_repos", + json!({ "visibility": "internal" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + ui::scenario("evan (engineer) → search_repos public (CEL denies: engineers internal-only)"); + let o = mediate( + &mgr, + &evan, + "search_repos", + json!({ "visibility": "public" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + + ui::scenario("sam (security) → search_repos public (CEL allows: security reads any)"); + let o = mediate( + &mgr, + &sam, + "search_repos", + json!({ "visibility": "public" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + println!("One CEL expression captured a rule that mixes role and argument. The PDP decided; CPEX enforced."); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m06_delegation.rs b/examples/tutorial/examples/m06_delegation.rs new file mode 100644 index 00000000..b50f13d1 --- /dev/null +++ b/examples/tutorial/examples/m06_delegation.rs @@ -0,0 +1,82 @@ +// Location: ./examples/tutorial/examples/m06_delegation.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Tutorial module 6, Scoped credentials (Delegation). +// +// Prerequisite: the tutorial IdP must be running. +// docker compose -f examples/tutorial/idp/docker-compose.yml up -d +// +// cargo run -p cpex-tutorial --example m06_delegation +// cargo run -p cpex-tutorial --example m06_delegation -- --check +// +// The route mints a downstream, audience-scoped token for the caller with +// a real OAuth 2.0 token exchange (RFC 8693) against Keycloak, then gates +// on whether the exchange succeeded. alice (hr) gets a workday-api token +// and proceeds; evan (engineer) is stopped at require(role.hr) before any +// delegation happens. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::idp; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m06.yaml"); + +async fn token(user: &str) -> Caller { + match idp::mint_token(user, user).await { + Ok(t) => Caller::with_token(t), + Err(e) => { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }, + } +} + +#[tokio::main] +async fn main() { + ui::module_banner("Module 6: Scoped credentials (Delegation)"); + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m06.yaml should load"); + mgr.initialize().await.expect("initialize"); + + let alice = token("alice").await; + let evan = token("evan").await; + let mut all_passed = true; + + ui::scenario("alice (hr) → get_compensation (delegate mints a workday-api token, then allow)"); + let o = mediate( + &mgr, + &alice, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + ui::scenario("evan (engineer) → get_compensation (denied at require(role.hr), no delegation)"); + let o = mediate( + &mgr, + &evan, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + + println!("Delegation is a policy step: the caller's token was exchanged for a downstream-scoped one before the backend call."); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m07_tainting.rs b/examples/tutorial/examples/m07_tainting.rs new file mode 100644 index 00000000..db1b05b7 --- /dev/null +++ b/examples/tutorial/examples/m07_tainting.rs @@ -0,0 +1,92 @@ +// Location: ./examples/tutorial/examples/m07_tainting.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Tutorial module 7, Information flow (Tainting). +// +// Prerequisite: the tutorial IdP must be running. +// docker compose -f examples/tutorial/idp/docker-compose.yml up -d +// +// cargo run -p cpex-tutorial --example m07_tainting +// cargo run -p cpex-tutorial --example m07_tainting -- --check +// +// The same send_email call is allowed in a fresh session and denied in a +// session that previously read compensation. Reading tainted the session; +// the later send is blocked by the session's history, not its content. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::idp; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m07.yaml"); + +#[tokio::main] +async fn main() { + ui::module_banner("Module 7: Information flow (Tainting)"); + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m07.yaml should load"); + mgr.initialize().await.expect("initialize"); + + let token = match idp::mint_token("alice", "alice").await { + Ok(t) => t, + Err(e) => { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }, + }; + let mut all_passed = true; + + // Fresh session: nothing read yet, so the email is allowed. + let clean = Caller::with_token(token.clone()).in_session("session-clean"); + ui::scenario("alice, fresh session → send_email (nothing tainted yet)"); + let o = mediate( + &mgr, + &clean, + "send_email", + json!({ "to": "coworker@corp.example", "subject": "lunch" }), + backends::send_email, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + // A second session: read compensation first (taints the session)... + let working = Caller::with_token(token.clone()).in_session("session-hr-work"); + ui::scenario("alice, session-hr-work → get_compensation (taints session 'secret')"); + let o = mediate( + &mgr, + &working, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + // ...then the SAME send_email is denied, because the session is tainted. + ui::scenario("alice, session-hr-work → send_email (write-down blocked)"); + let o = mediate( + &mgr, + &working, + "send_email", + json!({ "to": "coworker@corp.example", "subject": "lunch" }), + backends::send_email, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + + println!("Identical send_email call: allowed in the clean session, denied after the same session read secret data."); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m08_elicitation.rs b/examples/tutorial/examples/m08_elicitation.rs new file mode 100644 index 00000000..ffa2f238 --- /dev/null +++ b/examples/tutorial/examples/m08_elicitation.rs @@ -0,0 +1,235 @@ +// Location: ./examples/tutorial/examples/m08_elicitation.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Tutorial module 8, Human in the loop (Elicitation). +// +// Prerequisite: the tutorial IdP must be running. +// docker compose -f examples/tutorial/idp/docker-compose.yml up -d +// +// cargo run -p cpex-tutorial --example m08_elicitation +// cargo run -p cpex-tutorial --example m08_elicitation -- --check +// +// The route suspends an outbound email until the caller's manager +// approves. The first call comes back PENDING with an elicitation id. A +// human approves out of band with a curl to the approval channel this +// binary serves on :8090. The caller then retries with the id and the +// call proceeds. In --check mode the module approves itself so CI can +// exercise the full suspend -> approve -> resume path unattended. +// +// The approval plugin below implements the `elicit` hook. It is backed by +// the harness ApprovalChannel; the same channel is served over HTTP for +// the curl. In production this would be an OIDC CIBA backchannel or a push +// to the approver's phone; the point of this module is CPEX's suspend and +// resume model, not the notification transport. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; + +use cpex::PluginManager; +use cpex_core::elicitation::{ + ElicitationHook, ElicitationOp, ElicitationOutcomeKind, ElicitationPayload, + ElicitationStatusKind, +}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_sdk::{ + Extensions, HookHandler, Plugin, PluginConfig, PluginContext, PluginError, PluginResult, +}; + +use cpex_tutorial::approvals::{ApprovalChannel, Status}; +use cpex_tutorial::backends; +use cpex_tutorial::idp; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller, Outcome}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m08.yaml"); +const APPROVAL_PORT: u16 = 8090; + +/// Turn an approver name into a stable elicitation id, so dispatch and the +/// later check agree on which request they mean. +fn elicitation_id_for(approver: &str) -> String { + let slug: String = approver + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect(); + format!("elic-{slug}") +} + +/// An elicitation plugin backed by the harness approval channel. It +/// implements the three operations the runtime drives: dispatch opens a +/// pending request, check reports its status, validate confirms the +/// approver. +struct ChannelApprover { + cfg: PluginConfig, + channel: ApprovalChannel, +} + +#[async_trait] +impl Plugin for ChannelApprover { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for ChannelApprover { + async fn handle( + &self, + payload: &ElicitationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let mut out = payload.clone(); + match payload.operation() { + ElicitationOp::Dispatch => { + let approver = payload.from().to_string(); + let id = elicitation_id_for(&approver); + self.channel + .open(&id, &approver, payload.purpose().unwrap_or("(no purpose)")); + out.id = Some(id); + out.status = Some(ElicitationStatusKind::Pending); + out.approver = Some(approver); + }, + ElicitationOp::Check => { + let id = payload.elicitation_id().unwrap_or_default(); + match self.channel.status(id) { + Some(Status::Approved) => { + out.status = Some(ElicitationStatusKind::Resolved); + out.outcome = Some(ElicitationOutcomeKind::Approved); + }, + Some(Status::Denied) => { + out.status = Some(ElicitationStatusKind::Resolved); + out.outcome = Some(ElicitationOutcomeKind::Denied); + }, + _ => out.status = Some(ElicitationStatusKind::Pending), + } + }, + ElicitationOp::Validate => { + out.valid = Some(true); + out.approver = Some(payload.from().to_string()); + }, + } + PluginResult::modify_payload(out) + } +} + +struct ChannelApproverFactory { + channel: ApprovalChannel, +} + +impl PluginFactory for ChannelApproverFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(ChannelApprover { + cfg: config.clone(), + channel: self.channel.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "elicit", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +#[tokio::main] +async fn main() { + ui::module_banner("Module 8: Human in the loop (Elicitation)"); + + // The approval channel: shared between the elicit plugin (which opens + // and reads requests) and the HTTP server (which the human curls). The + // HTTP server is only needed for the interactive curl; --check approves + // through the channel directly, so it skips the server (and its port). + let channel = ApprovalChannel::new(); + if !ui::check_mode() { + channel.serve(APPROVAL_PORT); + } + + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory( + "approval-channel", + Box::new(ChannelApproverFactory { + channel: channel.clone(), + }), + ); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m08.yaml should load"); + mgr.initialize().await.expect("initialize"); + + let evan = match idp::mint_token("evan", "evan").await { + Ok(t) => Caller::with_token(t), + Err(e) => { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }, + }; + let mut all_passed = true; + + // --- First attempt: the call suspends awaiting approval. --- + ui::scenario("evan → send_email (first attempt: suspends for manager approval)"); + let outcome = mediate( + &mgr, + &evan, + "send_email", + json!({ "to": "client@partner.example", "subject": "proposal" }), + backends::send_email, + ) + .await; + ui::print_outcome(&outcome); + let elicitation_id = match &outcome { + Outcome::Pending { elicitation_id, .. } => elicitation_id.clone(), + other => { + println!(" \x1b[33m! expected PENDING, got {other:?}\x1b[0m"); + ui::finish_check(false); + return; + }, + }; + all_passed &= outcome.is_pending(); + + // --- A human approves out of band. --- + println!(" Approve it from another terminal with:"); + println!(" curl -X POST localhost:{APPROVAL_PORT}/approvals/{elicitation_id}/approve\n"); + if ui::check_mode() { + // CI path: approve ourselves so the resume can proceed unattended. + channel.resolve(&elicitation_id, true); + println!(" (--check mode approved it automatically)\n"); + } else { + // Interactive path: wait for the human to curl an approval. + print!(" waiting for approval"); + for _ in 0..60 { + if channel.status(&elicitation_id) == Some(Status::Approved) { + break; + } + print!("."); + use std::io::Write; + let _ = std::io::stdout().flush(); + tokio::time::sleep(Duration::from_secs(1)).await; + } + println!(); + } + + // --- Retry with the elicitation id: the approval is resolved, so the + // call proceeds. --- + ui::scenario("evan → send_email (retry with the approval: resumes and runs)"); + let resumed = evan.clone().resuming(&elicitation_id); + let outcome = mediate( + &mgr, + &resumed, + "send_email", + json!({ "to": "client@partner.example", "subject": "proposal" }), + backends::send_email, + ) + .await; + ui::print_outcome(&outcome); + all_passed &= ui::expect(&outcome, true); + + println!("The operation suspended until a human approved, then resumed. The agent could not proceed alone."); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m09_custom_plugin.rs b/examples/tutorial/examples/m09_custom_plugin.rs new file mode 100644 index 00000000..06a06723 --- /dev/null +++ b/examples/tutorial/examples/m09_custom_plugin.rs @@ -0,0 +1,165 @@ +// Location: ./examples/tutorial/examples/m09_custom_plugin.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Tutorial module 9, Write your own plugin. +// +// cargo run -p cpex-tutorial --example m09_custom_plugin +// cargo run -p cpex-tutorial --example m09_custom_plugin -- --check +// +// A plugin is a Rust type that implements a hook handler. You register it +// under a `kind`, and policy references it by name with run(...), exactly +// like a builtin. This one is a "business hours" guard: it denies a call +// whose `hour` argument falls outside the open/close window in its config. +// +// The traits come from cpex-sdk (the lean plugin-author crate). The +// factory and handler-adapter plumbing come from cpex-core, the same +// pattern every builtin uses. + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex::PluginManager; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_sdk::{ + CmfHook, Extensions, HookHandler, MessagePayload, Plugin, PluginConfig, PluginContext, + PluginError, PluginResult, PluginViolation, +}; + +use cpex_tutorial::backends; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller, Outcome}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m09.yaml"); + +/// The plugin. Holds its parsed config: the open/close window. +struct BusinessHours { + cfg: PluginConfig, + open_hour: u64, + close_hour: u64, +} + +#[async_trait] +impl Plugin for BusinessHours { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for BusinessHours { + async fn handle( + &self, + payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Read the `hour` argument off the tool call. + let hour = payload + .message + .get_tool_calls() + .into_iter() + .next() + .and_then(|tc| tc.arguments.get("hour")) + .and_then(|v| v.as_u64()); + + match hour { + Some(h) if h >= self.open_hour && h < self.close_hour => PluginResult::allow(), + Some(h) => PluginResult::deny(PluginViolation::new( + "office.closed", + format!( + "requested at hour {h}, outside business hours {}-{}", + self.open_hour, self.close_hour + ), + )), + None => PluginResult::deny(PluginViolation::new( + "office.no_hour", + "request did not carry an `hour` argument", + )), + } + } +} + +/// The factory. `install_builtins` and the APL visitor resolve `kind: +/// business-hours` to this, build the plugin, and wire its handler onto +/// the `cmf.tool_pre_invoke` hook. +struct BusinessHoursFactory; + +impl PluginFactory for BusinessHoursFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let cfg_val = config.config.clone().unwrap_or_default(); + let open_hour = cfg_val + .get("open_hour") + .and_then(|v| v.as_u64()) + .unwrap_or(9); + let close_hour = cfg_val + .get("close_hour") + .and_then(|v| v.as_u64()) + .unwrap_or(17); + let plugin = Arc::new(BusinessHours { + cfg: config.clone(), + open_hour, + close_hour, + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +#[tokio::main] +async fn main() { + ui::module_banner("Module 9: Write your own plugin"); + + let mgr = Arc::new(PluginManager::default()); + // Register the custom factory BEFORE loading config, so the APL visitor + // can resolve `kind: business-hours` to it. + mgr.register_factory("business-hours", Box::new(BusinessHoursFactory)); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m09.yaml should load"); + mgr.initialize().await.expect("initialize"); + + let caller = Caller::anonymous(); + let mut all_passed = true; + + ui::scenario("get_compensation at 10:00 (within 9-17 window)"); + let o = mediate( + &mgr, + &caller, + "get_compensation", + json!({ "employee_id": "e-1001", "hour": 10 }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + ui::scenario("get_compensation at 22:00 (outside the window)"); + let o = mediate( + &mgr, + &caller, + "get_compensation", + json!({ "employee_id": "e-1001", "hour": 22 }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + if let Outcome::Denied { code, .. } = &o { + all_passed &= code == "office.closed"; + } + + println!( + "Your plugin plugs into policy exactly like a builtin: referenced by name with run(...)." + ); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m10_testing.rs b/examples/tutorial/examples/m10_testing.rs new file mode 100644 index 00000000..bd7f69a7 --- /dev/null +++ b/examples/tutorial/examples/m10_testing.rs @@ -0,0 +1,67 @@ +// Location: ./examples/tutorial/examples/m10_testing.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Tutorial module 10, Testing your policy. +// +// cargo run -p cpex-tutorial --example m10_testing +// cargo test -p cpex-tutorial # the real tests +// +// Policy is code, so test it like code. You load a policy, drive routes +// through mediate() with a fake backend, and assert the outcome. No IdP or +// real service needed for structural rules. This binary runs a small +// allow/deny table; tests/policy_tests.rs does the same as real cargo +// tests you can wire into CI. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller}; + +use serde_json::json; + +const M04: &str = include_str!("../policies/m04.yaml"); + +#[tokio::main] +async fn main() { + ui::module_banner("Module 10: Testing your policy"); + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(M04).expect("policy should load"); + mgr.initialize().await.expect("initialize"); + + // A table of expectations. Each row is a policy assertion. + let cases = [ + ( + "send_email", + json!({ "to": "x@evil.example", "external": true }), + false, + ), + ( + "send_email", + json!({ "to": "x@corp.example", "external": false }), + false, + ), + ]; + + let mut all_passed = true; + for (tool, args, want_allowed) in cases { + ui::scenario(&format!( + "{tool} {args} → expect {}", + if want_allowed { "ALLOW" } else { "DENY" } + )); + let outcome = mediate(&mgr, &Caller::anonymous(), tool, args, |a| { + backends::dispatch(tool, a) + }) + .await; + ui::print_outcome(&outcome); + all_passed &= ui::expect(&outcome, want_allowed); + } + + println!("Same assertions live in tests/policy_tests.rs; run `cargo test -p cpex-tutorial`."); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/wait_for_idp.rs b/examples/tutorial/examples/wait_for_idp.rs new file mode 100644 index 00000000..4e5a36c3 --- /dev/null +++ b/examples/tutorial/examples/wait_for_idp.rs @@ -0,0 +1,35 @@ +// Location: ./examples/tutorial/examples/wait_for_idp.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// A readiness probe for the tutorial Keycloak realm. +// +// cargo run -p cpex-tutorial --example wait_for_idp +// +// Polls the realm's OIDC discovery endpoint until it answers, then exits 0; +// exits 1 if it does not come up in time. This is the single place readiness +// is gated: `make tutorial-check` runs it once after `docker compose up`, so +// the IdP-backed modules do not each have to wait. The JWT plugin fetches +// JWKS at manager initialize(), so the realm must be serving before any +// module starts — running this probe first guarantees that. + +use std::time::Duration; + +use cpex_tutorial::idp; + +#[tokio::main] +async fn main() { + match idp::wait_until_ready(Duration::from_secs(90)).await { + Ok(()) => { + println!("tutorial IdP is ready at {}", idp::issuer()); + }, + Err(e) => { + eprintln!("\x1b[31m{e}\x1b[0m"); + eprintln!( + "Start it with:\n docker compose -f examples/tutorial/idp/docker-compose.yml up -d" + ); + std::process::exit(1); + }, + } +} diff --git a/examples/tutorial/idp/README.md b/examples/tutorial/idp/README.md new file mode 100644 index 00000000..ea0938ee --- /dev/null +++ b/examples/tutorial/idp/README.md @@ -0,0 +1,153 @@ +# CPEX Tutorial: Keycloak IdP + +A throwaway [Keycloak](https://www.keycloak.org/) realm that provides users, +roles, and clients for the CPEX tutorial. It mints the JWTs the CPEX gateway +validates, and it backs the token-exchange and CIBA exercises. + +> **⚠️ Tutorial-only credentials.** The admin login, every client secret, and +> every user password in this stack are hard-coded, public, and identical to +> the usernames. They exist so the tutorial is reproducible on your laptop. +> **Never use any of this in production, and never reuse these secrets.** + +--- + +## What's in the realm + +Realm name: **`cpex-tutorial`** + +### Personas + +| User | Password | Realm role | `permissions` | `team` | `manager` | Notes | +|-------|----------|------------|---------------|---------------|-----------|------------------------------------| +| alice | `alice` | `hr` | `view_ssn` | `compliance` | n/a | HR analyst who **can** see SSNs | +| dana | `dana` | `hr` | *(none)* | `compliance` | n/a | HR analyst who **cannot** see SSNs | +| evan | `evan` | `engineer` | *(none)* | `engineering` | `mona` | Engineer with a manager | +| sam | `sam` | `security` | *(none)* | `security` | n/a | Security team member | +| mona | `mona` | `hr` | *(none)* | `compliance` | n/a | Manager persona (elicitation approvals) | + +All users have `emailVerified: true` and are enabled for the password +(direct access) grant. + +### Clients + +| Client | Type | Purpose | Secret | +|-----------------|--------------|----------------------------------------------------------------|----------------------| +| `cpex-tutorial` | public | Mints user tokens (password + standard flow). Redirect `*`. | n/a (public) | +| `workday-api` | confidential | Token-exchange **target** audience (resource server). | `workday-dev-secret` | +| `github-api` | confidential | Token-exchange **target** audience (resource server). | `github-dev-secret` | +| `cpex-gateway` | confidential | Token-exchange **requester** + CIBA client. Service account on.| `gateway-dev-secret` | + +`cpex-gateway` has CIBA (`oidc.ciba.grant.enabled=true`) and standard OAuth2 +token exchange (`standard.token.exchange.enabled=true`) enabled. + +### Flat claims (important) + +The CPEX JWT validator reads **flat, top-level** claims, not Keycloak's +default nested `realm_access.roles`. The `cpex-tutorial` client therefore +carries protocol mappers that emit these claims into the **access token**: + +| Claim | Source | Shape | +|---------------|-------------------------------------------|----------------| +| `roles` | realm-role mapper (flat, no prefix) | array of string | +| `permissions` | user attribute `permissions`, multivalued | array of string | +| `team` | user attribute `team`, multivalued | array of string | +| `manager` | user attribute `manager`, single-valued | string | +| `aud` | audience mapper | includes `cpex-tutorial` | + +--- + +## Start it + +```bash +docker compose up -d +``` + +First boot pulls the `quay.io/keycloak/keycloak:26.1` image and imports the +realm, roughly **30 seconds**. Watch readiness with: + +```bash +docker compose ps # STATUS becomes "healthy" once the realm is up +docker compose logs -f keycloak +``` + +Optional Valkey session store (tutorial Module 7), off by default: + +```bash +docker compose --profile valkey up -d +``` + +## Reset it (ephemeral = your reset button) + +This runs in dev mode with **no persistent database**. The realm is imported +fresh on every start and discarded on stop: + +```bash +docker compose down # wipes all users, tokens, and config +docker compose up -d # clean realm again +``` + +There is nothing to migrate and no state to clean up. If anything gets into a +weird state, `down` then `up -d`. + +--- + +## Admin console + +- URL: +- Login: `admin` / `admin` + +Inspect the realm: switch the realm dropdown (top-left) to **cpex-tutorial**, +then browse **Clients → cpex-tutorial → Client scopes / Dedicated scopes** to +see the protocol mappers, or **Users** to see attributes and role mappings. + +--- + +## Endpoints (wire these into the JWT plugin) + +Base issuer: + +``` +http://localhost:8081/realms/cpex-tutorial +``` + +| Endpoint | URL | +|-------------------|--------------------------------------------------------------------------------| +| Issuer | `http://localhost:8081/realms/cpex-tutorial` | +| OIDC discovery | `http://localhost:8081/realms/cpex-tutorial/.well-known/openid-configuration` | +| Token | `http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token` | +| JWKS | `http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs` | + +Configure the JWT validator with audience **`cpex-tutorial`**. + +--- + +## Mint a token (password grant) + +Get an access token for **alice**: + +```bash +curl -s \ + -X POST \ + http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token \ + -d grant_type=password \ + -d client_id=cpex-tutorial \ + -d username=alice \ + -d password=alice \ + | jq -r .access_token +``` + +Decode the payload to confirm the flat claims (`roles`, `permissions`, `team`, +`manager`, `aud`): + +```bash +TOKEN=$(curl -s -X POST \ + http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token \ + -d grant_type=password -d client_id=cpex-tutorial \ + -d username=alice -d password=alice | jq -r .access_token) + +echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq . +``` + +For **alice** you should see `"roles": ["hr"]`, `"permissions": ["view_ssn"]`, +`"team": ["compliance"]`, and `cpex-tutorial` in `aud`. For **dana**, the same +minus `permissions`. diff --git a/examples/tutorial/idp/docker-compose.yml b/examples/tutorial/idp/docker-compose.yml new file mode 100644 index 00000000..09809617 --- /dev/null +++ b/examples/tutorial/idp/docker-compose.yml @@ -0,0 +1,56 @@ +# ============================================================================= +# CPEX Tutorial - Keycloak Identity Provider (DEV MODE ONLY) +# ============================================================================= +# This stack runs Keycloak in development mode for the CPEX tutorial. +# +# * EPHEMERAL: the realm is imported fresh on every `docker compose up` and +# is thrown away on `docker compose down`. There is NO persistent database +# volume - the realm lives only in memory. `down` is your reset button. +# * TUTORIAL-ONLY CREDENTIALS: the admin login (admin/admin), the client +# secrets, and the user passwords in realm-export.json are hard-coded and +# public. They exist to make the tutorial reproducible. +# * NEVER use any part of this stack, or these credentials, in production. +# ============================================================================= + +services: + keycloak: + image: quay.io/keycloak/keycloak:26.3 + container_name: cpex-tutorial-keycloak + command: ["start-dev", "--import-realm"] + ports: + # Host 8081 -> container 8080. Issuer is http://localhost:8081/realms/cpex-tutorial + - "8081:8080" + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + # Keycloak 26.2+ ships Standard Token Exchange (RFC 8693, the flow the + # delegation module uses) as a default feature, enabled per-client in + # the realm export - no KC_FEATURES flag needed. CIBA is also a standard + # grant. If a later Keycloak drops these from defaults, re-add them here. + volumes: + - ./realm-export.json:/opt/keycloak/data/import/realm-export.json:ro + healthcheck: + # Hit the realm's OIDC discovery document. Once the realm is imported and + # the server is serving, this returns 200. + test: + [ + "CMD-SHELL", + "exec 3<>/dev/tcp/localhost/8080 && printf 'GET /realms/cpex-tutorial/.well-known/openid-configuration HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200 OK'", + ] + interval: 10s + timeout: 5s + retries: 15 + start_period: 40s + + # --------------------------------------------------------------------------- + # Optional Valkey (Redis-compatible) service for the tutorial's Module 7 + # "swap the session store" exercise. OFF by default. + # + # Start with: docker compose --profile valkey up -d + # --------------------------------------------------------------------------- + valkey: + image: valkey/valkey:8 + container_name: cpex-tutorial-valkey + profiles: ["valkey"] + ports: + - "6379:6379" diff --git a/examples/tutorial/idp/realm-export.json b/examples/tutorial/idp/realm-export.json new file mode 100644 index 00000000..cc46c553 --- /dev/null +++ b/examples/tutorial/idp/realm-export.json @@ -0,0 +1,272 @@ +{ + "realm": "cpex-tutorial", + "enabled": true, + "displayName": "CPEX Tutorial", + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "accessTokenLifespan": 3600, + "roles": { + "realm": [ + { "name": "hr", "description": "HR analysts" }, + { "name": "engineer", "description": "Engineers" }, + { "name": "security", "description": "Security team" } + ] + }, + "clients": [ + { + "clientId": "cpex-tutorial", + "name": "CPEX Tutorial (public user-token client)", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "redirectUris": ["*"], + "webOrigins": ["*"], + "protocol": "openid-connect", + "fullScopeAllowed": true, + "protocolMappers": [ + { + "name": "realm-roles-flat", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "roles", + "jsonType.label": "String", + "usermodel.realmRoleMapping.rolePrefix": "" + } + }, + { + "name": "permissions-attr", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "permissions", + "claim.name": "permissions", + "jsonType.label": "String", + "multivalued": "true", + "aggregate.attrs": "false", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "name": "team-attr", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "team", + "claim.name": "team", + "jsonType.label": "String", + "multivalued": "true", + "aggregate.attrs": "false", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "name": "manager-attr", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "manager", + "claim.name": "manager", + "jsonType.label": "String", + "multivalued": "false", + "aggregate.attrs": "false", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "name": "audience-cpex-tutorial", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "cpex-tutorial", + "included.custom.audience": "", + "id.token.claim": "false", + "access.token.claim": "true" + } + }, + { + "name": "audience-cpex-gateway", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "cpex-gateway", + "included.custom.audience": "", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] + }, + { + "clientId": "workday-api", + "name": "Workday API (token-exchange target audience)", + "enabled": true, + "publicClient": false, + "bearerOnly": false, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "secret": "workday-dev-secret", + "protocol": "openid-connect", + "fullScopeAllowed": true + }, + { + "clientId": "github-api", + "name": "GitHub API (token-exchange target audience)", + "enabled": true, + "publicClient": false, + "bearerOnly": false, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "secret": "github-dev-secret", + "protocol": "openid-connect", + "fullScopeAllowed": true + }, + { + "clientId": "cpex-gateway", + "name": "CPEX Gateway (token-exchange requester + CIBA client)", + "enabled": true, + "publicClient": false, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "secret": "gateway-dev-secret", + "protocol": "openid-connect", + "fullScopeAllowed": true, + "attributes": { + "oidc.ciba.grant.enabled": "true", + "standard.token.exchange.enabled": "true", + "standard.token.exchange.audiences": "workday-api,github-api", + "backchannel.logout.session.required": "true" + }, + "protocolMappers": [ + { + "name": "workday-api-as-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "workday-api", + "included.custom.audience": "", + "id.token.claim": "false", + "access.token.claim": "true" + } + }, + { + "name": "github-api-as-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "github-api", + "included.custom.audience": "", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] + } + ], + "users": [ + { + "username": "alice", + "enabled": true, + "emailVerified": true, + "email": "alice@cpex-tutorial.local", + "firstName": "Alice", + "lastName": "Analyst", + "attributes": { + "permissions": ["view_ssn"], + "team": ["compliance"] + }, + "credentials": [ + { "type": "password", "value": "alice", "temporary": false } + ], + "realmRoles": ["hr"] + }, + { + "username": "dana", + "enabled": true, + "emailVerified": true, + "email": "dana@cpex-tutorial.local", + "firstName": "Dana", + "lastName": "Analyst", + "attributes": { + "team": ["compliance"] + }, + "credentials": [ + { "type": "password", "value": "dana", "temporary": false } + ], + "realmRoles": ["hr"] + }, + { + "username": "evan", + "enabled": true, + "emailVerified": true, + "email": "evan@cpex-tutorial.local", + "firstName": "Evan", + "lastName": "Engineer", + "attributes": { + "team": ["engineering"], + "manager": ["mona"] + }, + "credentials": [ + { "type": "password", "value": "evan", "temporary": false } + ], + "realmRoles": ["engineer"] + }, + { + "username": "sam", + "enabled": true, + "emailVerified": true, + "email": "sam@cpex-tutorial.local", + "firstName": "Sam", + "lastName": "Security", + "attributes": { + "team": ["security"] + }, + "credentials": [ + { "type": "password", "value": "sam", "temporary": false } + ], + "realmRoles": ["security"] + }, + { + "username": "mona", + "enabled": true, + "emailVerified": true, + "email": "mona@cpex-tutorial.local", + "firstName": "Mona", + "lastName": "Manager", + "attributes": { + "team": ["compliance"] + }, + "credentials": [ + { "type": "password", "value": "mona", "temporary": false } + ], + "realmRoles": ["hr"] + } + ] +} diff --git a/examples/tutorial/policies/capstone-nodeleg.yaml b/examples/tutorial/policies/capstone-nodeleg.yaml new file mode 100644 index 00000000..2c7da783 --- /dev/null +++ b/examples/tutorial/policies/capstone-nodeleg.yaml @@ -0,0 +1,69 @@ +# Capstone (no-delegation variant) +# +# The same scenario as capstone.yaml, for readers who skipped module 6 +# (delegation). It drops the delegate(...) step and the workday-oauth +# plugin; everything else is identical. Run with: +# +# cargo run -p cpex-tutorial --example capstone -- --no-delegation + +global: + apl: + pdp: + - kind: cel + +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + config: + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 + + - name: audit-log + kind: audit/logger + hooks: [cmf.tool_pre_invoke] + capabilities: [read_subject, read_meta] + config: + destination: stderr + +routes: + - tool: get_compensation + authentication: + - keycloak + authorization: + pre_invocation: + - "require(role.hr)" + - "taint(secret, session)" + - "run(audit-log)" + result: + ssn: "str | redact(!perm.view_ssn)" + salary: "int | redact(!role.hr)" + + - tool: search_repos + authentication: + - keycloak + authorization: + pre_invocation: + - "require(authenticated)" + - cel: + expr: "(has(role.engineer) && role.engineer && args.visibility == 'internal') || (has(role.security) && role.security)" + on_deny: + - "deny('engineers read internal only; security reads any', 'repo.policy_denied')" + - "run(audit-log)" + + - tool: send_email + authentication: + - keycloak + authorization: + pre_invocation: + - "require(authenticated)" + - "security.labels contains \"secret\": deny('write-down blocked: this session read secret data', 'session_tainted')" + - "run(audit-log)" diff --git a/examples/tutorial/policies/capstone.yaml b/examples/tutorial/policies/capstone.yaml new file mode 100644 index 00000000..3ab20fb1 --- /dev/null +++ b/examples/tutorial/policies/capstone.yaml @@ -0,0 +1,95 @@ +# Capstone, the three-backend agent +# +# This is the scenario from the CPEX Overview, assembled from everything +# the tutorial built. One agent, three backends, one policy. The same +# request gets a different result per caller, and information follows the +# session across calls. +# +# get_compensation (HR) authorize by role, mint a scoped downstream +# token (delegation), redact by permission, and +# taint the session as having read secret data. +# search_repos (repos) a CEL decision: engineers read internal only, +# security reads any. +# send_email (email) refuse if the session already touched secret +# data (write-down), and audit every attempt. +# +# The application never changes. Only this policy decides the outcomes. + +global: + apl: + pdp: + - kind: cel + +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + config: + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 + + - name: workday-oauth + kind: delegator/oauth + hooks: [token.delegate] + capabilities: [read_inbound_credentials, write_delegated_tokens] + config: + token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token + client_id: cpex-gateway + client_secret_source: + kind: literal + secret: gateway-dev-secret + insecure_http: true + + - name: audit-log + kind: audit/logger + hooks: [cmf.tool_pre_invoke] + capabilities: [read_subject, read_meta] + config: + destination: stderr + +routes: + # HR lookup: gate on role, mint a scoped downstream token, taint the + # session, audit, then redact fields by permission on the way out. + - tool: get_compensation + authentication: + - keycloak + authorization: + pre_invocation: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, audience: workday-api)" + - "taint(secret, session)" + - "run(audit-log)" + result: + ssn: "str | redact(!perm.view_ssn)" + salary: "int | redact(!role.hr)" + + # Repo search: hand the decision to CEL. + - tool: search_repos + authentication: + - keycloak + authorization: + pre_invocation: + - "require(authenticated)" + - cel: + expr: "(has(role.engineer) && role.engineer && args.visibility == 'internal') || (has(role.security) && role.security)" + on_deny: + - "deny('engineers read internal only; security reads any', 'repo.policy_denied')" + - "run(audit-log)" + + # Outbound email: refuse if the session already read secret data. + - tool: send_email + authentication: + - keycloak + authorization: + pre_invocation: + - "require(authenticated)" + - "security.labels contains \"secret\": deny('write-down blocked: this session read secret data', 'session_tainted')" + - "run(audit-log)" diff --git a/examples/tutorial/policies/m01.yaml b/examples/tutorial/policies/m01.yaml new file mode 100644 index 00000000..577dd000 --- /dev/null +++ b/examples/tutorial/policies/m01.yaml @@ -0,0 +1,29 @@ +# Module 1, Hello, enforcement +# +# Two routes, so one anonymous caller can see both outcomes a route +# produces: +# +# * get_compensation requires an authenticated caller. We have no IdP +# until module 2, so every caller here is anonymous, this route +# always DENIES, and the denial names the predicate that failed. +# +# * search_repos has no pre-invocation rule, so nothing blocks it, it +# always ALLOWS. A route with no denying rule is open by default. +# +# The lesson: the route defines the pipeline. Same caller, same backend, +# different route -> different outcome. Nothing in the Rust code changed. +# +# Try it: change `require(authenticated)` to `require(role.hr)` and re-run. +# get_compensation still denies, but the reason code changes. Or add a +# `require(authenticated)` line under search_repos and watch it start +# denying too. Reset with `git checkout -- examples/tutorial/policies`. + +routes: + - tool: get_compensation + authorization: + pre_invocation: + - "require(authenticated)" + + - tool: search_repos + authorization: + pre_invocation: [] diff --git a/examples/tutorial/policies/m02.yaml b/examples/tutorial/policies/m02.yaml new file mode 100644 index 00000000..1c4e8ed9 --- /dev/null +++ b/examples/tutorial/policies/m02.yaml @@ -0,0 +1,45 @@ +# Module 2, Who's calling? (Identity) +# +# This is the first module with a real identity provider. The `keycloak` +# plugin resolves the caller's bearer token (a JWT minted by the tutorial +# Keycloak realm) into a subject: an id, a set of roles, and a set of +# permissions. Policy predicates then read those attributes. +# +# The plugin validates the token OFFLINE against the realm's signing keys +# (fetched once from the JWKS url, then cached), it does not call Keycloak +# on every request. It checks the issuer, audience, expiry, and signature; +# a token failing any of those is rejected before any authorization rule +# runs (try an expired or tampered token and watch the reason code). +# +# The realm emits FLAT `roles` and `permissions` claims (via protocol +# mappers, see idp/README.md). The `standard` claim mapper turns +# `roles: ["hr"]` into role.hr = true and `permissions: ["view_ssn"]` +# into perm.view_ssn = true, which is exactly what these predicates read. + +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + config: + role: user + header: Authorization + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true # localhost speaks http; never set this in production + refresh_secs: 600 + leeway_seconds: 60 + +routes: + - tool: get_compensation + authentication: + - keycloak + authorization: + pre_invocation: + - "require(authenticated)" + - "require(role.hr)" diff --git a/examples/tutorial/policies/m03.yaml b/examples/tutorial/policies/m03.yaml new file mode 100644 index 00000000..1dde1caa --- /dev/null +++ b/examples/tutorial/policies/m03.yaml @@ -0,0 +1,27 @@ +# Module 3, Shaping data +# +# Same backend record, different fields visible per caller. The route +# allows the call, then a `result:` field pipeline transforms the response +# on the way out: +# +# * ssn is redacted unless the caller has the view_ssn permission. +# * salary is redacted unless the caller has the hr role. +# * employee_id is always masked to its last 4 characters. +# +# A pipeline entry reads `: " | ()"`. The op runs +# only when its predicate is true; `redact(!perm.view_ssn)` means "redact +# when the caller does NOT have view_ssn." +# +# In this module the caller is still anonymous (no IdP until you reach +# module 2, but you can run this one now), so neither perm.view_ssn nor +# role.hr is set: both redactions fire. Once you have tokens (module 2+), +# an HR caller with view_ssn sees the full record from the SAME policy. + +routes: + - tool: get_compensation + authorization: + pre_invocation: [] + result: + ssn: "str | redact(!perm.view_ssn)" + salary: "int | redact(!role.hr)" + employee_id: "str | mask(4)" diff --git a/examples/tutorial/policies/m04.yaml b/examples/tutorial/policies/m04.yaml new file mode 100644 index 00000000..13d9fa3c --- /dev/null +++ b/examples/tutorial/policies/m04.yaml @@ -0,0 +1,36 @@ +# Module 4, Effects and sequencing +# +# A route's pre_invocation is an ordered list of effects. They run top to +# bottom and HALT on the first denial (halt-on-deny). Effects before the +# denial still ran, including side effects like auditing. +# +# This route protects outbound email: +# +# 1. run(audit-log): a side-effecting plugin. It records the attempt and +# always allows, so the pipeline continues. Watch stderr for its line +# even when the call is later denied. +# 2. a deny guard: `deny('msg','code')` produces a denial with a machine +# code you choose, so downstream systems can branch on it. Here we +# block outbound email whose recipient is external. +# 3. require(authenticated): never reached when step 2 denies. Proof of +# halt-on-deny. +# +# Try it: reorder the steps so require(authenticated) comes first. Now an +# anonymous call is denied by authentication and the external-recipient +# guard never runs (and the reason code changes). Ordering is policy. + +plugins: + - name: audit-log + kind: audit/logger + hooks: [cmf.tool_pre_invoke] + capabilities: [read_subject, read_meta] + config: + destination: stderr + +routes: + - tool: send_email + authorization: + pre_invocation: + - "run(audit-log)" + - "args.external == true: deny('outbound email to external recipients is blocked', 'email.external_blocked')" + - "require(authenticated)" diff --git a/examples/tutorial/policies/m05.yaml b/examples/tutorial/policies/m05.yaml new file mode 100644 index 00000000..e15baa95 --- /dev/null +++ b/examples/tutorial/policies/m05.yaml @@ -0,0 +1,50 @@ +# Module 5, Delegating decisions (PDP) +# +# Some decisions are easier to express as a rule engine's expression than +# as a list of require() predicates. CPEX hands those decisions to a Policy +# Decision Point (PDP). The engine decides allow or deny; CPEX enforces it. +# +# This route uses the CEL PDP. The `cel:` step evaluates a boolean +# expression against the same attributes predicates see (roles, args). The +# rule: engineers may search internal repos only; security may search any. +# +# * evan (engineer) + internal -> allow +# * evan (engineer) + public -> deny (engineers are internal-only here) +# * sam (security) + anything -> allow +# +# A PDP is declared once under global.apl.pdp, then referenced by steps. +# CEL expressions must guard optional attributes with has(...), because a +# reference to an unset attribute is an error (and errors fail closed). + +global: + apl: + pdp: + - kind: cel + +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + config: + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 + +routes: + - tool: search_repos + authentication: + - keycloak + authorization: + pre_invocation: + - "require(authenticated)" + - cel: + expr: "(has(role.engineer) && role.engineer && args.visibility == 'internal') || (has(role.security) && role.security)" + on_deny: + - "deny('engineers read internal only; security reads any', 'repo.policy_denied')" diff --git a/examples/tutorial/policies/m06.yaml b/examples/tutorial/policies/m06.yaml new file mode 100644 index 00000000..8fd03df4 --- /dev/null +++ b/examples/tutorial/policies/m06.yaml @@ -0,0 +1,63 @@ +# Module 6, Scoped credentials (Delegation) +# +# An agent calling a downstream API should not reuse the caller's full +# token. It should present a narrower, purpose-scoped credential minted for +# that one call. CPEX does this with a delegate(...) step: an OAuth 2.0 +# token exchange (RFC 8693) that swaps the caller's token for a downstream +# token scoped to a specific audience. +# +# This route authorizes an HR lookup, then mints a downstream token for the +# `workday-api` audience before the backend call: +# +# 1. require(role.hr) gate on role first. +# 2. delegate(workday-oauth, target: ...) exchange the caller's token +# for a workday-api token. +# 3. require(delegation.granted) proceed only if the exchange +# succeeded. +# +# The exchange runs against the real Keycloak token endpoint. The gateway +# client (cpex-gateway) is configured to exchange the caller's token for +# the workday-api / github-api audiences (see idp/realm-export.json: +# standard.token.exchange.audiences plus the audience mappers). + +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + config: + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 + + - name: workday-oauth + kind: delegator/oauth + hooks: [token.delegate] + # The delegator needs to read the caller's inbound token (to exchange + # it) and write the minted downstream token. Declaring these here lets + # the route surface them to the enforcement boundary; without them the + # inbound token is filtered out before the exchange and it fails. + capabilities: [read_inbound_credentials, write_delegated_tokens] + config: + token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token + client_id: cpex-gateway + client_secret_source: + kind: literal + secret: gateway-dev-secret + insecure_http: true # localhost speaks http; never in production + +routes: + - tool: get_compensation + authentication: + - keycloak + authorization: + pre_invocation: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, audience: workday-api)" + - "require(delegation.granted)" diff --git a/examples/tutorial/policies/m07.yaml b/examples/tutorial/policies/m07.yaml new file mode 100644 index 00000000..c9d303fd --- /dev/null +++ b/examples/tutorial/policies/m07.yaml @@ -0,0 +1,57 @@ +# Module 7, Information flow (Tainting) +# +# Most authorization systems judge each request in isolation. CPEX can +# carry security state across a whole session, so a later request can be +# denied because of what an earlier one did. This stops write-down: data +# read under one clearance leaking out through a lower-clearance channel. +# +# Two routes, one session: +# +# * get_compensation taints the session with the label `secret` when it +# runs. `taint(secret, session)` records that the session has now +# touched sensitive data. The label persists in the session store. +# +# * send_email refuses if the session already carries `secret`. The +# guard `security.labels contains "secret": deny(...)` reads the +# accumulated session state, not just this request's arguments. +# +# So the same send_email call is allowed in a fresh session and denied in +# a session that previously read compensation. The email's own content is +# irrelevant; the session's history is what blocks it. +# +# Session state lives in an in-memory store here. In production you would +# point it at Valkey (see idp/docker-compose.yml, the `valkey` profile) so +# state survives across processes. + +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + config: + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 + +routes: + - tool: get_compensation + authentication: + - keycloak + authorization: + pre_invocation: + - "require(role.hr)" + - "taint(secret, session)" + + - tool: send_email + authentication: + - keycloak + authorization: + pre_invocation: + - "require(authenticated)" + - "security.labels contains \"secret\": deny('write-down blocked: this session read secret data', 'session_tainted')" diff --git a/examples/tutorial/policies/m08.yaml b/examples/tutorial/policies/m08.yaml new file mode 100644 index 00000000..570b5b94 --- /dev/null +++ b/examples/tutorial/policies/m08.yaml @@ -0,0 +1,51 @@ +# Module 8, Human in the loop (Elicitation) +# +# Some actions should not run on the agent's say-so alone. They need a +# human to approve first. CPEX models this by SUSPENDING the operation: +# policy pauses the call, records a pending approval, and tells the caller +# to come back once a human has decided. Nothing runs until then. +# +# This route requires the caller's manager to approve an outbound email: +# +# 1. require(authenticated) +# 2. require_approval(manager-approval, from: claim.manager, ...) +# +# The first time a caller hits this route, the approval is Pending: the +# call is suspended and the caller gets an elicitation id. A human approves +# out of band (in this tutorial, with a curl to the approval channel the +# harness serves). The caller then retries with that id, and the approval +# is now resolved, so the call proceeds. +# +# `from: claim.manager` resolves to the caller's manager from their token +# (evan's manager is mona). The approval plugin is a small custom plugin +# on the `elicit` hook, backed by a curl-driven channel. In production this +# would be an OIDC CIBA backchannel or a push to the approver's device. + +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + config: + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 + + - name: manager-approval + kind: approval-channel + hooks: [elicit] + +routes: + - tool: send_email + authentication: + - keycloak + authorization: + pre_invocation: + - "require(authenticated)" + - "require_approval(manager-approval, from: claim.manager, purpose: \"Approve outbound email\")" diff --git a/examples/tutorial/policies/m09.yaml b/examples/tutorial/policies/m09.yaml new file mode 100644 index 00000000..a84d9ef8 --- /dev/null +++ b/examples/tutorial/policies/m09.yaml @@ -0,0 +1,31 @@ +# Module 9, Write your own plugin +# +# Builtins cover common needs, but eventually you want a check the bundle +# does not ship. You write a plugin: a small Rust type that implements a +# hook handler, register it under a `kind`, then reference it from policy +# by name with run(...), exactly like a builtin. +# +# This route runs a custom `business-hours` guard before allowing an HR +# lookup. The guard reads the request's `hour` argument and the open/close +# window from its own config, and denies calls outside business hours. +# +# * hour within [open_hour, close_hour) -> allow +# * hour outside the window -> deny (office.closed) +# +# The plugin source is in examples/m09_custom_plugin.rs. Note that the +# route below looks like any other: a plugin you wrote is referenced the +# same way as run(audit-log) in module 4. + +plugins: + - name: business-hours + kind: business-hours + hooks: [cmf.tool_pre_invoke] + config: + open_hour: 9 + close_hour: 17 + +routes: + - tool: get_compensation + authorization: + pre_invocation: + - "run(business-hours)" diff --git a/examples/tutorial/recordings/README.md b/examples/tutorial/recordings/README.md new file mode 100644 index 00000000..c9abddb8 --- /dev/null +++ b/examples/tutorial/recordings/README.md @@ -0,0 +1,47 @@ +# Tutorial terminal recordings + +Short asciinema recordings for the tutorial docs. Four moments where seeing the output beats reading it: + +| Cast | Module | Published | What it shows | +|------|--------|-----------|---------------| +| `m01-hello` | 1 | [a/GWQ1rUgWufRxMUcE](https://asciinema.org/a/GWQ1rUgWufRxMUcE) | the first allow and deny | +| `m03-shaping` | 3 | [a/FJscsrCUbnazbPSl](https://asciinema.org/a/FJscsrCUbnazbPSl) | the same record, redacted vs. full | +| `m07-tainting` | 7 | [a/MCo8BWT3DvW7OH8d](https://asciinema.org/a/MCo8BWT3DvW7OH8d) | the write-down block across a session | +| `m08-elicitation` | 8 | [a/xjfOzQwrEnrLLCp8](https://asciinema.org/a/xjfOzQwrEnrLLCp8) | suspend, approve out of band, resume | + +> **These uploads are anonymous and must be claimed.** They were published +> from a CLI that is not linked to an account, so asciinema.org will delete +> them after about 7 days unless claimed. To keep them permanently: run +> `asciinema auth`, open the printed URL in a browser while signed in to +> your asciinema.org account, and the recordings from this machine attach +> to it. The docs already embed these ids (see the four module pages). + +## Recording and publishing + +These are published to [asciinema.org](https://asciinema.org) from a logged-in account, so the casts do not expire and each has its own shareable URL. Unlike a GIF, the player is pausable and seekable, and the terminal text is selectable. + +1. Log in once: `asciinema auth` (opens a browser to link this machine to your account). +2. Start the tutorial IdP (modules 7 and 8 need it): + ```bash + docker compose -f examples/tutorial/idp/docker-compose.yml up -d + ``` +3. Record each cast (scripts drive the module binaries so the sessions are reproducible): + ```bash + ./record.sh # records all four into ./casts/ + ``` +4. Upload each to your account and note the returned id: + ```bash + asciinema upload casts/m01-hello.cast # prints https://asciinema.org/a/ + ``` + +## Embedding in the docs + +Once a cast is uploaded, add its player to the matching docs page with the hugo-book shortcode, pointing at the public cast on asciinema.org: + +``` +{{< asciinema cast="https://asciinema.org/a/.cast" poster="npt:0:03" >}} +``` + +The player streams the cast from asciinema.org (non-expiring, individually shareable at `asciinema.org/a/`) while rendering inline in the page. Keep the copyable text output already in each page: it is the accessible fallback and works even if asciinema.org is unreachable. + +The casts and the `record.sh` script live here so they can be regenerated and re-uploaded whenever module output changes. `make tutorial-check` catches when a module's output has drifted from what the docs and casts show. diff --git a/examples/tutorial/recordings/casts/m01-hello.cast b/examples/tutorial/recordings/casts/m01-hello.cast new file mode 100644 index 00000000..1e651540 --- /dev/null +++ b/examples/tutorial/recordings/casts/m01-hello.cast @@ -0,0 +1,7 @@ +{"version": 3, "term": {"cols": 80, "rows": 24}, "timestamp": 1784230036, "command": "cargo run -q -p cpex-tutorial --example m01_hello", "title": "CPEX tutorial: m01-hello", "env": {"SHELL": "/bin/zsh"}} +[0.6, "o", "\r\n\u001b[1;36m=== Module 1: Hello, enforcement ===\u001b[0m\r\n\r\n"] +[1.2, "o", "\u001b[1m\u25b8 anonymous \u2192 get_compensation (route requires authentication)\u001b[0m\r\n"] +[1.8, "o", " \u001b[31m\u2717 DENIED\u001b[0m [routes.tool:get_compensation.apl.pre_invocation[0]] access denied\r\n\r\n"] +[2.4, "o", "\u001b[1m\u25b8 anonymous \u2192 search_repos (route has no rule)\u001b[0m\r\n"] +[3.0, "o", " \u001b[32m\u2713 ALLOWED\u001b[0m {\"visibility\":\"public\",\"repositories\":[{\"name\":\"brand-site\",\"visibility\":\"public\"}]}\r\n\r\nThe route decided the outcome, the code above treated both calls identically.\r\n"] +[3.8, "x", "0"] diff --git a/examples/tutorial/recordings/casts/m03-shaping.cast b/examples/tutorial/recordings/casts/m03-shaping.cast new file mode 100644 index 00000000..a3436cb1 --- /dev/null +++ b/examples/tutorial/recordings/casts/m03-shaping.cast @@ -0,0 +1,7 @@ +{"version": 3, "term": {"cols": 80, "rows": 24}, "timestamp": 1784230063, "command": "cargo run -q -p cpex-tutorial --example m03_shaping", "title": "CPEX tutorial: m03-shaping", "env": {"SHELL": "/bin/zsh"}} +[0.6, "o", "\r\n\u001b[1;36m=== Module 3: Shaping data ===\u001b[0m\r\n\r\n"] +[1.2, "o", "\u001b[1m\u25b8 anonymous \u2192 get_compensation (result pipeline redacts ssn + salary, masks id)\u001b[0m\r\n"] +[1.8, "o", " \u001b[32m\u2713 ALLOWED\u001b[0m {\"employee_id\":\"**1001\",\"name\":\"Alice Okafor\",\"title\":\"Staff Engineer\",\"salary\":\"[REDACTED]\",\"ssn\":\"[REDACTED]\"}\r\n\r\n"] +[2.4, "o", " ssn was transformed (no view_ssn permission) \u2713\r\n employee_id was masked \u2713\r\n\r\n"] +[3.0, "o", "Redaction happens on the way OUT, the backend returned the full record; policy shaped it.\r\n"] +[3.8, "x", "0"] diff --git a/examples/tutorial/recordings/casts/m07-tainting.cast b/examples/tutorial/recordings/casts/m07-tainting.cast new file mode 100644 index 00000000..f671e182 --- /dev/null +++ b/examples/tutorial/recordings/casts/m07-tainting.cast @@ -0,0 +1,8 @@ +{"version": 3, "term": {"cols": 80, "rows": 24}, "timestamp": 1784230064, "command": "cargo run -q -p cpex-tutorial --example m07_tainting", "title": "CPEX tutorial: m07-tainting", "env": {"SHELL": "/bin/zsh"}} +[0.6, "o", "\r\n\u001b[1;36m=== Module 7: Information flow (Tainting) ===\u001b[0m\r\n\r\n"] +[1.2, "o", "\u001b[1m\u25b8 alice, fresh session \u2192 send_email (nothing tainted yet)\u001b[0m\r\n"] +[1.8, "o", " \u001b[32m\u2713 ALLOWED\u001b[0m {\"sent\":true,\"to\":\"coworker@corp.example\",\"subject\":\"lunch\"}\r\n\r\n"] +[2.4, "o", "\u001b[1m\u25b8 alice, session-hr-work \u2192 get_compensation (taints session 'secret')\u001b[0m\r\n"] +[3.0, "o", " \u001b[32m\u2713 ALLOWED\u001b[0m {\"employee_id\":\"e-1001\",\"name\":\"Alice Okafor\",\"title\":\"Staff Engineer\",\"salary\":198000,\"ssn\":\"521-38-7710\"}\r\n\r\n\u001b[1m\u25b8 alice, session-hr-work \u2192 send_email (write-down blocked)\u001b[0m\r\n"] +[3.6, "o", " \u001b[31m\u2717 DENIED\u001b[0m [session_tainted] write-down blocked: this session read secret data\r\n\r\nIdentical send_email call: allowed in the clean session, denied after the same session read secret data.\r\n"] +[4.4, "x", "0"] diff --git a/examples/tutorial/recordings/casts/m08-elicitation.cast b/examples/tutorial/recordings/casts/m08-elicitation.cast new file mode 100644 index 00000000..7a1bb795 --- /dev/null +++ b/examples/tutorial/recordings/casts/m08-elicitation.cast @@ -0,0 +1,9 @@ +{"version": 3, "term": {"cols": 80, "rows": 24}, "timestamp": 1784230065, "command": "cargo run -q -p cpex-tutorial --example m08_elicitation -- --check", "title": "CPEX tutorial: m08-elicitation", "env": {"SHELL": "/bin/zsh"}} +[0.6, "o", "\r\n\u001b[1;36m=== Module 8: Human in the loop (Elicitation) ===\u001b[0m\r\n\r\n"] +[1.2, "o", "\u001b[1m\u25b8 evan \u2192 send_email (first attempt: suspends for manager approval)\u001b[0m\r\n"] +[1.8, "o", " \u001b[33m\u23f8 PENDING\u001b[0m awaiting mona's approval (id elic-mona)\r\n\r\n Approve it from another terminal with:\r\n"] +[2.4, "o", " curl -X POST localhost:8090/approvals/elic-mona/approve\r\n\r\n"] +[3.0, "o", " (--check mode approved it automatically)\r\n\r\n\u001b[1m\u25b8 evan \u2192 send_email (retry with the approval: resumes and runs)\u001b[0m\r\n"] +[3.6, "o", " \u001b[32m\u2713 ALLOWED\u001b[0m {\"sent\":true,\"to\":\"client@partner.example\",\"subject\":\"proposal\"}\r\n\r\nThe operation suspended until a human approved, then resumed. The agent could not proceed alone.\r\n"] +[4.2, "o", "\u001b[32mAll checks passed.\u001b[0m\r\n"] +[5.0, "x", "0"] diff --git a/examples/tutorial/recordings/record.sh b/examples/tutorial/recordings/record.sh new file mode 100755 index 00000000..fd9012f7 --- /dev/null +++ b/examples/tutorial/recordings/record.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Record the four tutorial casts into ./casts/. Run from the repo root or +# from this directory; paths are resolved relative to the repo root. +# +# ./record.sh # record all four +# ./record.sh m01-hello # record just one +# +# Prerequisites: asciinema installed, and (for m07/m08) the tutorial IdP up: +# docker compose -f examples/tutorial/idp/docker-compose.yml up -d +# +# Each recording runs a module binary once. The sessions are scripted (no +# manual typing) so re-recording is deterministic. Module 8 is recorded in +# --check mode so it approves itself and the cast completes unattended; the +# docs describe the interactive curl step in prose alongside it. +set -euo pipefail + +# Resolve the repo root (two levels up from this script). +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +CASTS="$(dirname "${BASH_SOURCE[0]}")/casts" +mkdir -p "$CASTS" + +record() { + local name="$1" example="$2" + shift 2 + echo "→ recording $name" + asciinema rec --overwrite \ + --title "CPEX tutorial: $name" \ + --command "cd '$ROOT' && cargo run -q -p cpex-tutorial --example $example -- $*" \ + "$CASTS/$name.cast" +} + +target="${1:-all}" + +if [[ "$target" == "all" || "$target" == "m01-hello" ]]; then + record "m01-hello" m01_hello +fi +if [[ "$target" == "all" || "$target" == "m03-shaping" ]]; then + record "m03-shaping" m03_shaping +fi +if [[ "$target" == "all" || "$target" == "m07-tainting" ]]; then + record "m07-tainting" m07_tainting +fi +if [[ "$target" == "all" || "$target" == "m08-elicitation" ]]; then + # --check so the cast self-approves and completes without a second terminal. + record "m08-elicitation" m08_elicitation --check +fi + +echo "done. Casts in $CASTS/. Upload with: asciinema upload $CASTS/.cast" diff --git a/examples/tutorial/src/approvals.rs b/examples/tutorial/src/approvals.rs new file mode 100644 index 00000000..4808a86e --- /dev/null +++ b/examples/tutorial/src/approvals.rs @@ -0,0 +1,149 @@ +// Location: ./examples/tutorial/src/approvals.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// A deliberately tiny human-approval channel for the elicitation module +// (module 8). When policy suspends an operation awaiting a human, the +// request lands here as "pending"; a human approves or denies it out of +// band, in the tutorial, with a `curl` from a second terminal: +// +// curl localhost:8090/approvals # list pending +// curl -X POST localhost:8090/approvals//approve +// curl -X POST localhost:8090/approvals//deny +// +// This stands in for what a production deployment does with a real +// out-of-band channel (a push notification to an approver's phone, an +// OIDC CIBA backchannel, a Slack action). The point of module 8 is CPEX's +// suspend/resume model, not the transport, so we keep the transport a +// one-screen HTTP server. The "Go deeper" link points at the full +// Keycloak-CIBA channel for readers who want the real backchannel. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +/// Status of a pending approval request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + Pending, + Approved, + Denied, +} + +/// One approval request tracked by the channel. +#[derive(Debug, Clone)] +pub struct Request { + pub id: String, + pub approver: String, + pub purpose: String, + pub status: Status, +} + +/// Shared, thread-safe store of approval requests. Cloneable handle, the +/// HTTP server thread and the policy path share one store. +#[derive(Clone, Default)] +pub struct ApprovalChannel { + inner: Arc>>, +} + +impl ApprovalChannel { + pub fn new() -> Self { + Self::default() + } + + /// Register a new pending request (called when policy suspends). + pub fn open(&self, id: &str, approver: &str, purpose: &str) { + let mut map = self.inner.lock().unwrap(); + map.entry(id.to_string()).or_insert_with(|| Request { + id: id.to_string(), + approver: approver.to_string(), + purpose: purpose.to_string(), + status: Status::Pending, + }); + } + + /// Current status of a request (`None` if unknown). + pub fn status(&self, id: &str) -> Option { + self.inner.lock().unwrap().get(id).map(|r| r.status) + } + + /// Approve or deny a request. Returns `true` if the id was found. + pub fn resolve(&self, id: &str, approved: bool) -> bool { + let mut map = self.inner.lock().unwrap(); + if let Some(req) = map.get_mut(id) { + req.status = if approved { + Status::Approved + } else { + Status::Denied + }; + true + } else { + false + } + } + + /// Snapshot of all requests, for the `GET /approvals` listing. + pub fn list(&self) -> Vec { + self.inner.lock().unwrap().values().cloned().collect() + } + + /// Start the HTTP server on `port` in a background thread. Returns the + /// join handle; the server runs until the process exits. + pub fn serve(&self, port: u16) -> std::thread::JoinHandle<()> { + let channel = self.clone(); + std::thread::spawn(move || { + let server = match tiny_http::Server::http(("127.0.0.1", port)) { + Ok(s) => s, + Err(e) => { + eprintln!("approval channel failed to bind port {port}: {e}"); + return; + }, + }; + for request in server.incoming_requests() { + let (status, body) = route(&channel, request.method(), request.url()); + let response = tiny_http::Response::from_string(body).with_status_code(status); + let _ = request.respond(response); + } + }) + } +} + +/// Route one HTTP request to a (status, body) pair. Split out so it can be +/// unit-tested without a live socket. +fn route(channel: &ApprovalChannel, method: &tiny_http::Method, url: &str) -> (u16, String) { + let path = url.trim_end_matches('/'); + match (method, path) { + (tiny_http::Method::Get, "/approvals") => { + let lines: Vec = channel + .list() + .iter() + .map(|r| { + format!( + "{}\t{:?}\tapprover={}\t{}", + r.id, r.status, r.approver, r.purpose + ) + }) + .collect(); + let body = if lines.is_empty() { + "no pending approvals\n".to_string() + } else { + format!("{}\n", lines.join("\n")) + }; + (200, body) + }, + (tiny_http::Method::Post, p) if p.ends_with("/approve") || p.ends_with("/deny") => { + let approved = p.ends_with("/approve"); + let id = p + .trim_start_matches("/approvals/") + .trim_end_matches("/approve") + .trim_end_matches("/deny"); + if channel.resolve(id, approved) { + let verb = if approved { "approved" } else { "denied" }; + (200, format!("{id} {verb}\n")) + } else { + (404, format!("no pending approval '{id}'\n")) + } + }, + _ => (404, "not found\n".to_string()), + } +} diff --git a/examples/tutorial/src/backends.rs b/examples/tutorial/src/backends.rs new file mode 100644 index 00000000..d547cf37 --- /dev/null +++ b/examples/tutorial/src/backends.rs @@ -0,0 +1,92 @@ +// Location: ./examples/tutorial/src/backends.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Fake tool backends for the tutorial. These stand in for the real +// services an agent would call, an HR system, a source-repository +// index, an outbound mailer. They hold hard-coded data and do no +// enforcement of their own: every access decision in the tutorial is +// made by CPEX policy *in front of* these functions, never inside them. +// That separation is the whole point, the same backend returns the +// same bytes regardless of caller; policy decides who sees what. + +use serde_json::{json, Value}; + +/// Look up an employee's full HR record by id. The record includes an +/// SSN and salary; policy (not this function) decides whether the +/// caller may see those fields. +pub fn get_compensation(args: &Value) -> Value { + let employee_id = args + .get("employee_id") + .and_then(Value::as_str) + .unwrap_or("e-0000"); + // A tiny fake directory. Any unknown id falls back to a stub record + // so the module scenarios always get a well-formed response. + match employee_id { + "e-1001" => json!({ + "employee_id": "e-1001", + "name": "Alice Okafor", + "title": "Staff Engineer", + "salary": 198_000, + "ssn": "521-38-7710", + }), + "e-1002" => json!({ + "employee_id": "e-1002", + "name": "Ben Underwood", + "title": "HR Business Partner", + "salary": 141_500, + "ssn": "492-11-6034", + }), + other => json!({ + "employee_id": other, + "name": "Unknown Employee", + "title": "n/a", + "salary": 0, + "ssn": "000-00-0000", + }), + } +} + +/// Search source repositories. Returns a list of repos matching the +/// requested visibility. Backend does no authorization, a policy route +/// gates who may search which visibility. +pub fn search_repos(args: &Value) -> Value { + let visibility = args + .get("visibility") + .and_then(Value::as_str) + .unwrap_or("internal"); + let all = [ + json!({ "name": "payments-core", "visibility": "internal" }), + json!({ "name": "fraud-models", "visibility": "internal" }), + json!({ "name": "brand-site", "visibility": "public" }), + ]; + let hits: Vec = all + .iter() + .filter(|r| r["visibility"] == visibility) + .cloned() + .collect(); + json!({ "visibility": visibility, "repositories": hits }) +} + +/// Send an outbound email. The backend "sends" by echoing what it would +/// have transmitted. Whether this is allowed to run at all, for +/// instance, after the session has touched compensation data, is a +/// policy decision made before this function is ever reached. +pub fn send_email(args: &Value) -> Value { + let to = args.get("to").and_then(Value::as_str).unwrap_or(""); + let subject = args.get("subject").and_then(Value::as_str).unwrap_or(""); + json!({ "sent": true, "to": to, "subject": subject }) +} + +/// Dispatch a tool call by name to the matching backend. Modules that +/// build multi-tool scenarios (the capstone) route through here; simpler +/// modules can call a specific function directly. +pub fn dispatch(tool: &str, args: &Value) -> Value { + match tool { + "get_compensation" => get_compensation(args), + "search_repos" => search_repos(args), + "send_email" => send_email(args), + other => json!({ "error": format!("no backend for tool '{other}'") }), + } +} diff --git a/examples/tutorial/src/idp.rs b/examples/tutorial/src/idp.rs new file mode 100644 index 00000000..e6e5e0fc --- /dev/null +++ b/examples/tutorial/src/idp.rs @@ -0,0 +1,106 @@ +// Location: ./examples/tutorial/src/idp.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Helpers for talking to the tutorial Keycloak realm (started by +// `idp/docker-compose.yml`). Modules 2+ call `mint_token` to get a real +// JWT for a persona; the JWT plugin in policy then validates it against +// the realm's JWKS. This is the ONLY place the tutorial speaks HTTP to +// the IdP, policy never does; it verifies tokens offline against cached +// JWKS. + +use std::time::Duration; + +/// Base URL of the tutorial Keycloak realm. Override with the +/// `CPEX_TUTORIAL_ISSUER` env var if you mapped Keycloak to another port. +pub fn issuer() -> String { + std::env::var("CPEX_TUTORIAL_ISSUER") + .unwrap_or_else(|_| "http://localhost:8081/realms/cpex-tutorial".into()) +} + +/// The realm's OIDC token endpoint. +pub fn token_endpoint() -> String { + format!("{}/protocol/openid-connect/token", issuer()) +} + +/// The realm's JWKS endpoint, where the JWT plugin fetches signing keys. +pub fn jwks_url() -> String { + format!("{}/protocol/openid-connect/certs", issuer()) +} + +/// Mint an access token for a persona via the OAuth password grant against +/// the tutorial realm. `client_id` is the public `cpex-tutorial` client. +/// +/// Returns the raw JWT string, ready to hand to +/// [`crate::Caller::with_token`]. Errors carry a human-readable hint so a +/// reader whose IdP isn't up sees "is the stack running?" rather than a +/// bare connection error. +pub async fn mint_token(username: &str, password: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + + let resp = client + .post(token_endpoint()) + .form(&[ + ("grant_type", "password"), + ("client_id", "cpex-tutorial"), + ("username", username), + ("password", password), + ("scope", "openid"), + ]) + .send() + .await + .map_err(|e| { + format!( + "could not reach the tutorial IdP at {} ({e}).\n \ + Is it running? Start it with:\n \ + docker compose -f examples/tutorial/idp/docker-compose.yml up -d", + token_endpoint() + ) + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!( + "token request for '{username}' failed ({status}): {body}" + )); + } + + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("token response was not JSON: {e}"))?; + json.get("access_token") + .and_then(|v| v.as_str()) + .map(str::to_string) + .ok_or_else(|| "token response had no access_token".into()) +} + +/// Poll the realm's discovery document until Keycloak answers or the +/// deadline passes. Modules call this in `--check` mode so CI waits for +/// the container to finish booting before minting tokens. +pub async fn wait_until_ready(max_wait: Duration) -> Result<(), String> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + let url = format!("{}/.well-known/openid-configuration", issuer()); + let deadline = tokio::time::Instant::now() + max_wait; + loop { + if let Ok(resp) = client.get(&url).send().await { + if resp.status().is_success() { + return Ok(()); + } + } + if tokio::time::Instant::now() >= deadline { + return Err(format!( + "tutorial IdP not ready at {url} after {max_wait:?}" + )); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } +} diff --git a/examples/tutorial/src/lib.rs b/examples/tutorial/src/lib.rs new file mode 100644 index 00000000..adf09520 --- /dev/null +++ b/examples/tutorial/src/lib.rs @@ -0,0 +1,29 @@ +// Location: ./examples/tutorial/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Shared harness for the CPEX tutorial. Each tutorial module is a binary +// under `examples/`; they all lean on this crate for: +// +// * `backends` , fake HR / repo / email tool implementations, so a +// module can focus on *policy*, not on standing up a +// real service. +// * `mediate` , the one function every module calls. It wraps the +// CPEX dispatch loop a host owns (resolve identity → +// pre-invocation policy → backend → post-invocation +// policy) behind a single call. See its "no magic" +// note: this is harness code, not a CPEX API. +// * `idp` , mint a token from the tutorial Keycloak realm. +// * `approvals`, a tiny HTTP approval channel for the elicitation +// module, driven from a second terminal with `curl`. +// * `ui` , banner / outcome printing shared by every module so +// the terminal recordings look consistent. + +pub mod approvals; +pub mod backends; +pub mod idp; +pub mod mediate; +pub mod ui; + +pub use mediate::{mediate, Caller, Outcome}; diff --git a/examples/tutorial/src/mediate.rs b/examples/tutorial/src/mediate.rs new file mode 100644 index 00000000..08a2c753 --- /dev/null +++ b/examples/tutorial/src/mediate.rs @@ -0,0 +1,318 @@ +// Location: ./examples/tutorial/src/mediate.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// `mediate()`, the tutorial's one-call wrapper around the enforcement +// loop a CPEX host owns. +// +// ┌──────────────────────────────────────────────────────────────────┐ +// │ NO MAGIC. This is HARNESS code, not a CPEX API. │ +// │ │ +// │ CPEX deliberately has no "mediate this tool call for me" function: │ +// │ CPEX is a library you embed at an enforcement point (a gateway, an │ +// │ MCP server, an agent framework), and *your host* owns the loop of │ +// │ resolving identity, running policy, calling the backend, and │ +// │ running policy again on the result. This file is that loop, ~1 │ +// │ screen of code, written once so the tutorial modules can focus on │ +// │ POLICY instead of re-typing it every time. │ +// │ │ +// │ Module 9 re-derives this loop hook-by-hook when we teach the │ +// │ dispatch API directly. Until then, read `mediate` below once and │ +// │ trust it. │ +// └──────────────────────────────────────────────────────────────────┘ +// +// The real dispatch surface it wraps: +// 1. `invoke_named::("identity.resolve", ...)` resolves a +// token into a subject (id, roles, permissions). Skipped when the +// caller is anonymous. +// 2. `invoke_named::("cmf.tool_pre_invoke", ...)` runs the APL +// Pre phase: `args` validation + `authorization.pre_invocation`. +// 3. the host calls the backend itself. +// 4. `invoke_named::("cmf.tool_post_invoke", ...)` runs the APL +// Post phase: the `result:` field pipeline (redact/mask) + +// `authorization.post_invocation`. + +use std::sync::Arc; + +use serde_json::{Map, Value}; + +use cpex::PluginManager; +use cpex_core::cmf::content::{ToolCall, ToolResult}; +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload}; +use cpex_core::executor::PipelineResult; +use cpex_core::extensions::{AgentExtension, Extensions, HttpExtension, MetaExtension}; +use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; + +/// Request header an agent echoes to resume a suspended elicitation. Its +/// value is the `elicitation_id` from a prior pending outcome. +const ELICITATION_ID_HEADER: &str = "X-Policy-Elicitation-Id"; + +/// Who is making the call. Built with [`Caller::anonymous`] (modules 0–1, +/// no IdP) or [`Caller::with_token`] (modules 2+, a Keycloak-minted JWT). +/// A `session_id` opts the call into cross-request session state, the +/// tainting module (7) relies on two calls sharing one session id. +#[derive(Debug, Clone, Default)] +pub struct Caller { + /// Bearer token (a JWT from the tutorial IdP). `None` = anonymous. + pub token: Option, + /// Session id for cross-request information flow. Session state only + /// keys off this when the caller is also authenticated (has a subject). + pub session_id: Option, + /// Elicitation id being resumed. Set on a retry after a prior call + /// returned [`Outcome::Pending`]; it is sent as the resume header so + /// policy checks the existing approval instead of opening a new one. + pub elicitation_id: Option, +} + +impl Caller { + /// An unauthenticated caller, no token. Used by module 1, where the + /// policy gates on structural predicates only. + pub fn anonymous() -> Self { + Self::default() + } + + /// A caller presenting a bearer token (a JWT). The identity plugin + /// validates it and resolves the subject before policy runs. + pub fn with_token(token: impl Into) -> Self { + Self { + token: Some(token.into()), + session_id: None, + elicitation_id: None, + } + } + + /// Attach a session id so this call shares information-flow state with + /// other calls using the same id (module 7). + pub fn in_session(mut self, session_id: impl Into) -> Self { + self.session_id = Some(session_id.into()); + self + } + + /// Resume a suspended operation by echoing the elicitation id from a + /// prior [`Outcome::Pending`] (module 8). + pub fn resuming(mut self, elicitation_id: impl Into) -> Self { + self.elicitation_id = Some(elicitation_id.into()); + self + } +} + +/// The result of mediating one operation. +#[derive(Debug, Clone)] +pub enum Outcome { + /// Policy allowed the call. `result` is the backend's response *after* + /// any `result:` pipeline transformations (redaction/masking) ran. + Allowed { result: Value }, + /// Policy denied the call, the backend never ran. `code` is the APL + /// reason code (e.g. `policy.deny`, `auth.token_expired`); `reason` is + /// the human-readable message. + Denied { code: String, reason: String }, + /// Policy suspended the call awaiting a human. The operation did not + /// run. Resume by approving out of band, then call again with + /// `Caller::resuming(elicitation_id)` (module 8). + Pending { + elicitation_id: String, + approver: String, + }, +} + +impl Outcome { + pub fn is_allowed(&self) -> bool { + matches!(self, Outcome::Allowed { .. }) + } + + pub fn is_pending(&self) -> bool { + matches!(self, Outcome::Pending { .. }) + } +} + +/// Mediate one tool call end-to-end through CPEX policy. +/// +/// `backend` is the function CPEX brackets: it runs only if the Pre phase +/// allows, and its output is fed through the Post phase before returning. +/// This mirrors a real host, where CPEX sits in front of the actual tool +/// and never calls it directly. +pub async fn mediate( + mgr: &Arc, + caller: &Caller, + tool: &str, + args: Value, + backend: F, +) -> Outcome +where + F: FnOnce(&Value) -> Value, +{ + // --- Base extensions: which entity is this, and (optionally) which + // session does it belong to. Meta drives APL route matching. --- + let mut ext = Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some(tool.into()), + ..Default::default() + })), + ..Default::default() + }; + if let Some(sid) = &caller.session_id { + ext.agent = Some(Arc::new(AgentExtension { + session_id: Some(sid.clone()), + ..Default::default() + })); + } + // Resuming a suspended elicitation: echo its id in the resume header so + // policy checks the existing approval instead of opening a new one. + if let Some(eid) = &caller.elicitation_id { + let mut http = HttpExtension::default(); + http.set_request_header(ELICITATION_ID_HEADER, eid); + ext.http = Some(Arc::new(http)); + } + + // --- Step 1: resolve identity (skipped for anonymous callers). The + // JWT plugin validates the token and returns a subject; we fold + // that subject into the extensions the policy phases will read. --- + if let Some(token) = &caller.token { + let (id_result, id_bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + IdentityPayload::new(token.clone(), TokenSource::Bearer), + ext.clone(), + None, + ) + .await; + id_bg.wait_for_background_tasks().await; + if !id_result.continue_processing { + return denied(&id_result); + } + if let Some(identity) = IdentityPayload::from_pipeline_result(&id_result) { + ext = identity.apply_to_extensions(ext); + } + } + + // --- Step 2: Pre phase, args validation + authorization.pre_invocation. --- + let pre_payload = MessagePayload { + message: tool_call_message(tool, &args), + }; + let (pre_result, pre_bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", pre_payload, ext.clone(), None) + .await; + pre_bg.wait_for_background_tasks().await; + if !pre_result.continue_processing { + // A suspended elicitation surfaces as a distinguished violation + // carrying the elicitation id, not an ordinary denial. + if let Some(v) = &pre_result.violation { + if v.code == "elicitation.pending" { + let id = v + .details + .get("elicitation_id") + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string(); + let approver = v + .details + .get("approver") + .and_then(|x| x.as_str()) + .unwrap_or("the approver") + .to_string(); + return Outcome::Pending { + elicitation_id: id, + approver, + }; + } + } + return denied(&pre_result); + } + // Carry any extension changes the Pre phase made (e.g. a delegated + // token, or session labels) into the Post phase. + let post_ext = pre_result.modified_extensions.clone().unwrap_or(ext); + + // --- Step 3: the host calls the backend. In production this is the + // real tool/service; here it is a fake in `backends.rs`. --- + let raw_result = backend(&args); + + // --- Step 4: Post phase, the `result:` pipeline (redact/mask) + + // authorization.post_invocation. The returned payload carries the + // transformed result, which is what the caller actually sees. --- + let post_payload = MessagePayload { + message: tool_result_message(tool, &raw_result), + }; + let (post_result, post_bg) = mgr + .invoke_named::("cmf.tool_post_invoke", post_payload, post_ext, None) + .await; + post_bg.wait_for_background_tasks().await; + if !post_result.continue_processing { + return denied(&post_result); + } + + // The Post phase may have rewritten the result (redaction). If it did, + // `modified_payload` holds the transformed message; otherwise the + // backend's original result stands. + let result = post_result + .modified_payload + .as_ref() + .and_then(|p| p.as_any().downcast_ref::()) + .and_then(|mp| mp.message.get_tool_results().into_iter().next()) + .map(|tr| tr.content.clone()) + .unwrap_or(raw_result); + + Outcome::Allowed { result } +} + +/// Turn a denied pipeline result into an [`Outcome::Denied`], pulling the +/// APL reason code and message out of the violation (with sane fallbacks +/// for the rare case where a phase halts without one). +fn denied(result: &PipelineResult) -> Outcome { + match &result.violation { + Some(v) => Outcome::Denied { + code: v.code.clone(), + reason: v.reason.clone(), + }, + None => Outcome::Denied { + code: "policy.deny".into(), + reason: "denied without a violation".into(), + }, + } +} + +/// Build a CMF message carrying a tool call (the Pre-phase payload). +fn tool_call_message(tool: &str, args: &Value) -> Message { + Message::with_content( + Role::User, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tutorial-call".into(), + name: tool.into(), + arguments: json_to_map(args), + namespace: None, + }, + }], + ) +} + +/// Build a CMF message carrying a tool result (the Post-phase payload). +fn tool_result_message(tool: &str, result: &Value) -> Message { + Message::with_content( + Role::Tool, + vec![ContentPart::ToolResult { + content: ToolResult { + tool_call_id: "tutorial-call".into(), + tool_name: tool.into(), + content: result.clone(), + is_error: false, + }, + }], + ) +} + +/// Flatten a JSON object into the `HashMap` shape `ToolCall.arguments` +/// wants. A non-object value is wrapped under a `"value"` key so callers +/// can pass a bare scalar without ceremony. +fn json_to_map(args: &Value) -> std::collections::HashMap { + match args { + Value::Object(map) => map.clone().into_iter().collect(), + other => { + let mut m = Map::new(); + m.insert("value".into(), other.clone()); + m.into_iter().collect() + }, + } +} diff --git a/examples/tutorial/src/ui.rs b/examples/tutorial/src/ui.rs new file mode 100644 index 00000000..7bbafd68 --- /dev/null +++ b/examples/tutorial/src/ui.rs @@ -0,0 +1,75 @@ +// Location: ./examples/tutorial/src/ui.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Console formatting shared by every tutorial module, so the output, and +// the terminal recordings made from it, look consistent. Nothing here is +// CPEX-specific; it just prints scenarios and outcomes tidily. + +use crate::mediate::Outcome; + +/// Print a module title banner. +pub fn module_banner(title: &str) { + println!("\n\x1b[1;36m=== {title} ===\x1b[0m\n"); +} + +/// Print a scenario sub-header (one per distinct call in a module). +pub fn scenario(label: &str) { + println!("\x1b[1m▸ {label}\x1b[0m"); +} + +/// Print an outcome with a colored ✓ / ✗ and, for allows, a compact +/// rendering of the (possibly redacted) result. +pub fn print_outcome(outcome: &Outcome) { + match outcome { + Outcome::Allowed { result } => { + let rendered = + serde_json::to_string(result).unwrap_or_else(|_| "".into()); + println!(" \x1b[32m✓ ALLOWED\x1b[0m {rendered}"); + }, + Outcome::Denied { code, reason } => { + println!(" \x1b[31m✗ DENIED\x1b[0m [{code}] {reason}"); + }, + Outcome::Pending { + elicitation_id, + approver, + } => { + println!( + " \x1b[33m⏸ PENDING\x1b[0m awaiting {approver}'s approval (id {elicitation_id})" + ); + }, + } + println!(); +} + +/// Assert an outcome matches what a scenario expects, for `--check` mode. +/// Returns `true` on match; prints a diff line and returns `false` +/// otherwise so the caller can accumulate a non-zero exit code. +pub fn expect(outcome: &Outcome, want_allowed: bool) -> bool { + let ok = outcome.is_allowed() == want_allowed; + if !ok { + let want = if want_allowed { "ALLOWED" } else { "DENIED" }; + println!(" \x1b[33m! CHECK FAILED\x1b[0m expected {want}, got {outcome:?}"); + } + ok +} + +/// Whether the module was run with `--check` (scripted assertion mode, +/// used by CI) rather than interactively. +pub fn check_mode() -> bool { + std::env::args().any(|a| a == "--check") +} + +/// Exit helper for `--check` runs: 0 if every assertion passed, 1 if any +/// failed. No-op in interactive mode. +pub fn finish_check(all_passed: bool) { + if check_mode() { + if all_passed { + println!("\x1b[32mAll checks passed.\x1b[0m"); + } else { + println!("\x1b[31mSome checks failed.\x1b[0m"); + std::process::exit(1); + } + } +} diff --git a/examples/tutorial/tests/policy_tests.rs b/examples/tutorial/tests/policy_tests.rs new file mode 100644 index 00000000..8e372ed4 --- /dev/null +++ b/examples/tutorial/tests/policy_tests.rs @@ -0,0 +1,109 @@ +// Location: ./examples/tutorial/tests/policy_tests.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Module 10, Testing your policy. +// +// cargo test -p cpex-tutorial +// +// These tests show how to unit-test APL without a live IdP or backend. +// You load a policy into a manager, drive routes through `mediate()` with +// a fake backend, and assert on the outcome. Table-driven cases keep the +// allow/deny matrix readable and make a new case one line. +// +// Anonymous callers are enough to exercise structural predicates +// (require(authenticated), argument guards, result pipelines), so these +// run in plain CI with no Keycloak. For identity-dependent rules you would +// mint tokens the way the module binaries do. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::{mediate, Caller, Outcome}; + +use serde_json::{json, Value}; + +/// Load a policy string into a ready-to-use manager. +async fn manager_with(policy: &str) -> Arc { + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(policy).expect("policy should load"); + mgr.initialize().await.expect("initialize"); + mgr +} + +/// A single table row: call `tool` with `args` and expect allow or deny. +struct Case { + tool: &'static str, + args: Value, + want_allowed: bool, + want_code: Option<&'static str>, +} + +async fn run_case(mgr: &Arc, c: &Case) { + let outcome = mediate(mgr, &Caller::anonymous(), c.tool, c.args.clone(), |a| { + backends::dispatch(c.tool, a) + }) + .await; + match (&outcome, c.want_allowed) { + (Outcome::Allowed { .. }, true) => {}, + (Outcome::Denied { code, .. }, false) => { + if let Some(want) = c.want_code { + assert_eq!(code, want, "wrong deny code for {}", c.tool); + } + }, + (got, _) => panic!( + "{}: expected allowed={}, got {:?}", + c.tool, c.want_allowed, got + ), + } +} + +const M01: &str = include_str!("../policies/m01.yaml"); +const M04: &str = include_str!("../policies/m04.yaml"); + +#[tokio::test] +async fn module1_gates_by_authentication() { + let mgr = manager_with(M01).await; + let cases = [ + Case { + tool: "get_compensation", + args: json!({ "employee_id": "e-1001" }), + want_allowed: false, + want_code: None, + }, + Case { + tool: "search_repos", + args: json!({ "visibility": "public" }), + want_allowed: true, + want_code: None, + }, + ]; + for c in &cases { + run_case(&mgr, c).await; + } +} + +#[tokio::test] +async fn module4_external_email_denied_with_custom_code() { + let mgr = manager_with(M04).await; + let cases = [ + Case { + tool: "send_email", + args: json!({ "to": "x@evil.example", "subject": "hi", "external": true }), + want_allowed: false, + want_code: Some("email.external_blocked"), + }, + Case { + tool: "send_email", + args: json!({ "to": "x@corp.example", "subject": "hi", "external": false }), + want_allowed: false, // anonymous, so require(authenticated) halts it + want_code: None, + }, + ]; + for c in &cases { + run_case(&mgr, c).await; + } +}