|
| 1 | +<!-- SPDX-License-Identifier: MPL-2.0 --> |
| 2 | +<!-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> --> |
| 3 | + |
| 4 | +# 7. Trust-tier policy DSL — Nickel-encoded PEP at the bridge, PDP as a cartridge |
| 5 | + |
| 6 | +Date: 2026-05-20 |
| 7 | + |
| 8 | +## Status |
| 9 | + |
| 10 | +Proposed (RFC — implementation tracked in epic #87 item 4) |
| 11 | + |
| 12 | +## Context |
| 13 | + |
| 14 | +BoJ defines three trust tiers — **Teranga** (formally verified, full ABI discharge), **Shield** (operationally hardened, explicit security review), **Ayo** (community contributions, master-approval-gated). The tier vocabulary appears in: |
| 15 | + |
| 16 | +- README ("Cartridges — 115 pluggable cartridges across Teranga / Shield / Ayo trust tiers") |
| 17 | +- `mcp-bridge/lib/offline-menu.js` (cartridges grouped into `tier_teranga` / `tier_shield` / `tier_ayo`) |
| 18 | +- `boj://server/info` (the `trust_tiers` field, added in PR #89) |
| 19 | +- Cartridge manifests (declared per-cartridge) |
| 20 | + |
| 21 | +But **the tiers are not load-bearing at runtime**. The bridge today applies one rate limit, one prompt-injection filter, and one input-size cap to every call regardless of which cartridge is invoked or what tier it sits in. A `boj_github_merge_pr` (destructive, tier-3-equivalent) and a `boj_cartridge_info` (read-only, tier-0) are gated identically. |
| 22 | + |
| 23 | +ADR-0002 ("BoJ-only MCP") establishes BoJ as the single MCP gateway for the estate. By construction there is one **policy enforcement point** (the bridge). It follows that there should be one **policy decision point** that the bridge consults — separating *what is allowed* from *how it's enforced*. Without that separation, every new policy rule means touching `mcp-bridge/main.js`, which doesn't scale and conflicts with the BoJ-only-MCP rule (you can't push policy out to per-cartridge code without breaking the rule). |
| 24 | + |
| 25 | +The coord layer already uses **Nickel** for typed-envelope contracts (`cartridges/local-coord-mcp/coord-messages.ncl`, gated by `COORD_REQUIRE_NICKEL=1`). Extending Nickel to the broader policy surface keeps the verification story coherent: one schema language, one validator, two surfaces (coord envelopes + bridge dispatch). |
| 26 | + |
| 27 | +## Decision |
| 28 | + |
| 29 | +Adopt **Nickel as the policy DSL**, with a clean **PEP / PDP split**: |
| 30 | + |
| 31 | +- **PEP (Policy Enforcement Point)** — the bridge's `hardeningGate` in `mcp-bridge/main.js`. Stays where it is. |
| 32 | +- **PDP (Policy Decision Point)** — a new `policy-mcp` cartridge that holds the active policy bundle, evaluates queries, and returns allow / deny / require-approval / rate-limit verdicts. Lives at `cartridges/policy-mcp/`. |
| 33 | + |
| 34 | +### Policy schema (Nickel) |
| 35 | + |
| 36 | +```nickel |
| 37 | +# policies/boj-default.ncl |
| 38 | +let CartridgePolicy = { |
| 39 | + tier | std.number.Number | std.contract.in [0, 1, 2, 3, 4], |
| 40 | + rate_limit | { per_minute | Number, per_hour | Number }, |
| 41 | + required_role | [| 'apprentice, 'journeyman, 'master |], |
| 42 | + master_approval | Bool, |
| 43 | + allowed_args | { _ : { type | String, validator | String | default = "" } }, |
| 44 | + side_effect | [| 'read, 'small_write, 'major_write, 'destructive |], |
| 45 | +} in |
| 46 | +
|
| 47 | +{ |
| 48 | + # Teranga (tier 0-1) — formally verified, broadly available |
| 49 | + cartridge."github-api-mcp".tools.boj_github_get_repo = { |
| 50 | + tier = 0, |
| 51 | + rate_limit = { per_minute = 60, per_hour = 1000 }, |
| 52 | + required_role = 'apprentice, |
| 53 | + master_approval = false, |
| 54 | + allowed_args = { |
| 55 | + owner = { type = "string", validator = "^[A-Za-z0-9-]+$" }, |
| 56 | + repo = { type = "string", validator = "^[A-Za-z0-9._-]+$" }, |
| 57 | + }, |
| 58 | + side_effect = 'read, |
| 59 | + }, |
| 60 | +
|
| 61 | + # Shield (tier 2) — write operations gated on journeyman role |
| 62 | + cartridge."github-api-mcp".tools.boj_github_create_issue = { |
| 63 | + tier = 2, |
| 64 | + rate_limit = { per_minute = 10, per_hour = 50 }, |
| 65 | + required_role = 'journeyman, |
| 66 | + master_approval = false, |
| 67 | + side_effect = 'small_write, |
| 68 | + }, |
| 69 | +
|
| 70 | + # Ayo (tier 3-4) — destructive operations require master approval |
| 71 | + cartridge."github-api-mcp".tools.boj_github_merge_pr = { |
| 72 | + tier = 3, |
| 73 | + rate_limit = { per_minute = 2, per_hour = 5 }, |
| 74 | + required_role = 'journeyman, |
| 75 | + master_approval = true, |
| 76 | + side_effect = 'major_write, |
| 77 | + }, |
| 78 | +} |
| 79 | +``` |
| 80 | + |
| 81 | +### Wire protocol (PEP → PDP) |
| 82 | + |
| 83 | +The bridge already loads Nickel locally (`mcp-bridge/lib/nickel-validator.js`). For policy queries, the bridge calls into `policy-mcp` over the same REST surface every cartridge uses: |
| 84 | + |
| 85 | +``` |
| 86 | +POST /cartridge/policy-mcp/evaluate |
| 87 | +{ |
| 88 | + "cartridge": "github-api-mcp", |
| 89 | + "tool": "boj_github_merge_pr", |
| 90 | + "args": { "owner": "...", "repo": "...", "pull_number": 42 }, |
| 91 | + "peer": { "role": "journeyman", "client_kind": "claude", "variant": "opus-4.7" } |
| 92 | +} |
| 93 | +→ |
| 94 | +{ |
| 95 | + "verdict": "require_approval" | "allow" | "deny" | "rate_limit", |
| 96 | + "tier": 3, |
| 97 | + "reason": "boj_github_merge_pr is tier-3; journeyman role + master approval required", |
| 98 | + "approval_token": "<opaque>", // only when verdict=require_approval |
| 99 | + "rate_limit_window_ms": 60000, // only when verdict=rate_limit |
| 100 | + "audit_id": "<uuid>" |
| 101 | +} |
| 102 | +``` |
| 103 | + |
| 104 | +### PEP behaviour matrix |
| 105 | + |
| 106 | +| Verdict | Bridge action | |
| 107 | +|---|---| |
| 108 | +| `allow` | Proceed to dispatch | |
| 109 | +| `deny` | Return MCP error `-32000` "policy denied: <reason>"; emit audit log; do not dispatch | |
| 110 | +| `rate_limit` | Return MCP error `-32001` "rate limit; retry after <window>"; do not dispatch | |
| 111 | +| `require_approval` | If caller's role is master, proceed. Otherwise enqueue in `coord_send_gated`-style quarantine and return `-32002` "pending master approval, request_id=<n>" | |
| 112 | + |
| 113 | +### Default policy bundle |
| 114 | + |
| 115 | +Ship `policies/boj-default.ncl` covering all 41 bridge tools. Derived from each tool's existing annotations (read-only vs side-effectful), which were classified during the v0.4.6 AAA-tier description pass. No new metadata work required for the default bundle. |
| 116 | + |
| 117 | +### Configuration |
| 118 | + |
| 119 | +- `BOJ_POLICY_MODE=enforce` (default) | `audit` (log verdicts but always allow) | `off` (skip PDP) |
| 120 | +- `BOJ_POLICY_BUNDLE` — path to active `.ncl` file (default `policies/boj-default.ncl`) |
| 121 | +- New tool `boj_policy_reload` for hot-reloading the bundle without restart |
| 122 | + |
| 123 | +## Consequences |
| 124 | + |
| 125 | +### Positive |
| 126 | + |
| 127 | +- **Tier names become enforceable** — `tier: 3` in policy = `master_approval: true` at runtime. The taxonomy is no longer documentation. |
| 128 | +- **Per-cartridge / per-tool granularity** — different tools in the same cartridge can have different policies (e.g. `boj_github_list_issues` open, `boj_github_merge_pr` master-gated). |
| 129 | +- **One DSL, two surfaces** — extends the existing Nickel investment from coord envelopes to bridge dispatch. Reuses `nickel-validator.js` infrastructure. |
| 130 | +- **PEP/PDP separation matches NIST RBAC 4.0 reference architecture** — policy auditing tools (OPA, Rego, etc.) generalise to this shape; future migration is mechanical. |
| 131 | +- **Audit trail by construction** — every decision yields an `audit_id` linkable to a span (pairs with epic #87 item 13's OTel exporter). |
| 132 | +- **Cleans up `hardeningGate`** — the bridge's current 5-stage validation collapses into "ask the PDP". Each rule becomes a Nickel contract, not JS. |
| 133 | + |
| 134 | +### Negative |
| 135 | + |
| 136 | +- **Latency** — every `tools/call` now incurs a PDP round-trip. Mitigation: in-process Nickel evaluation (no HTTP) when policy-mcp is loaded as a same-process cartridge. Out-of-process only for federated multi-machine setups (see ADR-0010). |
| 137 | +- **Cold-start failure mode** — what does the bridge do if policy-mcp is unreachable? Three modes: `fail-closed` (deny all), `fail-open` (allow all), `fail-static` (use cached last-good bundle). Default: `fail-static` with `BOJ_POLICY_FAIL_MODE` override. |
| 138 | +- **Policy authorship burden** — writing 41-tool policies once is fine; the marginal cost per new cartridge isn't trivial. Mitigation: ADR-0008 (cartridge marketplace) requires every submitted cartridge to ship a default policy, just as it ships a manifest. |
| 139 | +- **Existing 5-stage hardening must coexist** — rate limit, size cap, injection scan, name validation, required-args all stay in the PEP; the PDP layers *on top*. Don't fold them in until they're proven equivalent under Nickel contracts. |
| 140 | + |
| 141 | +## Non-goals |
| 142 | + |
| 143 | +- Not building a generic RBAC system. The roles are master/journeyman/apprentice as already defined in coord-mcp; new roles require ADR. |
| 144 | +- Not adopting OPA/Rego — Nickel is already the estate's contract language; importing another would split the surface. |
| 145 | +- Not creating "user accounts" — peer identity is via `coord_register` token, which is the canonical identity. Multi-tenancy is out of scope for v1. |
| 146 | +- Not enforcing policy on `resources/read` or `prompts/get` (added in PR #89) initially — those are read-only and the cost/benefit doesn't justify it. Revisit if abuse surfaces. |
| 147 | + |
| 148 | +## Open questions |
| 149 | + |
| 150 | +1. **PDP-as-cartridge vs PDP-in-bridge** — does the PDP genuinely need to be a separate cartridge, or should it be an in-process module like `nickel-validator.js`? Arguments both ways. Recommend cartridge for separation of concerns and to enable per-deployment policy customisation. |
| 151 | + |
| 152 | +2. **Policy versioning** — when the bundle changes, in-flight quarantined approvals could have been written against a different version. Need a `policy_version` field on quarantined envelopes; reject approval if version doesn't match. |
| 153 | + |
| 154 | +3. **Cross-cartridge composition policies** — `boj_cartridge_invoke` against an arbitrary cartridge currently bypasses tool-level policy. Either (a) require every cartridge to declare its full tool surface in its manifest so the PDP knows the per-tool tier, or (b) apply the cartridge-level worst-case tier to all unrecognised tools. Recommend (a); it's already partially true for verified cartridges. |
| 155 | + |
| 156 | +4. **Migration sequence** — should the existing `hardeningGate` rate-limit logic be ported into the PDP (so all policy lives in one place) or left in the PEP for performance? Recommend PEP keeps rate-limit + injection-scan as fast-path guards, PDP handles the tier-aware logic. |
| 157 | + |
| 158 | +## Implementation sketch |
| 159 | + |
| 160 | +When this RFC is accepted: |
| 161 | + |
| 162 | +1. New cartridge `cartridges/policy-mcp/` with Idris2 ABI + Zig FFI + Deno adapter (standard triple). Manifest declares one tool `policy_evaluate` plus internal `policy_reload`. |
| 163 | +2. `policies/boj-default.ncl` shipped at the repo root. 41 tools covered. |
| 164 | +3. `mcp-bridge/lib/policy-client.js` — minimal Nickel-backed PDP client (in-process when local, REST when federated). |
| 165 | +4. `mcp-bridge/main.js` — `hardeningGate` adds a step 6 ("PDP evaluation") and respects the verdict. |
| 166 | +5. New env vars in `glama.json`: `BOJ_POLICY_MODE`, `BOJ_POLICY_BUNDLE`, `BOJ_POLICY_FAIL_MODE`. |
| 167 | +6. Test suite: 1 test per verdict outcome (allow / deny / rate_limit / require_approval); fixture policy bundles for each case. |
| 168 | +7. Documentation: a new `docs/operations/POLICY-AUTHORING.md` showing how operators customise the default bundle. |
| 169 | + |
| 170 | +## Linked |
| 171 | + |
| 172 | +- ADR-0002 (BoJ-only MCP) — establishes the single-gateway invariant this depends on. |
| 173 | +- ADR-0008 (cartridge marketplace) — submission flow requires a default policy per cartridge. |
| 174 | +- Epic #87 item 4 (this). |
| 175 | +- `cartridges/local-coord-mcp/coord-messages.ncl` — vocabulary precedent. |
0 commit comments