Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions docs/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ plugins: # the plugins available to policy, by kind
config: { ... }

global: # cross-cutting resolvers and stores
pdp:
- kind: ...
session_store:
kind: ...
apl:
pdp:
- kind: ...
session_store:
kind: ...

routes: # APL policy, a list of operations
- tool: <name> # or resource: / prompt: / llm:
Expand Down Expand Up @@ -72,18 +73,19 @@ plugins:

## Global

`global.pdp` registers PDP resolvers; `global.session_store` selects where taint labels live (absent it, the in-process memory store is used).
`global.apl.pdp` registers PDP resolvers; `global.apl.session_store` selects where taint labels live (absent it, the in-process memory store is used).

```yaml
global:
pdp:
- kind: cedar-direct
policy_text: |
permit(principal, action == Action::"read", resource is Repo)
when { principal.roles.contains("security") };
session_store:
kind: valkey
endpoint: localhost:6379
apl:
pdp:
- kind: cedar-direct
policy_text: |
permit(principal, action == Action::"read", resource is Repo)
when { principal.roles.contains("security") };
session_store:
kind: valkey
endpoint: localhost:6379
```

## Routes
Expand Down
11 changes: 11 additions & 0 deletions docs/content/docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ Picture one agent serving several people. It answers questions by calling tools

![One agent serves three users across HR, repo, and email backends; CPEX policy produces a different outcome per identity](images/demo_scenario.png)

The **R1–R6** labels in the diagram are the six requirements this scenario places on the enforcement point. Each is a control CPEX applies, and each maps to a later page:

| Label | Requirement | Where |
|-------|-------------|-------|
| R1 | Resolve the real user behind the agent | [Identity]({{< relref "/docs/apl/identity" >}}) |
| R2 | Same request, different data (redact per identity) | [Effects]({{< relref "/docs/apl/effects" >}}) |
| R3 | Enforce on inputs and results (validate args, shape output) | [APL]({{< relref "/docs/apl" >}}) |
| R4 | Delegate downstream with a narrower credential | [Delegation]({{< relref "/docs/apl/delegation" >}}) |
| R5 | Remember the session (carry state across calls) | [Session Tainting]({{< relref "/docs/apl/tainting" >}}) |
| R6 | Out-of-band elicitations (human approval) | [Elicitation]({{< relref "/docs/apl/elicitation" >}}) |

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
Expand Down
2 changes: 2 additions & 0 deletions docs/content/docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ weight: 20

This walks through standing up CPEX as an enforcement point and running the [scenario]({{< relref "/docs/overview" >}}): the `get_employee` route that authorizes by role and redacts a field by permission.

