From 39b21dcda51faaecaf5d8fcaa49332b396571a8a Mon Sep 17 00:00:00 2001 From: hermes Date: Mon, 8 Jun 2026 16:36:37 -0700 Subject: [PATCH] =?UTF-8?q?feat(opa):=20Layered=20judge=20L0=20=E2=80=94?= =?UTF-8?q?=20OPA=20deployment=20+=204=20Rego=20policies=20(eng#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scoble-policy/opa/README.md | 83 +++++++++++ scoble-policy/opa/opa-deployment.yaml | 76 ++++++++++ scoble-policy/opa/opa-policies-configmap.yaml | 137 ++++++++++++++++++ scoble-policy/opa/opa-service.yaml | 21 +++ scoble-policy/opa/opa-serviceaccount.yaml | 11 ++ scoble-policy/opa/policies/_lib.rego | 93 ++++++++++++ .../opa/policies/demo-concierge.rego | 36 +++++ scoble-policy/opa/policies/demo-watchdog.rego | 29 ++++ scoble-policy/opa/policies/main-agent.rego | 54 +++++++ 9 files changed, 540 insertions(+) create mode 100644 scoble-policy/opa/README.md create mode 100644 scoble-policy/opa/opa-deployment.yaml create mode 100644 scoble-policy/opa/opa-policies-configmap.yaml create mode 100644 scoble-policy/opa/opa-service.yaml create mode 100644 scoble-policy/opa/opa-serviceaccount.yaml create mode 100644 scoble-policy/opa/policies/_lib.rego create mode 100644 scoble-policy/opa/policies/demo-concierge.rego create mode 100644 scoble-policy/opa/policies/demo-watchdog.rego create mode 100644 scoble-policy/opa/policies/main-agent.rego diff --git a/scoble-policy/opa/README.md b/scoble-policy/opa/README.md new file mode 100644 index 0000000..016615b --- /dev/null +++ b/scoble-policy/opa/README.md @@ -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) diff --git a/scoble-policy/opa/opa-deployment.yaml b/scoble-policy/opa/opa-deployment.yaml new file mode 100644 index 0000000..a041b51 --- /dev/null +++ b/scoble-policy/opa/opa-deployment.yaml @@ -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 diff --git a/scoble-policy/opa/opa-policies-configmap.yaml b/scoble-policy/opa/opa-policies-configmap.yaml new file mode 100644 index 0000000..d308fde --- /dev/null +++ b/scoble-policy/opa/opa-policies-configmap.yaml @@ -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] + } diff --git a/scoble-policy/opa/opa-service.yaml b/scoble-policy/opa/opa-service.yaml new file mode 100644 index 0000000..d497fec --- /dev/null +++ b/scoble-policy/opa/opa-service.yaml @@ -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 diff --git a/scoble-policy/opa/opa-serviceaccount.yaml b/scoble-policy/opa/opa-serviceaccount.yaml new file mode 100644 index 0000000..3b58c3e --- /dev/null +++ b/scoble-policy/opa/opa-serviceaccount.yaml @@ -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 diff --git a/scoble-policy/opa/policies/_lib.rego b/scoble-policy/opa/policies/_lib.rego new file mode 100644 index 0000000..ac81764 --- /dev/null +++ b/scoble-policy/opa/policies/_lib.rego @@ -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]) +} diff --git a/scoble-policy/opa/policies/demo-concierge.rego b/scoble-policy/opa/policies/demo-concierge.rego new file mode 100644 index 0000000..6a2c6fe --- /dev/null +++ b/scoble-policy/opa/policies/demo-concierge.rego @@ -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]) +} diff --git a/scoble-policy/opa/policies/demo-watchdog.rego b/scoble-policy/opa/policies/demo-watchdog.rego new file mode 100644 index 0000000..adad5af --- /dev/null +++ b/scoble-policy/opa/policies/demo-watchdog.rego @@ -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. diff --git a/scoble-policy/opa/policies/main-agent.rego b/scoble-policy/opa/policies/main-agent.rego new file mode 100644 index 0000000..a6f2615 --- /dev/null +++ b/scoble-policy/opa/policies/main-agent.rego @@ -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.