feat: an OPA decision point for tool-call parameters
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user