scobleclaw#42: refactor OPA Rego to per-agent packages (main_agent, demo_concierge, demo_watchdog, lib). Verifies eng#40 7/7 test cases.

This commit is contained in:
agent-cos 2026-06-08 17:26:47 -07:00
parent 39b21dcda5
commit bfe6e4cdd1
5 changed files with 164 additions and 57 deletions

View file

@ -1,7 +1,10 @@
--- ---
# ConfigMap containing the Rego policy bundle. Mounted at /policies in the # ConfigMap containing the Rego policy bundle. Mounted at /policies in the
# OPA pod. Policies share the `scobleclaw` package; per-agent files add # OPA pod. Per scobleclaw#42 refactor (cycle 142): policies are now split
# their own allow + agent-specific deny rules on top of _lib's base denies. # into per-agent packages — `scobleclaw.lib` (helpers + base deny rules)
# and one package per agent (`scobleclaw.main_agent`,
# `scobleclaw.demo_concierge`, `scobleclaw.demo_watchdog`). API paths are
# `data.scobleclaw.<agent>.{allow,deny,needs_l1}`.
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap
metadata: metadata:
@ -11,11 +14,23 @@ metadata:
app: opa app: opa
component: l0-judge component: l0-judge
data: data:
_lib.rego: | lib.rego: |
package scobleclaw package scobleclaw.lib
# lib.rego — shared helpers + base deny rules for all agents.
#
# Per scobleclaw#42 refactor: this file lives at `package scobleclaw.lib`
# (was `package scobleclaw` in the package-root layout). Per-agent policy
# files (main_agent, demo_concierge, demo_watchdog) `import data.scobleclaw.lib`
# to pull in the constants, helpers, and base deny rules. Each per-agent
# package re-aggregates `base_deny` into its own `deny` set so the API
# surface is `data.scobleclaw.<agent>.deny` (one path per agent).
# Internal email domain. Anything else is treated as external.
internal_email_domain = "scoble.me" 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 = { payment_kamelets = {
"kamelet:source/stripe-charge", "kamelet:source/stripe-charge",
"kamelet:source/paypal-transfer", "kamelet:source/paypal-transfer",
@ -41,14 +56,17 @@ data:
params.repo != "engineering" params.repo != "engineering"
} }
deny[reason] { # Base deny rules. Each per-agent package re-emits these into its own
# `deny` set via `deny[reason] { lib.base_deny[reason] }`, giving each
# agent a single `data.scobleclaw.<agent>.deny` path.
base_deny[reason] {
input.kamelet_ref != "" input.kamelet_ref != ""
input.params.to != "" input.params.to != ""
is_external_email(input.params.domain) is_external_email(input.params.domain)
reason := sprintf("external email not permitted (domain=%v, to=%v)", [input.params.domain, input.params.to]) reason := sprintf("external email not permitted (domain=%v, to=%v)", [input.params.domain, input.params.to])
} }
deny[reason] { base_deny[reason] {
is_forgejo_write_outside_allowed(input.params) is_forgejo_write_outside_allowed(input.params)
reason := sprintf( reason := sprintf(
"forgejo writes restricted to marcus/engineering (got owner=%v repo=%v)", "forgejo writes restricted to marcus/engineering (got owner=%v repo=%v)",
@ -56,12 +74,16 @@ data:
) )
} }
deny[reason] { base_deny[reason] {
is_payment_kamelet(input.kamelet_ref) is_payment_kamelet(input.kamelet_ref)
reason := sprintf("payment kamelet blocked at L0: %v", [input.kamelet_ref]) reason := sprintf("payment kamelet blocked at L0: %v", [input.kamelet_ref])
} }
main-agent.rego: | main-agent.rego: |
package scobleclaw package scobleclaw.main_agent
# main-agent.rego — policy for the primary orchestrator agent.
# Per scobleclaw#42: API path is `data.scobleclaw.main_agent.allow`.
import data.scobleclaw.lib
main_agent_allowed_kamelets = { main_agent_allowed_kamelets = {
"kamelet:source/forgejo-search", "kamelet:source/forgejo-search",
@ -76,6 +98,11 @@ data:
"kamelet:source/age-decrypt", "kamelet:source/age-decrypt",
} }
# Re-emit lib base_deny into the per-agent deny set.
deny[reason] {
lib.base_deny[reason]
}
allow { allow {
input.agent_id == "main-agent" input.agent_id == "main-agent"
main_agent_allowed_kamelets[input.kamelet_ref] main_agent_allowed_kamelets[input.kamelet_ref]
@ -85,10 +112,14 @@ data:
needs_l1 { needs_l1 {
input.agent_id == "main-agent" input.agent_id == "main-agent"
not main_agent_allowed_kamelets[input.kamelet_ref] not main_agent_allowed_kamelets[input.kamelet_ref]
not payment_kamelets[input.kamelet_ref] not lib.payment_kamelets[input.kamelet_ref]
} }
demo-concierge.rego: | demo-concierge.rego: |
package scobleclaw package scobleclaw.demo_concierge
# demo-concierge.rego — policy for the demo concierge agent.
# Per scobleclaw#42: API path is `data.scobleclaw.demo_concierge.allow`.
import data.scobleclaw.lib
demo_concierge_allowed_kamelets = { demo_concierge_allowed_kamelets = {
"kamelet:source/forgejo-search", "kamelet:source/forgejo-search",
@ -97,6 +128,18 @@ data:
"kamelet:source/kamelet-dispatch", "kamelet:source/kamelet-dispatch",
} }
# Re-emit lib base_deny into the per-agent deny set.
deny[reason] {
lib.base_deny[reason]
}
# Per-agent deny: demo-concierge is read-only.
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])
}
allow { allow {
input.agent_id == "demo-concierge" input.agent_id == "demo-concierge"
demo_concierge_allowed_kamelets[input.kamelet_ref] demo_concierge_allowed_kamelets[input.kamelet_ref]
@ -106,16 +149,14 @@ data:
needs_l1 { needs_l1 {
input.agent_id == "demo-concierge" input.agent_id == "demo-concierge"
not demo_concierge_allowed_kamelets[input.kamelet_ref] not demo_concierge_allowed_kamelets[input.kamelet_ref]
not payment_kamelets[input.kamelet_ref] not lib.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: | demo-watchdog.rego: |
package scobleclaw package scobleclaw.demo_watchdog
# demo-watchdog.rego — policy for the demo watchdog agent.
# Per scobleclaw#42: API path is `data.scobleclaw.demo_watchdog.allow`.
import data.scobleclaw.lib
demo_watchdog_allowed_kamelets = { demo_watchdog_allowed_kamelets = {
"kamelet:source/ob1-search", "kamelet:source/ob1-search",
@ -124,6 +165,11 @@ data:
"kamelet:source/kamelet-dispatch", "kamelet:source/kamelet-dispatch",
} }
# Re-emit lib base_deny into the per-agent deny set.
deny[reason] {
lib.base_deny[reason]
}
allow { allow {
input.agent_id == "demo-watchdog" input.agent_id == "demo-watchdog"
demo_watchdog_allowed_kamelets[input.kamelet_ref] demo_watchdog_allowed_kamelets[input.kamelet_ref]
@ -133,5 +179,5 @@ data:
needs_l1 { needs_l1 {
input.agent_id == "demo-watchdog" input.agent_id == "demo-watchdog"
not demo_watchdog_allowed_kamelets[input.kamelet_ref] not demo_watchdog_allowed_kamelets[input.kamelet_ref]
not payment_kamelets[input.kamelet_ref] not lib.payment_kamelets[input.kamelet_ref]
} }

View file

@ -1,9 +1,16 @@
package scobleclaw package scobleclaw.demo_concierge
# demo-concierge.rego — policy for the demo concierge agent. # demo-concierge.rego — policy for the demo concierge agent.
# #
# Scope: read-only Forgejo + OB1 read access. No writes anywhere. This is a # Per scobleclaw#42 refactor: this file now lives at
# demo / smoke-test agent; its allowlist is intentionally small. # `package scobleclaw.demo_concierge` (was `package scobleclaw` in the
# package-root layout). API path is now
# `data.scobleclaw.demo_concierge.allow`.
#
# Scope: read-only Forgejo + OB1 read access. No writes anywhere. This
# is a demo / smoke-test agent; its allowlist is intentionally small.
import data.scobleclaw.lib
demo_concierge_allowed_kamelets = { demo_concierge_allowed_kamelets = {
"kamelet:source/forgejo-search", "kamelet:source/forgejo-search",
@ -12,6 +19,24 @@ demo_concierge_allowed_kamelets = {
"kamelet:source/kamelet-dispatch", "kamelet:source/kamelet-dispatch",
} }
# ---------------------------------------------------------------------------
# Deny set — re-emit base_deny from lib, plus per-agent rules.
# ---------------------------------------------------------------------------
deny[reason] {
lib.base_deny[reason]
}
# Per-agent deny rule: demo-concierge is read-only. Any kamelet whose
# name suggests a Forgejo write (starts with `forgejo-create`) is
# denied at L0. Note: this is in addition to the lib base_deny rules
# (external email, forgejo writes outside engineering, payment kamelets).
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])
}
# Allow only if kamelet is on the allowlist AND no shared deny rule fires. # Allow only if kamelet is on the allowlist AND no shared deny rule fires.
allow { allow {
input.agent_id == "demo-concierge" input.agent_id == "demo-concierge"
@ -24,13 +49,5 @@ allow {
needs_l1 { needs_l1 {
input.agent_id == "demo-concierge" input.agent_id == "demo-concierge"
not demo_concierge_allowed_kamelets[input.kamelet_ref] not demo_concierge_allowed_kamelets[input.kamelet_ref]
not payment_kamelets[input.kamelet_ref] not lib.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

@ -1,9 +1,16 @@
package scobleclaw package scobleclaw.demo_watchdog
# demo-watchdog.rego — policy for the demo watchdog agent. # demo-watchdog.rego — policy for the demo watchdog agent.
# #
# Scope: monitoring-only. Can read OB1 thoughts and ping Discord, but cannot # Per scobleclaw#42 refactor: this file now lives at
# touch Forgejo, kubectl, or any kamelet with side-effects. # `package scobleclaw.demo_watchdog` (was `package scobleclaw` in the
# package-root layout). API path is now
# `data.scobleclaw.demo_watchdog.allow`.
#
# Scope: monitoring-only. Can read OB1 thoughts and ping Discord, but
# cannot touch Forgejo, kubectl, or any kamelet with side-effects.
import data.scobleclaw.lib
demo_watchdog_allowed_kamelets = { demo_watchdog_allowed_kamelets = {
"kamelet:source/ob1-search", "kamelet:source/ob1-search",
@ -12,6 +19,16 @@ demo_watchdog_allowed_kamelets = {
"kamelet:source/kamelet-dispatch", "kamelet:source/kamelet-dispatch",
} }
# ---------------------------------------------------------------------------
# Deny set — re-emit base_deny from lib (watchdog has no per-agent
# deny rules of its own; the lib's external-email / forgejo / payment
# blocks are sufficient).
# ---------------------------------------------------------------------------
deny[reason] {
lib.base_deny[reason]
}
allow { allow {
input.agent_id == "demo-watchdog" input.agent_id == "demo-watchdog"
demo_watchdog_allowed_kamelets[input.kamelet_ref] demo_watchdog_allowed_kamelets[input.kamelet_ref]
@ -21,9 +38,9 @@ allow {
needs_l1 { needs_l1 {
input.agent_id == "demo-watchdog" input.agent_id == "demo-watchdog"
not demo_watchdog_allowed_kamelets[input.kamelet_ref] not demo_watchdog_allowed_kamelets[input.kamelet_ref]
not payment_kamelets[input.kamelet_ref] not lib.payment_kamelets[input.kamelet_ref]
} }
# Watchdog is the only agent allowed to post to Discord from L0. The # 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 # shared `is_external_email` rule still applies, so Discord DMs to
# addresses are blocked. # external addresses are blocked.

View file

@ -1,10 +1,13 @@
package scobleclaw package scobleclaw.lib
# _lib.rego — shared helpers + base deny rules for all agents. # 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` # Per scobleclaw#42 refactor: this file lives at `package scobleclaw.lib`
# definitions. Rego merges rules across files in the same package, so # (was `package scobleclaw` in the package-root layout). Per-agent policy
# helpers below are visible everywhere in `scobleclaw.*`. # files (main_agent, demo_concierge, demo_watchdog) `import data.scobleclaw.lib`
# to pull in the constants, helpers, and base deny rules. Each per-agent
# package re-aggregates `base_deny` into its own `deny` set so the API
# surface is `data.scobleclaw.<agent>.deny` (one path per agent).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Input shape (L0 contract) # Input shape (L0 contract)
@ -64,13 +67,21 @@ is_forgejo_write_outside_allowed(params) {
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Base deny rules — every agent inherits these. An agent file only needs # Base deny rules — every agent inherits these by importing this package
# to add its own `deny` rules on top. # and re-emitting the `base_deny` set into its own `deny` set.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
#
# Why `base_deny` instead of just `deny`? With per-agent packages, a
# `deny` set in this lib package would be exposed at
# `data.scobleclaw.lib.deny` (one shared path) and would force every
# caller to merge it with the per-agent set. By naming it `base_deny`
# here and letting each agent package own the final `deny` set, the
# API stays clean: one `deny` path per agent, and the lib package
# contributes only shared rules.
# Rule 1: no external email. Any kamelet whose params include a `to` field # Rule 1: no external email. Any kamelet whose params include a `to` field
# with an external email domain is denied. # with an external email domain is denied.
deny[reason] { base_deny[reason] {
input.kamelet_ref != "" input.kamelet_ref != ""
input.params.to != "" input.params.to != ""
is_external_email(input.params.domain) is_external_email(input.params.domain)
@ -78,7 +89,7 @@ deny[reason] {
} }
# Rule 2: no Forgejo writes outside marcus/engineering. # Rule 2: no Forgejo writes outside marcus/engineering.
deny[reason] { base_deny[reason] {
is_forgejo_write_outside_allowed(input.params) is_forgejo_write_outside_allowed(input.params)
reason := sprintf( reason := sprintf(
"forgejo writes restricted to marcus/engineering (got owner=%v repo=%v)", "forgejo writes restricted to marcus/engineering (got owner=%v repo=%v)",
@ -87,7 +98,7 @@ deny[reason] {
} }
# Rule 3: no payment kamelets. Period. # Rule 3: no payment kamelets. Period.
deny[reason] { base_deny[reason] {
is_payment_kamelet(input.kamelet_ref) is_payment_kamelet(input.kamelet_ref)
reason := sprintf("payment kamelet blocked at L0: %v", [input.kamelet_ref]) reason := sprintf("payment kamelet blocked at L0: %v", [input.kamelet_ref])
} }

View file

@ -1,13 +1,19 @@
package scobleclaw package scobleclaw.main_agent
# main-agent.rego — policy for the primary orchestrator agent. # main-agent.rego — policy for the primary orchestrator agent.
# #
# Scope: this is the agent that drives engineering work (Forgejo reads/writes, # Per scobleclaw#42 refactor: this file now lives at
# OB1 captures, kamelet dispatch). It has the tightest allowlist. # `package scobleclaw.main_agent` (was `package scobleclaw` in the
# package-root layout). API path is now `data.scobleclaw.main_agent.allow`
# (was `data.scobleclaw.allow`). The shared `lib` package contributes
# the base deny rules, which are re-emitted into the per-agent `deny`
# set below so callers see a single `deny` path per agent.
# #
# This file uses the same package as _lib.rego, so all the shared `deny` # Scope: this is the agent that drives engineering work (Forgejo
# rules apply automatically. We add a per-agent `allow` rule that defines # reads/writes, OB1 captures, kamelet dispatch). It has the tightest
# the kamelet allowlist and a couple of agent-specific deny rules. # allowlist.
import data.scobleclaw.lib
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Per-agent allowlist # Per-agent allowlist
@ -28,6 +34,18 @@ main_agent_allowed_kamelets = {
"kamelet:source/age-decrypt", "kamelet:source/age-decrypt",
} }
# ---------------------------------------------------------------------------
# Deny set — re-emit base_deny from lib, plus per-agent rules.
# ---------------------------------------------------------------------------
# Each rule below re-publishes a base_deny[reason] entry into the
# per-agent `deny` set. This keeps the API surface to a single
# `data.scobleclaw.main_agent.deny` path while still giving lib
# ownership of the shared rules.
deny[reason] {
lib.base_deny[reason]
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Per-agent allow rule # Per-agent allow rule
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -48,7 +66,5 @@ allow {
needs_l1 { needs_l1 {
input.agent_id == "main-agent" input.agent_id == "main-agent"
not main_agent_allowed_kamelets[input.kamelet_ref] not main_agent_allowed_kamelets[input.kamelet_ref]
not payment_kamelets[input.kamelet_ref] not lib.payment_kamelets[input.kamelet_ref]
} }
# Anything not main-agent falls through to the per-agent policy files.