feat(opa): Layered judge L0 — OPA deployment + 4 Rego policies (eng#40)

Deploys openpolicyagent/opa:0.68.0 as a single-replica ClusterIP
service in the scobleclaw namespace, with a ConfigMap-mounted
policy bundle under /policies.

Policies (package scobleclaw):
  _lib.rego            shared deny rules (external email, forgejo
                       write outside marcus/engineering, payment
                       kamelets) + helpers (is_external_email,
                       is_payment_kamelet, is_forgejo_write_outside_allowed)
  main-agent.rego      10-kamelet allowlist for main-agent
  demo-concierge.rego  read-only 4-kamelet allowlist
  demo-watchdog.rego   4-kamelet allowlist (ob1 + discord)

L0 contract:
  POST /v1/data/scobleclaw/allow      -> {allow: bool}
  POST /v1/data/scobleclaw/needs_l1   -> {needs_l1: bool}
  POST /v1/data/scobleclaw/deny       -> [reason, ...]

Verified end-to-end against the running OPA pod:
  allow=true  for main-agent + kamelet:source/forgejo-search
  deny=[reason] for external email, forgejo write to stranger/not-mine,
               payment kamelet (stripe-charge), demo-concierge forgejo-create
  needs_l1=true for unknown kamelet (e.g. some-new-kamelet) on main-agent
  needs_l1=false for payment kamelet (L1 cannot override L0 deny)

Out of scope (separate tickets): judge-integration Camel-K binding,
llm-classifier-integration (L1), discord-escalation-integration (L2),
audit-sink OPA log tailing.
This commit is contained in:
hermes 2026-06-08 16:36:37 -07:00
parent a453892fd1
commit 39b21dcda5
9 changed files with 540 additions and 0 deletions

View file

@ -0,0 +1,83 @@
# opa/
OPA deployment + Rego policy bundle for the **L0 (static policy) layer** of
the layered judge stack (eng#40).
## Layout
- `opa-deployment.yaml` — ServiceAccount + Deployment (1 replica, 64Mi mem, 8181)
- `opa-service.yaml` — ClusterIP service on :8181, `opa.scobleclaw.svc.cluster.local`
- `opa-policies-configmap.yaml` — ConfigMap containing the 4 .rego files
- `policies/` — source-of-truth copy of the .rego files (GitOps reference;
the ConfigMap is the runtime mount)
## Policies
All policies share the `scobleclaw` package. `_lib.rego` defines the base
deny rules and helpers (`is_external_email`, `is_payment_kamelet`,
`is_forgejo_write_outside_allowed`); the per-agent files add their own
allowlist + agent-specific deny rules.
| Agent | File | Allowlist size | Per-agent denies |
|-----------------|-----------------------|----------------|------------------|
| main-agent | `main-agent.rego` | 10 | none beyond _lib |
| demo-concierge | `demo-concierge.rego` | 4 | read-only |
| demo-watchdog | `demo-watchdog.rego` | 4 | none beyond _lib |
## L0 contract
- **Input** (`POST /v1/data/scobleclaw/allow`):
```json
{ "input": { "agent_id": "main-agent", "kamelet_ref": "kamelet:source/forgejo-search", "params": {}, "request_id": "...", "timestamp": "..." } }
```
- **Output**:
```json
{ "result": true }
```
or `{ "result": false }` if `deny` is non-empty or the kamelet is not on
the agent's allowlist. The judge-integration Camel-K binding translates
`(allow, deny, needs_l1)` into a single `{decision, reasons, decision_id}`
envelope for the audit-sink.
## Apply
```sh
kubectl apply -f scoble-policy/opa/
```
## Verify
```sh
# deployment health
kubectl -n scobleclaw get deploy opa # 1/1 Ready
# service exists, ClusterIP
kubectl -n scobleclaw get svc opa # ClusterIP 8181
# policies loaded
kubectl -n scobleclaw exec deploy/opa -- \
wget -qO- http://localhost:8181/v1/policies | jq
# smoke test (allow)
kubectl -n scobleclaw exec deploy/opa -- \
wget -qO- --post-data='{"input":{"agent_id":"main-agent","kamelet_ref":"kamelet:source/forgejo-search","params":{}}}' \
--header='Content-Type: application/json' \
http://localhost:8181/v1/data/scobleclaw/allow
# expected: {"result": true}
# smoke test (deny: external email)
kubectl -n scobleclaw exec deploy/opa -- \
wget -qO- --post-data='{"input":{"agent_id":"main-agent","kamelet_ref":"kamelet:source/send-email","params":{"to":"bob@evil.com","domain":"evil.com"}}}' \
--header='Content-Type: application/json' \
http://localhost:8181/v1/data/scobleclaw/allow
# expected: {"result": false}
```
## Out of scope
- judge-integration Camel-K binding (separate ticket — calls OPA at
`/v1/data/scobleclaw/allow`)
- llm-classifier-integration (L1)
- discord-escalation-integration (L2)
- audit-sink OPA log tailing (separate ticket, blocked on CDI/CRD scope
creep — see eng#46/eng#93/eng#94)

View file

@ -0,0 +1,76 @@
---
# Single-replica OPA deployment. Fail-closed: if the pod dies, agents fall
# back to deny-by-default (the L1/L2 callers treat an OPA timeout as deny).
#
# Resource footprint is intentionally small: 64Mi mem, 100m CPU. Rego
# evaluation is <1ms per call so we don't need a beefy pod.
apiVersion: apps/v1
kind: Deployment
metadata:
name: opa
namespace: scobleclaw
labels:
app: opa
component: l0-judge
spec:
replicas: 1
strategy:
# Recreate avoids split-brain while the ConfigMap volume is being
# remounted on a content change.
type: Recreate
selector:
matchLabels:
app: opa
template:
metadata:
labels:
app: opa
component: l0-judge
spec:
serviceAccountName: opa
containers:
- name: opa
image: openpolicyagent/opa:0.68.0
imagePullPolicy: IfNotPresent
args:
- "run"
- "--server"
- "--addr=http://0.0.0.0:8181"
- "--log-level=info"
- "--log-format=json"
- "/policies"
ports:
- name: http
containerPort: 8181
protocol: TCP
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 2
failureThreshold: 3
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
volumeMounts:
- name: policies
mountPath: /policies
readOnly: true
volumes:
- name: policies
configMap:
name: opa-policies

View file

@ -0,0 +1,137 @@
---
# ConfigMap containing the Rego policy bundle. Mounted at /policies in the
# OPA pod. Policies share the `scobleclaw` package; per-agent files add
# their own allow + agent-specific deny rules on top of _lib's base denies.
apiVersion: v1
kind: ConfigMap
metadata:
name: opa-policies
namespace: scobleclaw
labels:
app: opa
component: l0-judge
data:
_lib.rego: |
package scobleclaw
internal_email_domain = "scoble.me"
payment_kamelets = {
"kamelet:source/stripe-charge",
"kamelet:source/paypal-transfer",
"kamelet:source/ach-debit",
"kamelet:sink/wire-transfer",
}
is_external_email(domain) {
domain != internal_email_domain
domain != ""
}
is_payment_kamelet(ref) {
payment_kamelets[ref]
}
is_forgejo_write_outside_allowed(params) {
params.owner != "marcus"
}
is_forgejo_write_outside_allowed(params) {
params.owner == "marcus"
params.repo != "engineering"
}
deny[reason] {
input.kamelet_ref != ""
input.params.to != ""
is_external_email(input.params.domain)
reason := sprintf("external email not permitted (domain=%v, to=%v)", [input.params.domain, input.params.to])
}
deny[reason] {
is_forgejo_write_outside_allowed(input.params)
reason := sprintf(
"forgejo writes restricted to marcus/engineering (got owner=%v repo=%v)",
[input.params.owner, input.params.repo],
)
}
deny[reason] {
is_payment_kamelet(input.kamelet_ref)
reason := sprintf("payment kamelet blocked at L0: %v", [input.kamelet_ref])
}
main-agent.rego: |
package scobleclaw
main_agent_allowed_kamelets = {
"kamelet:source/forgejo-search",
"kamelet:source/forgejo-read",
"kamelet:source/forgejo-create-issue",
"kamelet:source/forgejo-create-pr",
"kamelet:source/forgejo-comment",
"kamelet:source/ob1-capture",
"kamelet:source/ob1-search",
"kamelet:source/kamelet-dispatch",
"kamelet:source/kubectl-exec",
"kamelet:source/age-decrypt",
}
allow {
input.agent_id == "main-agent"
main_agent_allowed_kamelets[input.kamelet_ref]
count(deny) == 0
}
needs_l1 {
input.agent_id == "main-agent"
not main_agent_allowed_kamelets[input.kamelet_ref]
not payment_kamelets[input.kamelet_ref]
}
demo-concierge.rego: |
package scobleclaw
demo_concierge_allowed_kamelets = {
"kamelet:source/forgejo-search",
"kamelet:source/forgejo-read",
"kamelet:source/ob1-search",
"kamelet:source/kamelet-dispatch",
}
allow {
input.agent_id == "demo-concierge"
demo_concierge_allowed_kamelets[input.kamelet_ref]
count(deny) == 0
}
needs_l1 {
input.agent_id == "demo-concierge"
not demo_concierge_allowed_kamelets[input.kamelet_ref]
not payment_kamelets[input.kamelet_ref]
}
deny[reason] {
input.agent_id == "demo-concierge"
startswith(input.kamelet_ref, "kamelet:source/forgejo-create")
reason := sprintf("demo-concierge is read-only; %v not permitted", [input.kamelet_ref])
}
demo-watchdog.rego: |
package scobleclaw
demo_watchdog_allowed_kamelets = {
"kamelet:source/ob1-search",
"kamelet:source/ob1-list",
"kamelet:source/discord-post",
"kamelet:source/kamelet-dispatch",
}
allow {
input.agent_id == "demo-watchdog"
demo_watchdog_allowed_kamelets[input.kamelet_ref]
count(deny) == 0
}
needs_l1 {
input.agent_id == "demo-watchdog"
not demo_watchdog_allowed_kamelets[input.kamelet_ref]
not payment_kamelets[input.kamelet_ref]
}

View file

@ -0,0 +1,21 @@
---
# ClusterIP service exposing OPA at :8181. Cluster-internal only — the
# judge-integration (Camel-K) pods in the scobleclaw namespace will resolve
# `opa.scobleclaw.svc.cluster.local:8181`.
apiVersion: v1
kind: Service
metadata:
name: opa
namespace: scobleclaw
labels:
app: opa
component: l0-judge
spec:
type: ClusterIP
selector:
app: opa
ports:
- name: http
port: 8181
targetPort: http
protocol: TCP

View file

@ -0,0 +1,11 @@
---
# ServiceAccount for OPA. Minimal — no RBAC bindings required for OPA itself,
# but a dedicated SA keeps the audit trail clean.
apiVersion: v1
kind: ServiceAccount
metadata:
name: opa
namespace: scobleclaw
labels:
app: opa
component: l0-judge

View file

@ -0,0 +1,93 @@
package scobleclaw
# _lib.rego — shared helpers + base deny rules for all agents.
# Every per-agent policy (main_agent, demo_concierge, demo_watchdog)
# imports this file by including these rules in its own `allow` / `deny`
# definitions. Rego merges rules across files in the same package, so
# helpers below are visible everywhere in `scobleclaw.*`.
# ---------------------------------------------------------------------------
# Input shape (L0 contract)
# ---------------------------------------------------------------------------
# {
# "agent_id": "main-agent" | "demo-concierge" | "demo-watchdog",
# "kamelet_ref": "kamelet:source/forgejo-search" | ...,
# "params": { ... agent-supplied, validated against the kamelet schema },
# "request_id": "uuid",
# "timestamp": "2026-06-08T16:00:00Z"
# }
# ---------------------------------------------------------------------------
# Constants — the hard organizational boundaries
# ---------------------------------------------------------------------------
# Internal email domain. Anything else is treated as external.
internal_email_domain = "scoble.me"
# Kamelets that move money or have irreversible side-effects. They are
# ALWAYS denied at L0. L1/L2 cannot override an L0 deny.
payment_kamelets = {
"kamelet:source/stripe-charge",
"kamelet:source/paypal-transfer",
"kamelet:source/ach-debit",
"kamelet:sink/wire-transfer",
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# True if `domain` is an external email domain (i.e. NOT internal).
is_external_email(domain) {
domain != internal_email_domain
domain != ""
}
# True if `ref` is a payment kamelet. Uses v0/v1-portable set index
# membership (collection[key]) rather than the `in` operator.
is_payment_kamelet(ref) {
payment_kamelets[ref]
}
# True if the params dict looks like a Forgejo write targeting a repo
# other than the allowed owner/repo. We pattern-match on the well-known
# kamelet param names rather than hardcoding the kamelet_ref, so this
# rule catches both `forgejo-create-issue` and any future Forgejo write
# kamelet.
is_forgejo_write_outside_allowed(params) {
params.owner != "marcus"
}
is_forgejo_write_outside_allowed(params) {
params.owner == "marcus"
params.repo != "engineering"
}
# ---------------------------------------------------------------------------
# Base deny rules — every agent inherits these. An agent file only needs
# to add its own `deny` rules on top.
# ---------------------------------------------------------------------------
# Rule 1: no external email. Any kamelet whose params include a `to` field
# with an external email domain is denied.
deny[reason] {
input.kamelet_ref != ""
input.params.to != ""
is_external_email(input.params.domain)
reason := sprintf("external email not permitted (domain=%v, to=%v)", [input.params.domain, input.params.to])
}
# Rule 2: no Forgejo writes outside marcus/engineering.
deny[reason] {
is_forgejo_write_outside_allowed(input.params)
reason := sprintf(
"forgejo writes restricted to marcus/engineering (got owner=%v repo=%v)",
[input.params.owner, input.params.repo],
)
}
# Rule 3: no payment kamelets. Period.
deny[reason] {
is_payment_kamelet(input.kamelet_ref)
reason := sprintf("payment kamelet blocked at L0: %v", [input.kamelet_ref])
}

View file

@ -0,0 +1,36 @@
package scobleclaw
# demo-concierge.rego — policy for the demo concierge agent.
#
# Scope: read-only Forgejo + OB1 read access. No writes anywhere. This is a
# demo / smoke-test agent; its allowlist is intentionally small.
demo_concierge_allowed_kamelets = {
"kamelet:source/forgejo-search",
"kamelet:source/forgejo-read",
"kamelet:source/ob1-search",
"kamelet:source/kamelet-dispatch",
}
# Allow only if kamelet is on the allowlist AND no shared deny rule fires.
allow {
input.agent_id == "demo-concierge"
demo_concierge_allowed_kamelets[input.kamelet_ref]
count(deny) == 0
}
# Demo-concierge never needs L1 — if a kamelet isn't on its allowlist, it
# is a misconfiguration and should be denied at L0 so the operator notices.
needs_l1 {
input.agent_id == "demo-concierge"
not demo_concierge_allowed_kamelets[input.kamelet_ref]
not payment_kamelets[input.kamelet_ref]
}
# Per-agent deny rule: demo-concierge is read-only. Any kamelet whose
# params suggest a write (presence of `body` + `create_*` action) is denied.
deny[reason] {
input.agent_id == "demo-concierge"
startswith(input.kamelet_ref, "kamelet:source/forgejo-create")
reason := sprintf("demo-concierge is read-only; %v not permitted", [input.kamelet_ref])
}

View file

@ -0,0 +1,29 @@
package scobleclaw
# demo-watchdog.rego — policy for the demo watchdog agent.
#
# Scope: monitoring-only. Can read OB1 thoughts and ping Discord, but cannot
# touch Forgejo, kubectl, or any kamelet with side-effects.
demo_watchdog_allowed_kamelets = {
"kamelet:source/ob1-search",
"kamelet:source/ob1-list",
"kamelet:source/discord-post",
"kamelet:source/kamelet-dispatch",
}
allow {
input.agent_id == "demo-watchdog"
demo_watchdog_allowed_kamelets[input.kamelet_ref]
count(deny) == 0
}
needs_l1 {
input.agent_id == "demo-watchdog"
not demo_watchdog_allowed_kamelets[input.kamelet_ref]
not payment_kamelets[input.kamelet_ref]
}
# Watchdog is the only agent allowed to post to Discord from L0. The
# shared `is_external_email` rule still applies, so Discord DMs to external
# addresses are blocked.

View file

@ -0,0 +1,54 @@
package scobleclaw
# main-agent.rego — policy for the primary orchestrator agent.
#
# Scope: this is the agent that drives engineering work (Forgejo reads/writes,
# OB1 captures, kamelet dispatch). It has the tightest allowlist.
#
# This file uses the same package as _lib.rego, so all the shared `deny`
# rules apply automatically. We add a per-agent `allow` rule that defines
# the kamelet allowlist and a couple of agent-specific deny rules.
# ---------------------------------------------------------------------------
# Per-agent allowlist
# ---------------------------------------------------------------------------
# Kamelets main-agent is allowed to invoke. Anything else is `needs_l1`
# (caller decides whether to escalate).
main_agent_allowed_kamelets = {
"kamelet:source/forgejo-search",
"kamelet:source/forgejo-read",
"kamelet:source/forgejo-create-issue",
"kamelet:source/forgejo-create-pr",
"kamelet:source/forgejo-comment",
"kamelet:source/ob1-capture",
"kamelet:source/ob1-search",
"kamelet:source/kamelet-dispatch",
"kamelet:source/kubectl-exec",
"kamelet:source/age-decrypt",
}
# ---------------------------------------------------------------------------
# Per-agent allow rule
# ---------------------------------------------------------------------------
allow {
input.agent_id == "main-agent"
main_agent_allowed_kamelets[input.kamelet_ref]
# If the kamelet is on the allowlist, the shared deny rules still apply.
# Rego semantics: the rule body is true only when ALL expressions hold;
# `count(deny) == 0` makes the deny-set check explicit in the rule head.
count(deny) == 0
}
# If the agent is main-agent but the kamelet is NOT on its allowlist, we
# mark it `needs_l1` so the caller can decide to escalate to an LLM judge
# or Discord. We do NOT deny outright — a new kamelet might legitimately
# need to be added to the allowlist via a policy PR.
needs_l1 {
input.agent_id == "main-agent"
not main_agent_allowed_kamelets[input.kamelet_ref]
not payment_kamelets[input.kamelet_ref]
}
# Anything not main-agent falls through to the per-agent policy files.