diff --git a/README.md b/README.md index c43839e..90c3614 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,21 @@ -# demos -Archive of demos and setups for demos with Praxis +# Praxis demos + +Runnable, self-contained demos and setups for [Praxis](https://github.com/praxis-proxy/praxis). +Each demo lives under `demos//` with its own README and bring-up script. + +## Demos + +| Demo | Description | +|------|-------------| +| [cpex](demos/cpex/) | End-to-end CPEX policy enforcement for MCP traffic: multi-source JWT identity, APL routes with a Cedar or CEL PDP, RFC 8693 token exchange, on-the-wire redaction, PII scanning, audit, and Valkey-backed session taint. Keycloak IdP, a mock MCP server, curl scenarios, and an LLM chat client. | + +## Layout + +```text +demos/ + / + README.md # what it shows and how to run it + ... # configs, scripts, and any services +``` + +Each demo is independent. Start from its README. diff --git a/demos/cpex/.gitignore b/demos/cpex/.gitignore index 5051e8a..f77c776 100644 --- a/demos/cpex/.gitignore +++ b/demos/cpex/.gitignore @@ -3,3 +3,9 @@ # gateway stdout/stderr captured by restart.sh gateway.log + +# Python virtualenv + caches for the agent/ chat client +agent/.venv/ +__pycache__/ +*.pyc +.env diff --git a/demos/cpex/README.md b/demos/cpex/README.md index 2d0ee74..b30c834 100644 --- a/demos/cpex/README.md +++ b/demos/cpex/README.md @@ -1,7 +1,13 @@ # CPEX HR Demo -End-to-end demo of **Praxis** with the feature-gated **`cpex`** filter, -**Keycloak** as the OIDC IdP, and a mock **MCP server**. It exercises the full +An agent that can call tools can also leak data, exceed a user's privileges, or +act on a credential it should never hold. This demo puts **Praxis** between the +agent and its tools as an identity-aware control point. One policy layer decides +who can call what, what data comes back, and where that data is allowed to go +next. + +It is an end-to-end setup of **Praxis** with the feature-gated **`cpex`** filter, +**Keycloak** as the OIDC IdP, and a mock **MCP server**, exercising the full CPEX/APL (Authorization Policy Logic) stack: - multi-source identity (user, agent, and workload JWTs in separate headers, @@ -14,9 +20,15 @@ CPEX/APL (Authorization Policy Logic) stack: - structured audit emission - session taint (cross-tool, cross-request data-flow control) -## The story +## Scenario -Three personas carry it: +

+ Demo scenario +

+ +A single agent serves three people and reaches three backends over the Model +Context Protocol: HR records, code repositories, and email. Everyone talks to the +same agent, and the agent calls the same tools. Identity decides the outcome. | Persona | Identity | Result | |---|---|---| @@ -24,23 +36,45 @@ Three personas carry it: | Eve | HR, no `view_ssn` | Same record, SSN redacted | | Alice | Engineering | Denied HR tools; allowed internal repos, denied external | -Bob and Eve send the byte-for-byte same `get_compensation` request and the -backend returns the same record, but Eve's response comes back without the SSN -because the policy redacts it. Bob's request reaches the backend with a freshly -minted, audience-scoped token, never his original IdP JWT. +Two outcomes make the value concrete: + +- **Same request, different data.** Bob and Eve send the byte-for-byte same + `get_compensation` request and the backend returns the same record. Eve's + response comes back without the SSN because the policy redacts it on the wire. + The tool never makes that call; Praxis does. Bob's request reaches the backend + with a freshly minted, audience-scoped token, never his original IdP JWT. +- **Data flow follows the conversation.** Once a session has touched compensation + data, Praxis stops that session from sending external email, even when the email + body is clean. The taint travels with the session, not the message. + +## Architecture + +

+ Demo architecture +