You need Rust 1.96 or newer ([install with rustup](https://rustup.rs)). Section 4 runs the tutorial's first module, which lives in the repo, so clone it first: `git clone https://github.com/contextforge-org/cpex.git && cd cpex`, and run `cargo` commands from that root.

## 1. Add CPEX

```bash
Expand Down
12 changes: 12 additions & 0 deletions docs/content/docs/tutorial/00-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ CPEX is a policy enforcement runtime for AI agents. It is a deterministic refere

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.

## Get the code

The tutorial runs from a clone of the CPEX repository (not from a published crate), so you can edit the policies and examples as you go.

- Install Rust 1.96 or newer with [rustup](https://rustup.rs) (gives you `rustc` and `cargo`).
- Clone the repo and move into it:
```bash
git clone https://github.com/contextforge-org/cpex.git
cd cpex
```
- Run every `cargo run -p cpex-tutorial ...` command from the repository root.

## Check your setup

```bash
Expand Down
4 changes: 2 additions & 2 deletions docs/content/docs/tutorial/01-hello.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ Same anonymous caller, same host code. The route decided the outcome, and the de

## 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.
1. Change the failing predicate. In `examples/tutorial/policies/m01.yaml`, change `require(authenticated)` to `require(role.hr)` and re-run. Expect: `get_compensation` still denies, because the anonymous caller has no role either. The displayed reason is the same generic, position-based code (`...pre_invocation[0]] access denied`); what changed is which predicate rejected the call, not the visible text.
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.
3. Gate the open route. `get_compensation` already denies; add `- "require(authenticated)"` under `search_repos` too, leaving `get_compensation` unchanged, and re-run. Expect: both calls now deny.

Reset any time with `git checkout -- examples/tutorial/policies`.

Expand Down
17 changes: 12 additions & 5 deletions docs/content/docs/tutorial/02-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ routes:

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).
The snippet is abbreviated; the file also sets `role`, `header`, and the JWKS `refresh_secs`. The tutorial personas: alice (hr and `view_ssn`), dana (hr, no `view_ssn`), evan (engineer), sam (security).

## Run it

Expand All @@ -66,13 +66,20 @@ cargo run -p cpex-tutorial --example m02_identity
✗ 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.
The harness *mints* each persona's token, meaning it obtains a signed JWT for them 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.
1. Swap personas. In `examples/tutorial/examples/m02_identity.rs`, replace the `evan` scenario with `dana` (hr, no `view_ssn`). Three edits: change the token line to `let dana = idp::mint_token("dana", "dana")...` (rename the `evan` binding to `dana`), update the `ui::scenario("evan (engineer) → ...")` label to `dana (hr) → get_compensation`, and pass `&dana` to that `mediate(...)` call. Re-run. Expect: dana is allowed too (she is hr); the `view_ssn` difference does not matter until module 3.
2. Break the audience. In `policies/m02.yaml`, change `audiences: [cpex-tutorial]` to `[some-other-api]` and re-run. Expect: alice and evan now deny with `auth.audience_mismatch`. The garbage token still fails earlier at `auth.malformed_header`: it is not a valid JWT, so audience is never checked.
3. Inspect a token. Mint one by hand and decode its claims (works on Linux and macOS, jq only):
```bash
TOKEN=$(curl -s 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 | jq -Rr '. + "=="[:(4 - length % 4) % 4] | @base64d' | jq .
```
Look for the flat `roles`, `permissions`, and `aud` claims the realm's mappers produced. See [`idp/README.md`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/idp) for all the personas and their passwords.

## Checkpoint

Expand Down
15 changes: 12 additions & 3 deletions docs/content/docs/tutorial/03-shaping.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,18 @@ The call is allowed, so the record still comes back, but `ssn` and `salary` are

## 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.
1. Redact another field. In `examples/tutorial/policies/m03.yaml`, add a line under `result:` such as `name: "str | redact(!perm.view_ssn)"`, then re-run. Expect: `name` now comes back `[REDACTED]` too. The pipeline transforms exactly the fields you name.
2. See the full record with identity. The default run is anonymous, so every redaction fires (no caller has `view_ssn`). To see the per-permission contrast, give the route an identity, the same way module 2 does. This is a two-file change:
- In `examples/tutorial/policies/m03.yaml`, add the `keycloak` identity plugin and reference it from the route. Copy the top-level `plugins:` block and the `authentication:` line from `policies/m02.yaml` (module 3's policy has no identity plugin on its own, which is why minting a token alone changes nothing).
- In `examples/tutorial/examples/m03_shaping.rs`, add `use cpex_tutorial::idp;` near the other imports and replace `let caller = Caller::anonymous();` with:
```rust
let caller = match idp::mint_token("alice", "alice").await {
Ok(t) => Caller::with_token(t),
Err(e) => { eprintln!("{e}"); std::process::exit(1); }
};
```
- Start the IdP and re-run. Expect: alice (`view_ssn`) now sees the full `ssn` and `salary`, with `employee_id` still masked. Swap `"alice"` for `"dana"` and `ssn` is redacted again (she lacks `view_ssn`) while `salary` stays visible. Same policy, different caller. This is the contrast the [capstone]({{< relref "capstone" >}}) runs end to end.
3. Change the mask width. Set `employee_id: "str | mask(2)"` and re-run. Expect: only the last two characters survive.

## Checkpoint

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/tutorial/04-effects.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,4 @@ For a hand-written guard, the second argument to `deny('reason', 'code')`. For a

## 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.
[Module 5: Delegating decisions]({{< relref "05-pdp" >}}): hand a decision to CEL or Cedar and let CPEX enforce the verdict.
2 changes: 1 addition & 1 deletion docs/content/docs/tutorial/05-pdp.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ One expression captured a rule that mixes role and argument. The PDP decided, an
## 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.
2. Why the guards matter. Remove the `has(role.engineer)` guard from the expression and re-run. Expect: sam is still allowed. `role.security` is true, and CEL's `||` yields true even though the other side now errors on the unset `role.engineer` (CEL absorbs an error when the other operand is true). The guard exists so the expression stays well-defined for every caller: a caller who is neither engineer nor security (an `hr` user like alice) would hit that evaluation error with nothing to absorb it and be denied fail-closed.
3. Change the deny code. Edit the `on_deny` code and confirm the reason code changes.

## Checkpoint
Expand Down
4 changes: 2 additions & 2 deletions docs/content/docs/tutorial/06-delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ alice's call runs a real token exchange and gets a `workday-api`-scoped token be

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).
3. Narrow the grant (advanced). In `policies/m06.yaml`, add a `permissions:` argument to the `delegate(...)` step so it reads `delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])`. The exchange then requests only that scope. For Keycloak to actually issue it, the `read_compensation` client scope must exist on the realm and be assigned to the `workday-api` client (see [`idp/README.md`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/idp)); without that realm setup the exchange returns the default scope. This is how you narrow the downstream grant to exactly what the operation needs.

## Checkpoint

Expand All @@ -89,4 +89,4 @@ Keycloak mints the scoped token during the exchange, constrained by the requeste

## Next

[Module 8: Human in the loop]({{< relref "08-elicitation" >}}) (or the capstone, which reassembles all three backends).
[Module 7: Information flow]({{< relref "07-tainting" >}}): carry security state across a session to block write-down.
17 changes: 14 additions & 3 deletions docs/content/docs/tutorial/07-tainting.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,20 @@ The same `send_email` call is allowed in a clean session and denied in the sessi

## 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.
1. Separate the sessions. In `examples/tutorial/examples/m07_tainting.rs`, change the third call's `.in_session("session-hr-work")` to a fresh id like `.in_session("session-new")` and re-run. Expect: the email allows again, because taint is per session.
2. Rename the label. In `examples/tutorial/policies/m07.yaml`, change `taint(secret, session)` to `taint(pii, session)` and update the guard `security.labels contains "secret"` to `"pii"`. Re-run. Expect: the write-down still blocks, now keyed on the `pii` label. Labels are arbitrary names you choose; the write and the read just have to agree.
3. Persist across restarts. The default store is in-memory and resets when the program exits. To back it with Valkey:
- Start Valkey: `docker compose -f examples/tutorial/idp/docker-compose.yml --profile valkey up -d`.
- Enable the feature: in `examples/tutorial/Cargo.toml`, change the `cpex` dependency to `features = ["builtins", "valkey"]`.
- In `policies/m07.yaml`, add a session store under `global`:
```yaml
global:
apl:
session_store:
kind: valkey
endpoint: localhost:6379
```
Re-run: the taint now lives in Valkey and survives a process restart.

## Checkpoint

Expand Down
12 changes: 7 additions & 5 deletions docs/content/docs/tutorial/08-elicitation.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ match payload.operation() {
}
```

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.
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. CIBA (Client-Initiated Backchannel Authentication, [OpenID spec](https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html)) is the OpenID flow where the app asks the identity provider to prompt a user on a separate device and polls for their decision; see [Human-in-the-Loop Elicitation]({{< relref "/docs/apl/elicitation" >}}) for how CPEX drives it. The point here is CPEX's suspend and resume model, not the notification transport.

## Run it

Expand All @@ -66,22 +66,24 @@ The first attempt suspends:
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:
Run that curl in a second terminal. You do not re-run anything: the same program is polling the approval channel in a loop, so a moment after your curl (it polls on an interval, so allow a few seconds) it picks up the decision and resumes on its own:

```
▸ evan → send_email (retry with the approval: resumes and runs)
✓ ALLOWED {"sent":true, ...}
```

Here "retry" means that automatic re-check inside the running program, not a second `cargo run`. In a real agent it is the agent re-sending the request with the elicitation id; the tutorial harness does it for you.

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.
1. Deny instead. Use `curl -X POST localhost:8090/approvals/elic-mona/deny`. Expect: the program's next poll picks up the denial (again allow a few seconds), and the call ends 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.
3. Let it stay pending. Do nothing. The program keeps polling and the operation never runs; it only proceeds once someone approves. There is no manual retry step, the running program resumes itself.

## Checkpoint

Expand All @@ -99,4 +101,4 @@ The first attempt returns an elicitation id. The caller echoes it on retry (the

## Next

The capstone reassembles all three backends and every control into the full scenario.
[Module 9: Write your own plugin]({{< relref "09-custom-plugin" >}}): build a custom plugin with the SDK and reference it from policy.
Loading
Loading