From f6aa695c831f6d70074046e55d7040af255e9940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Tailland?= Date: Sat, 29 Aug 2026 18:09:23 +0200 Subject: [PATCH] feat: an OPA decision point for tool-call parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cluster-scoped and platform-owned: every domain gateway asks the same server, so a rule is written once rather than five times. The policy has an opinion about arguments and none about anything else — the handshake, the tool listing and the REST surface pass untouched. Two rules today: a cap on how much one call may read, which is what a request-counting rate limit cannot see, and a bound on a unit price, which is the parameter that turns a permitted write into an expensive mistake. Co-Authored-By: Claude Opus 5 --- opa/deployment.yaml | 171 ++++++++++++++++++++++++++++++++++++++++++++ opa/policy.rego | 73 +++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 opa/deployment.yaml create mode 100644 opa/policy.rego diff --git a/opa/deployment.yaml b/opa/deployment.yaml new file mode 100644 index 0000000..265b623 --- /dev/null +++ b/opa/deployment.yaml @@ -0,0 +1,171 @@ +# The decision point for tool-call parameters. +# +# Cluster-scoped and platform-owned, like everything else in this repo: every +# domain gateway asks the same server, so a rule is written once rather than +# five times, and changing it does not mean redeploying five gateways. +apiVersion: v1 +kind: Namespace +metadata: + name: opa +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: opa-policy + namespace: opa +data: + policy.rego: | + # Authorisation on tool-call *parameters*, not just the tool name. + # + # The estate already answers "may this identity call this tool?" — the MCP + # server filters tool discovery by the caller's roles. What nothing answered is + # "may it call it with *these* arguments". `write` authorises writing; it does + # not authorise this particular record. A read cap counts calls; it does not + # stop one call asking for ten thousand rows. + # + # This runs at the gateway, so it holds whether the caller is an agent behaving + # itself, an agent that has been talked into something, or a person with curl. + + package kong + + import rego.v1 + + default allow := false + + # Everything that is not an MCP tool call passes: the session handshake, the + # tool listing, the REST surface. This policy has an opinion about arguments + # and no opinion about anything else. + allow if not is_tool_call + + allow if { + is_tool_call + count(violations) == 0 + } + + is_tool_call if { + input.request.http.parsed_body.method == "tools/call" + } + + tool := input.request.http.parsed_body.params.name + + args := object.get(input.request.http.parsed_body.params, "arguments", {}) + + # --- how much one call may read ------------------------------------------- + # + # The rate limit counts requests. Without this, one request asking for a + # hundred thousand rows costs the same as one asking for ten — which is how a + # volume cap is defeated without breaking it. + max_page_size := 200 + + violations contains msg if { + size := to_number(object.get(args, "query_page_size", 0)) + size > max_page_size + msg := sprintf("query_page_size %d exceeds the maximum of %d", [size, max_page_size]) + } + + # --- what one call may commit --------------------------------------------- + # + # A price is the parameter that turns a permitted write into an expensive + # mistake. The bound is deliberately wide: this is not pricing policy, it is a + # guard against a value that can only be an error or an attack. + max_unit_price := 100000 + + violations contains msg if { + body := object.get(args, "body", {}) + some field in ["buyprice", "msrp"] + value := to_number(object.get(body, field, 0)) + value > max_unit_price + msg := sprintf("%s %v exceeds the maximum of %d", [field, value, max_unit_price]) + } + + violations contains msg if { + body := object.get(args, "body", {}) + some field in ["buyprice", "msrp"] + to_number(object.get(body, field, 1)) <= 0 + msg := sprintf("%s must be greater than zero", [field]) + } + + # What the caller is told. A refusal that does not say which argument was + # refused sends someone looking in the wrong place. + reason := concat("; ", violations) +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: opa + namespace: opa +spec: + # Stateless: every decision is a function of the request. Two replicas because + # it sits on the request path of every write in the estate — an OPA that is + # down is a gateway that refuses. + replicas: 2 + selector: + matchLabels: + app: opa + template: + metadata: + labels: + app: opa + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: opa + image: openpolicyagent/opa:1.11.0 + args: + - run + - --server + - --addr=0.0.0.0:8181 + - --log-level=info + - /policy/policy.rego + ports: + - name: http + containerPort: 8181 + volumeMounts: + - name: policy + mountPath: /policy + readOnly: true + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 20 + resources: + requests: + cpu: 20m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumes: + - name: policy + configMap: + name: opa-policy +--- +apiVersion: v1 +kind: Service +metadata: + name: opa + namespace: opa +spec: + selector: + app: opa + ports: + - name: http + port: 8181 + targetPort: http diff --git a/opa/policy.rego b/opa/policy.rego new file mode 100644 index 0000000..045d9bd --- /dev/null +++ b/opa/policy.rego @@ -0,0 +1,73 @@ +# Authorisation on tool-call *parameters*, not just the tool name. +# +# The estate already answers "may this identity call this tool?" — the MCP +# server filters tool discovery by the caller's roles. What nothing answered is +# "may it call it with *these* arguments". `write` authorises writing; it does +# not authorise this particular record. A read cap counts calls; it does not +# stop one call asking for ten thousand rows. +# +# This runs at the gateway, so it holds whether the caller is an agent behaving +# itself, an agent that has been talked into something, or a person with curl. + +package kong + +import rego.v1 + +default allow := false + +# Everything that is not an MCP tool call passes: the session handshake, the +# tool listing, the REST surface. This policy has an opinion about arguments +# and no opinion about anything else. +allow if not is_tool_call + +allow if { + is_tool_call + count(violations) == 0 +} + +is_tool_call if { + input.request.http.parsed_body.method == "tools/call" +} + +tool := input.request.http.parsed_body.params.name + +args := object.get(input.request.http.parsed_body.params, "arguments", {}) + +# --- how much one call may read ------------------------------------------- +# +# The rate limit counts requests. Without this, one request asking for a +# hundred thousand rows costs the same as one asking for ten — which is how a +# volume cap is defeated without breaking it. +max_page_size := 200 + +violations contains msg if { + size := to_number(object.get(args, "query_page_size", 0)) + size > max_page_size + msg := sprintf("query_page_size %d exceeds the maximum of %d", [size, max_page_size]) +} + +# --- what one call may commit --------------------------------------------- +# +# A price is the parameter that turns a permitted write into an expensive +# mistake. The bound is deliberately wide: this is not pricing policy, it is a +# guard against a value that can only be an error or an attack. +max_unit_price := 100000 + +violations contains msg if { + body := object.get(args, "body", {}) + some field in ["buyprice", "msrp"] + value := to_number(object.get(body, field, 0)) + value > max_unit_price + msg := sprintf("%s %v exceeds the maximum of %d", [field, value, max_unit_price]) +} + +violations contains msg if { + body := object.get(args, "body", {}) + some field in ["buyprice", "msrp"] + to_number(object.get(body, field, 1)) <= 0 + msg := sprintf("%s must be greater than zero", [field]) +} + +# What the caller is told. A refusal that does not say which argument was +# refused sends someone looking in the wrong place. +reason := concat("; ", violations)