+ +The agent never talks to a tool directly. Every call passes through Praxis, which +authenticates the caller against Keycloak, runs the policy, and only then forwards +a scoped request to the MCP tool. In a single pass it: -## What runs where +- resolves identity from the user, agent, and workload tokens +- runs the APL gate and a PDP (Cedar or CEL) for relationship-based decisions +- exchanges the user token for an audience-scoped backend token (RFC 8693) +- redacts sensitive fields and scans arguments for PII +- tracks session taint and emits a structured audit record + +### What runs where ```text +------------------------------------------------------------------+ | host | | | | praxis (--features cpex) :8090 | -| filter: mcp parse JSON-RPC, set mcp.method/name | -| filter: cpex identity + APL + PDP + delegation + | -| PII + audit + taint + body rewrite | -| filter: router forward / to the hr-mcp upstream | -| filter: load_balancer single-endpoint cluster | +| filter: mcp parse JSON-RPC, set mcp.method/name | +| filter: cpex identity + APL + PDP + delegation + | +| PII + audit + taint + body rewrite | +| filter: router forward / to the hr-mcp upstream | +| filter: load_balancer single-endpoint cluster | +------------------------------------------------------------------+ ^ v chat / curl hr-mcp-server (Python, docker) @@ -49,10 +83,12 @@ minted, audience-scoped token, never his original IdP JWT. +------------------------------------------------------------------+ | docker compose | -| keycloak cpex-demo realm: bob/alice/eve users; praxis-gateway | -| / workday-api / github-api clients; STE v2 | -| hr-mcp mock MCP server: get_compensation, send_email, | -| search_repos | +| keycloak cpex-demo realm: bob/alice/eve users; praxis-gateway | +| / workday-api / github-api clients; STE v2 | +| hr-mcp mock MCP server: get_compensation, send_email, | +| search_repos | +| valkey :6379, CPEX session store: taint labels keyed by | +| H(subject:session_id), durable across gateway restart| +------------------------------------------------------------------+ ``` @@ -60,7 +96,7 @@ minted, audience-scoped token, never his original IdP JWT. - Docker daemon running (Docker Desktop, Rancher Desktop, or Colima) - Rust toolchain (whatever praxis's `rust-version` requires) -- Ports `8081`, `8090`, `9100` free on localhost +- Ports `8081`, `8090`, `9100`, `6379` free on localhost ## Quick start @@ -77,7 +113,7 @@ The equivalent steps, spelled out: ```bash GATEWAY_BIN="$(./build-praxis.sh)" # build the cpex gateway, print its path -docker compose up -d # Keycloak + mock MCP server +docker compose up -d # Keycloak + mock MCP server + valkey ./verify-token-exchange.sh # wait for the realm import, check STE v2 "$GATEWAY_BIN" -c ./praxis.yaml & # start the gateway ./walkthrough.sh # narrated tour of the core scenarios @@ -150,7 +186,7 @@ Both PDP backends are compiled into the same binary. The config's runs. The CEL step also shows an `on_deny:` reaction attaching a human reason and a stable violation code; `on_deny` and `on_allow` work on any PDP step. -## Session taint (scenarios 08 and 09) +## Session taint `get_compensation` runs `taint(secret, session)`, attaching the label `secret` to the session. `send_email` then refuses to send when the session carries it: @@ -184,9 +220,34 @@ not the content, which is what separates it from scenario 07's content-based PII deny. Scenario 09 shows the taint cannot cross principals. Tainting is independent of the PDP, so 08 and 09 behave the same under both -`cpex.yaml` and `cpex-cel.yaml`. The session store is in-memory and per process: -taint resets when the gateway restarts, and the scenarios use fresh per-run -session ids so reruns start clean. +`cpex.yaml` and `cpex-cel.yaml`. + +### Where taint is stored + +Both configs point `global.session_store` at Valkey: + +```yaml +global: + session_store: + kind: valkey + endpoint: localhost:6379 +``` + +So labels live in the `valkey` container (keys under the `taint:v1` prefix), +not in the gateway process. Taint survives a gateway restart and can be shared +across gateway instances. Inspect or clear it directly: + +```bash +docker compose exec valkey valkey-cli keys 'taint:v1:*' # one key per tainted session +docker compose exec valkey valkey-cli flushall # clear all taint +``` + +To see persistence across a restart: run scenario 08 step 2 (the +`get_compensation` that taints), restart only the gateway (do not run +`restart.sh`, which wipes the containers), then send the step-3 `send_email` on +the same session id. It is still denied. Drop the `session_store` block to fall +back to the in-process store, which resets on restart. The scenarios use fresh +per-run session ids either way, so reruns start clean. ## Notes @@ -208,7 +269,7 @@ the padding, so the wire stays correct. This is documented in the filter source. | `cpex.yaml` | CPEX policy: plugins, routes, Cedar PDP policy text | | `praxis-cel.yaml` | Same listener as `praxis.yaml`, loads `cpex-cel.yaml`. Run via `GATEWAY_CONFIG=praxis-cel.yaml` | | `cpex-cel.yaml` | CEL variant: `search_repos` uses an inline `cel:` expression, no `apl:` wrapper | -| `docker-compose.yml` | Keycloak (8081) and hr-mcp (9100) | +| `docker-compose.yml` | Keycloak (8081), hr-mcp (9100), and valkey (6379) | | `keycloak/realm-export.json` | Realm with users, clients, and STE v2 | | `hr-mcp-server/` | Python mock MCP server (Dockerfile and `server.py`) | | `scenarios/*.sh` | The nine scenarios (including 08 and 09 session taint) and `_lib.sh` helpers | @@ -227,4 +288,4 @@ behind the `cpex` Cargo feature on `praxis-proxy-filter`. That feature registers both the Cedar (`apl-pdp-cedar-direct`) and CEL (`apl-pdp-cel`) PDP backends, so one binary serves both `cpex.yaml` and `cpex-cel.yaml`. See the filter's own README there for configuration and internals. -``` + diff --git a/demos/cpex/agent/chat.py b/demos/cpex/agent/chat.py index 7f6d21b..bcc4d5b 100755 --- a/demos/cpex/agent/chat.py +++ b/demos/cpex/agent/chat.py @@ -38,8 +38,8 @@ pip install -r requirements.txt # No API keys required — default points at a local Ollama with - # llama3.1. Install Ollama (https://ollama.com) and `ollama pull - # llama3.1` first. + # llama3. Install Ollama (https://ollama.com) and `ollama pull + # llama3` first. python chat.py --persona bob # Or use any LiteLLM-supported provider via env: @@ -75,7 +75,7 @@ # Defaults # --------------------------------------------------------------------------- -DEFAULT_MODEL = "ollama/llama3.1" # local, no API key required +DEFAULT_MODEL = "ollama/qwen3:8b" # local, no API key required DEFAULT_GATEWAY = "http://localhost:8090/mcp" DEFAULT_KEYCLOAK = "http://localhost:8081" KEYCLOAK_REALM = "cpex-demo" @@ -118,14 +118,26 @@ "employee compensation, view directories, send emails, and similar " "tasks. Use the provided tools when needed. " "\n\n" + "Only request data the user actually asked for: in particular, set " + "get_compensation's `include_ssn` to true ONLY when the user explicitly " + "asks to include/show the SSN. If the user just asks to look up " + "compensation without mentioning the SSN, leave `include_ssn` false. " + "\n\n" + "CRITICAL — relay tool data verbatim: when you present a field, copy its " + "value EXACTLY as it appears in the tool result. Never invent, mask, " + "redact, or replace a value yourself. Only write `[REDACTED]` for a field " + "if the tool result's value for that field is literally the string " + "`[REDACTED]`; when the tool returns a real value (for example an actual " + "social-security number), show that exact value unchanged. The gateway — " + "not you — decides what to hide; your job is to relay precisely what it " + "returned. " + "\n\n" "How to interpret tool results: " "\n" - " * If the tool returns a normal result, present the data to the " - "user. If any field's value is the literal string `[REDACTED]`, " - "show it as-is in your answer — that is the gateway's transparent " - "enforcement marker that the field exists but is hidden for this " - "caller. Do NOT apologize or refuse; just include the field with " - "the value `[REDACTED]`. " + " * Normal result: present the data, copying each value verbatim per the " + "rule above. A field whose value is `[REDACTED]` is the gateway's " + "transparent enforcement marker (the field exists but is hidden for this " + "caller) — show it as-is; do NOT apologize or refuse. " "\n" " * If the tool returns an `error` envelope (a JSON-RPC error " "with a `code` and `message`), the gateway denied the call. " diff --git a/demos/cpex/cpex-cel.yaml b/demos/cpex/cpex-cel.yaml index 2c20438..c7a55dc 100644 --- a/demos/cpex/cpex-cel.yaml +++ b/demos/cpex/cpex-cel.yaml @@ -160,13 +160,8 @@ plugins: - write_delegated_tokens config: token_endpoint: "http://localhost:8081/realms/cpex-demo/protocol/openid-connect/token" - insecure_http: true # localhost dev — never production + insecure_http: true client_id: "praxis-gateway" - # Secret source. `kind: literal` is an inline secret (dev only). - # Other kinds: `kind: env_var` with `name:` (read from the - # environment at startup) and `kind: file` with `path:` (read from - # a file). Prefer env_var or file in production; do not commit - # secrets to YAML. client_secret_source: kind: literal secret: "praxis-gateway-secret" @@ -200,124 +195,59 @@ global: - kind: cel on_error: deny # fail-closed; the default, spelled out -routes: # ---------------------------------------------------------------- - # WORKDAY FLOW — Pattern 1 (centralized IdP knows all perms). - # User token carries view_ssn directly; APL gates on a flat - # role/perm predicate + redacts ssn on the wire if missing. + # Session store — where CPEX persists taint labels. # ---------------------------------------------------------------- + # `taint(secret, session)` on get_compensation writes a `secret` + # label keyed by H(subject : X-Session-Id); send_email reads it back + # via `security.labels contains "secret"`. With this Valkey backend + # the label survives a gateway restart and can be shared across + # gateway instances (the default is an in-process store that resets + # on restart). Valkey runs as the `valkey` compose service; the + # gateway reaches it on localhost:6379. See scenarios 08 / 09. + # + # This is a flat APL key (like `pdp:` above): no `apl:` wrapper. See + # the NOTE above on the flat form. + # + # Optional keys (read by the valkey factory): + # key_prefix: "taint:v1" # default key namespace + # tls: true # required for non-localhost endpoints + # username: / password: # Valkey 6+ ACL auth (pair them) + # ttl_seconds: 86400 # sliding label expiry (default: none) + # connect_timeout_ms / command_timeout_ms # defaults 250 / 500 + session_store: + kind: valkey + endpoint: localhost:6379 + +routes: + - tool: get_compensation policy: - "require(role.hr)" - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" - # Taint the session: reading compensation marks this session as - # having touched secret data. The label persists in the CPEX - # session store (keyed by H(subject : X-Session-Id)) and is - # checked later by send_email — a cross-tool, cross-request - # data-flow control. See scenarios 08 / 09. - "taint(secret, session)" - # Emit a structured audit record AFTER the policy + delegate - # decisions are in. Records who called what, with which scope. - "run(audit-log)" args: - # `redact(!perm.view_ssn)` fires when the caller LACKS the - # permission. The arg key remains, value becomes "[REDACTED]" — - # downstream tool can see "ssn was here but you can't read it." ssn: "str | redact(!perm.view_ssn)" result: - # Symmetric guard on the response side. The tool may include - # the SSN in its result (it does, when include_ssn=true); - # without this rule, a caller with role.hr but NOT - # perm.view_ssn (e.g. eve) would receive it. Redacting on the - # response closes the loop — the LLM-driven flow doesn't even - # require the caller to mention `ssn` in args for the guard - # to fire, because the SSN comes back unsolicited in the - # upstream tool's response. ssn: "str | redact(!perm.view_ssn)" - # ---------------------------------------------------------------- - # GITHUB FLOW — Pattern 3 (IdP per-audience mapping + CEL PDP). - # - # Four authorization layers in one route: - # - # 1. APL gate — cheap predicate. Short-circuits requests - # from users who can't ever read repos. - # 2. CEL PDP — inline boolean over subject roles and the - # requested repo's visibility. The CEL form of - # the Cedar relationship rule in cpex.yaml. - # 3. Delegation — RFC 8693 to github-api audience. Keycloak's - # per-audience mapper injects gh_permissions - # into the `permissions` claim. - # 4. Post-check — verify the IdP actually granted enough. - # If the IdP narrowed the scope, refuse to - # forward — the token never reaches GitHub. - # ---------------------------------------------------------------- - tool: search_repos policy: - # Layer 1 — coarse APL gate on group membership. - "require(team.engineering | team.security)" - - # Layer 2 — CEL fine-grained authorization. `subject.roles` - # is the principal's role list; `args.visibility` is the - # requested repo's visibility (lifted from the tool args). - # Engineers may read internal repos; security may read any. - cel: expr: | (has(role.engineer) && role.engineer && args.visibility == "internal") || (has(role.security) && role.security) - # on_deny reactions run when the expression is false. Without - # this block the PDP still fail-closes (violation `cel`), just - # like the Cedar step's `cedar.default_deny`; here we attach a - # clearer reason so the client sees WHY. NOTE the quoting: the - # whole reaction is a double-quoted YAML scalar (so the `;` and - # spaces stay in one string) and the reason is single-quoted - # per the APL DSL — `deny('reason')` / `deny('reason', 'code')`. on_deny: - "deny('engineering may read internal repos only; security may read any', 'cel.policy_denied')" - - # Layer 3 — Token exchange. Keycloak's github-api mapper - # injects the user's gh_permissions as the `permissions` - # claim on the minted token. - "delegate(github-oauth, target: github-api, audience: github-api, permissions: [repo:read:internal])" - - # Layer 4 — Verify the IdP granted enough. If the user's - # gh_permissions attribute didn't include repo:read:internal, - # the minted token comes back narrower than asked. Deny here - # rather than forwarding an insufficient token. - "!(delegation.granted.permissions contains 'repo:read:internal'): deny" - - # Audit every github call. - "run(audit-log)" - # ---------------------------------------------------------------- - # SEND_EMAIL — PII scanner gate. - # - # The interesting flow: when the LLM composes an email body, it - # might include raw PII pulled from earlier tool responses ("Bob's - # SSN is 555-12-3456"). The PII scanner catches that before the - # email goes out. Operators see the deny in real time on stderr - # via the audit logger. - # ---------------------------------------------------------------- - tool: send_email policy: - "require(perm.email_send)" - # audit-log is ordered BEFORE pii-scan deliberately. A policy - # step that denies short-circuits the chain, so any step after - # the deny never runs. Placing the observation first guarantees - # the attempt is recorded even though the very next step is - # about to block it — a denied data-exfiltration attempt is - # precisely what a security audit trail must capture. audit-log - # is observation-only (it cannot allow or deny), so running it - # first never changes the verdict; the emitted record carries - # the offending args, including the SSN-bearing body. - "run(audit-log)" - # Walks args.body / args.subject / args.to and denies if any - # carries an SSN-like or credit-card-like pattern. - "run(pii-scan)" - # Session-taint gate: block email from a session that previously - # accessed secret data (get_compensation runs taint(secret, - # session)). Distinct from pii-scan above — that catches PII in - # the body; this denies even a CLEAN body because the *session* - # is tainted. The label is read from `security.labels`, hydrated - # from the session store on this request. See scenarios 08 / 09. - "security.labels contains \"secret\": deny('external email blocked: this session accessed secret data', 'session_tainted_secret')" diff --git a/demos/cpex/cpex.yaml b/demos/cpex/cpex.yaml index ff35aba..39bb039 100644 --- a/demos/cpex/cpex.yaml +++ b/demos/cpex/cpex.yaml @@ -154,13 +154,8 @@ plugins: - write_delegated_tokens config: token_endpoint: "http://localhost:8081/realms/cpex-demo/protocol/openid-connect/token" - insecure_http: true # localhost dev — never production + insecure_http: true client_id: "praxis-gateway" - # Secret source. `kind: literal` is an inline secret (dev only). - # Other kinds: `kind: env_var` with `name:` (read from the - # environment at startup) and `kind: file` with `path:` (read from - # a file). Prefer env_var or file in production; do not commit - # secrets to YAML. client_secret_source: kind: literal secret: "praxis-gateway-secret" @@ -172,19 +167,6 @@ global: - jwt-user - jwt-client - # ---------------------------------------------------------------- - # Cedar PDP — declarative authorization for relationship-based - # decisions. The Cedar policies live inline below. APL routes - # call into Cedar via `cedar:` steps and translate decisions - # into allow/deny in the broader policy pipeline. - # - # Why Cedar here: pure APL predicates like `require(role.engineer)` - # are great for flat principal attributes. Cedar shines when the - # decision needs to relate principal attributes to resource - # attributes (e.g. "engineers can read repos when the repo's - # visibility is internal"). The two compose naturally — APL handles - # the orchestration around Cedar's discrete decisions. - # ---------------------------------------------------------------- pdp: - kind: cedar-direct dialect: cedar @@ -215,67 +197,42 @@ global: // Default-deny otherwise. Cedar's defaults make this // implicit but spelling it out documents intent. -routes: # ---------------------------------------------------------------- - # WORKDAY FLOW — Pattern 1 (centralized IdP knows all perms). - # User token carries view_ssn directly; APL gates on a flat - # role/perm predicate + redacts ssn on the wire if missing. + # Session store — where CPEX persists taint labels. # ---------------------------------------------------------------- + # `taint(secret, session)` on get_compensation writes a `secret` + # label keyed by H(subject : X-Session-Id); send_email reads it back + # via `security.labels contains "secret"`. With this Valkey backend + # the label survives a gateway restart and can be shared across + # gateway instances (the default is an in-process store that resets + # on restart). Valkey runs as the `valkey` compose service; the + # gateway reaches it on localhost:6379. See scenarios 08 / 09. + # + # Optional keys (read by the valkey factory): + # key_prefix: "taint:v1" # default key namespace + # tls: true # required for non-localhost endpoints + # username: / password: # Valkey 6+ ACL auth (pair them) + # ttl_seconds: 86400 # sliding label expiry (default: none) + # connect_timeout_ms / command_timeout_ms # defaults 250 / 500 + session_store: + kind: valkey + endpoint: localhost:6379 + +routes: - tool: get_compensation policy: - "require(role.hr)" - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" - # Taint the session: reading compensation marks this session as - # having touched secret data. The label persists in the CPEX - # session store (keyed by H(subject : X-Session-Id)) and is - # checked later by send_email, a cross-tool, cross-request - # data-flow control. See scenarios 08 / 09. - "taint(secret, session)" - # Emit a structured audit record AFTER the policy + delegate - # decisions are in. Records who called what, with which scope. - "run(audit-log)" args: - # `redact(!perm.view_ssn)` fires when the caller LACKS the - # permission. The arg key remains, value becomes "[REDACTED]"; - # downstream tool can see "ssn was here but you can't read it." ssn: "str | redact(!perm.view_ssn)" result: - # Symmetric guard on the response side. The tool may include - # the SSN in its result (it does, when include_ssn=true); - # without this rule, a caller with role.hr but NOT - # perm.view_ssn (e.g. eve) would receive it. Redacting on the - # response closes the loop. The LLM-driven flow doesn't even - # require the caller to mention `ssn` in args for the guard - # to fire, because the SSN comes back unsolicited in the - # upstream tool's response. ssn: "str | redact(!perm.view_ssn)" - # ---------------------------------------------------------------- - # GITHUB FLOW — Pattern 3 (IdP per-audience mapping + Cedar PDP). - # - # Four authorization layers in one route: - # - # 1. APL gate — cheap predicate. Short-circuits requests - # from users who can't ever read repos. - # 2. Cedar PDP — relationship between principal role and - # resource attributes (repo visibility). - # This is what pure flat predicates struggle - # with and what Cedar shines at. - # 3. Delegation — RFC 8693 to github-api audience. Keycloak's - # per-audience mapper injects gh_permissions - # into the `permissions` claim. - # 4. Post-check — verify the IdP actually granted enough. - # If the IdP narrowed the scope, refuse to - # forward — the token never reaches GitHub. - # ---------------------------------------------------------------- - tool: search_repos policy: - # Layer 1: coarse APL gate on group membership. - "require(team.engineering | team.security)" - - # Layer 2: Cedar fine-grained authorization. The resource - # type+id come from the tool args; the principal is built - # from the user's token automatically (subject + roles). - cedar: action: 'Action::"read"' resource: @@ -283,50 +240,13 @@ routes: id: ${args.repo_name} attributes: visibility: ${args.visibility} - - # Layer 3: Token exchange. Keycloak's github-api mapper - # injects the user's gh_permissions as the `permissions` - # claim on the minted token. - "delegate(github-oauth, target: github-api, audience: github-api, permissions: [repo:read:internal])" - - # Layer 4: Verify the IdP granted enough. If the user's - # gh_permissions attribute didn't include repo:read:internal, - # the minted token comes back narrower than asked. Deny here - # rather than forwarding an insufficient token. - "!(delegation.granted.permissions contains 'repo:read:internal'): deny" - - # Audit every github call. - "run(audit-log)" - # ---------------------------------------------------------------- - # SEND_EMAIL — PII scanner gate. - # - # The interesting flow: when the LLM composes an email body, it - # might include raw PII pulled from earlier tool responses ("Bob's - # SSN is 555-12-3456"). The PII scanner catches that before the - # email goes out. Operators see the deny in real time on stderr - # via the audit logger. - # ---------------------------------------------------------------- - tool: send_email policy: - "require(perm.email_send)" - # audit-log is ordered BEFORE pii-scan deliberately. A policy - # step that denies short-circuits the chain, so any step after - # the deny never runs. Placing the observation first guarantees - # the attempt is recorded even though the very next step is - # about to block it. A denied data-exfiltration attempt is - # precisely what a security audit trail must capture. audit-log - # is observation-only (it cannot allow or deny), so running it - # first never changes the verdict; the emitted record carries - # the offending args, including the SSN-bearing body. - "run(audit-log)" - # Walks args.body / args.subject / args.to and denies if any - # carries an SSN-like or credit-card-like pattern. - "run(pii-scan)" - # Session-taint gate: block email from a session that previously - # accessed secret data (get_compensation runs taint(secret, - # session)). Distinct from pii-scan above; that catches PII in - # the body, this denies even a CLEAN body because the session - # is tainted. The label is read from `security.labels`, hydrated - # from the session store on this request. See scenarios 08 / 09. - "security.labels contains \"secret\": deny('external email blocked: this session accessed secret data', 'session_tainted_secret')" diff --git a/demos/cpex/docker-compose.yml b/demos/cpex/docker-compose.yml index 34eae88..2621a7e 100644 --- a/demos/cpex/docker-compose.yml +++ b/demos/cpex/docker-compose.yml @@ -56,3 +56,21 @@ services: timeout: 3s retries: 10 start_period: 5s + + # Session-store backend for CPEX taint labels. The cpex configs point + # `global.session_store` at kind: valkey, endpoint localhost:6379, so + # the `secret` label written by get_compensation persists here (keyed + # by H(subject:session_id)) and survives a gateway restart — unlike the + # in-memory default. The gateway runs on the host and reaches Valkey + # via the published port below. + valkey: + image: valkey/valkey:8 + container_name: cpex-demo-valkey + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 2s diff --git a/demos/cpex/docs/figures/demo_arch.png b/demos/cpex/docs/figures/demo_arch.png new file mode 100644 index 0000000..237a76d Binary files /dev/null and b/demos/cpex/docs/figures/demo_arch.png differ diff --git a/demos/cpex/docs/figures/demo_scenario.png b/demos/cpex/docs/figures/demo_scenario.png new file mode 100644 index 0000000..0c49e15 Binary files /dev/null and b/demos/cpex/docs/figures/demo_scenario.png differ diff --git a/demos/cpex/restart.sh b/demos/cpex/restart.sh index 17d7627..dc0bab1 100755 --- a/demos/cpex/restart.sh +++ b/demos/cpex/restart.sh @@ -96,6 +96,22 @@ done printf "\n" ok "Keycloak responding at $KEYCLOAK_READY_URL" +# 4b. Wait for Valkey (CPEX session-store backend). The gateway connects +# lazily on first request, so this is a fail-loud guard rather than a +# hard dependency — a down Valkey would otherwise surface as a denied +# request mid-scenario. +step "waiting for Valkey (session store)" +deadline=$(( $(date +%s) + 30 )) +while [ "$(docker compose exec -T valkey valkey-cli ping 2>/dev/null | tr -d '\r')" != "PONG" ]; do + if [ "$(date +%s)" -ge "$deadline" ]; then + die "Valkey not ready after 30s — check 'docker compose logs valkey'" + fi + printf "." + sleep 1 +done +printf "\n" +ok "Valkey responding (PONG) on localhost:6379" + # 5. verify-token-exchange smoke check (skip on failure but warn loudly). step "verifying RFC 8693 token-exchange permission" if ./verify-token-exchange.sh >/dev/null 2>&1; then diff --git a/demos/cpex/scenarios/08-bob-taint-deny.sh b/demos/cpex/scenarios/08-bob-taint-deny.sh index 52d590e..52c0b6d 100755 --- a/demos/cpex/scenarios/08-bob-taint-deny.sh +++ b/demos/cpex/scenarios/08-bob-taint-deny.sh @@ -9,8 +9,8 @@ # from scenario 07's content-based PII deny. # # Three beats, with fresh per-run session ids so reruns start clean -# (the gateway's session store is in-memory and persists for the life -# of the process): +# (taint labels persist in the Valkey session store, keyed by +# H(subject:session_id), so a fixed id would carry over between runs): # S1 send_email (clean session) → 200 OK # S2 get_compensation (taints sess) → 200 OK (+ taint(secret, session)) # S3 send_email (SAME session as S2) → HTTP 200 + JSON-RPC error -32001,