diff --git a/scoble-policy/opa/opa-policies-configmap.yaml b/scoble-policy/opa/opa-policies-configmap.yaml index d308fde..45d46b1 100644 --- a/scoble-policy/opa/opa-policies-configmap.yaml +++ b/scoble-policy/opa/opa-policies-configmap.yaml @@ -1,7 +1,10 @@ --- # 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. +# OPA pod. Per scobleclaw#42 refactor (cycle 142): policies are now split +# 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..{allow,deny,needs_l1}`. apiVersion: v1 kind: ConfigMap metadata: @@ -11,11 +14,23 @@ metadata: app: opa component: l0-judge data: - _lib.rego: | - package scobleclaw + lib.rego: | + 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..deny` (one path per agent). + + # 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", @@ -41,14 +56,17 @@ data: 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..deny` path. + base_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] { + base_deny[reason] { is_forgejo_write_outside_allowed(input.params) reason := sprintf( "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) reason := sprintf("payment kamelet blocked at L0: %v", [input.kamelet_ref]) } 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 = { "kamelet:source/forgejo-search", @@ -76,6 +98,11 @@ data: "kamelet:source/age-decrypt", } + # Re-emit lib base_deny into the per-agent deny set. + deny[reason] { + lib.base_deny[reason] + } + allow { input.agent_id == "main-agent" main_agent_allowed_kamelets[input.kamelet_ref] @@ -85,10 +112,14 @@ data: needs_l1 { input.agent_id == "main-agent" not main_agent_allowed_kamelets[input.kamelet_ref] - not payment_kamelets[input.kamelet_ref] + not lib.payment_kamelets[input.kamelet_ref] } 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 = { "kamelet:source/forgejo-search", @@ -97,6 +128,18 @@ data: "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 { input.agent_id == "demo-concierge" demo_concierge_allowed_kamelets[input.kamelet_ref] @@ -106,16 +149,14 @@ data: 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]) + not lib.payment_kamelets[input.kamelet_ref] } 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 = { "kamelet:source/ob1-search", @@ -124,6 +165,11 @@ data: "kamelet:source/kamelet-dispatch", } + # Re-emit lib base_deny into the per-agent deny set. + deny[reason] { + lib.base_deny[reason] + } + allow { input.agent_id == "demo-watchdog" demo_watchdog_allowed_kamelets[input.kamelet_ref] @@ -133,5 +179,5 @@ data: needs_l1 { input.agent_id == "demo-watchdog" not demo_watchdog_allowed_kamelets[input.kamelet_ref] - not payment_kamelets[input.kamelet_ref] + not lib.payment_kamelets[input.kamelet_ref] } diff --git a/scoble-policy/opa/policies/demo-concierge.rego b/scoble-policy/opa/policies/demo-concierge.rego index 6a2c6fe..1dc1f8a 100644 --- a/scoble-policy/opa/policies/demo-concierge.rego +++ b/scoble-policy/opa/policies/demo-concierge.rego @@ -1,9 +1,16 @@ -package scobleclaw +package scobleclaw.demo_concierge # 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. +# Per scobleclaw#42 refactor: this file now lives at +# `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 = { "kamelet:source/forgejo-search", @@ -12,6 +19,24 @@ demo_concierge_allowed_kamelets = { "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 { input.agent_id == "demo-concierge" @@ -24,13 +49,5 @@ allow { 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]) + not lib.payment_kamelets[input.kamelet_ref] } diff --git a/scoble-policy/opa/policies/demo-watchdog.rego b/scoble-policy/opa/policies/demo-watchdog.rego index adad5af..ac1cb47 100644 --- a/scoble-policy/opa/policies/demo-watchdog.rego +++ b/scoble-policy/opa/policies/demo-watchdog.rego @@ -1,9 +1,16 @@ -package scobleclaw +package scobleclaw.demo_watchdog # 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. +# Per scobleclaw#42 refactor: this file now lives at +# `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 = { "kamelet:source/ob1-search", @@ -12,6 +19,16 @@ demo_watchdog_allowed_kamelets = { "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 { input.agent_id == "demo-watchdog" demo_watchdog_allowed_kamelets[input.kamelet_ref] @@ -21,9 +38,9 @@ allow { needs_l1 { input.agent_id == "demo-watchdog" 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 -# shared `is_external_email` rule still applies, so Discord DMs to external -# addresses are blocked. +# shared `is_external_email` rule still applies, so Discord DMs to +# external addresses are blocked. diff --git a/scoble-policy/opa/policies/_lib.rego b/scoble-policy/opa/policies/lib.rego similarity index 72% rename from scoble-policy/opa/policies/_lib.rego rename to scoble-policy/opa/policies/lib.rego index ac81764..ab5dcc7 100644 --- a/scoble-policy/opa/policies/_lib.rego +++ b/scoble-policy/opa/policies/lib.rego @@ -1,10 +1,13 @@ -package scobleclaw +package scobleclaw.lib -# _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.*`. +# 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..deny` (one path per agent). # --------------------------------------------------------------------------- # 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 -# to add its own `deny` rules on top. +# Base deny rules — every agent inherits these by importing this package +# 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 # with an external email domain is denied. -deny[reason] { +base_deny[reason] { input.kamelet_ref != "" input.params.to != "" is_external_email(input.params.domain) @@ -78,7 +89,7 @@ deny[reason] { } # Rule 2: no Forgejo writes outside marcus/engineering. -deny[reason] { +base_deny[reason] { is_forgejo_write_outside_allowed(input.params) reason := sprintf( "forgejo writes restricted to marcus/engineering (got owner=%v repo=%v)", @@ -87,7 +98,7 @@ deny[reason] { } # Rule 3: no payment kamelets. Period. -deny[reason] { +base_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/main-agent.rego b/scoble-policy/opa/policies/main-agent.rego index a6f2615..3f34f56 100644 --- a/scoble-policy/opa/policies/main-agent.rego +++ b/scoble-policy/opa/policies/main-agent.rego @@ -1,13 +1,19 @@ -package scobleclaw +package scobleclaw.main_agent # 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. +# Per scobleclaw#42 refactor: this file now lives at +# `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` -# rules apply automatically. We add a per-agent `allow` rule that defines -# the kamelet allowlist and a couple of agent-specific deny rules. +# Scope: this is the agent that drives engineering work (Forgejo +# reads/writes, OB1 captures, kamelet dispatch). It has the tightest +# allowlist. + +import data.scobleclaw.lib # --------------------------------------------------------------------------- # Per-agent allowlist @@ -28,6 +34,18 @@ main_agent_allowed_kamelets = { "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 # --------------------------------------------------------------------------- @@ -48,7 +66,5 @@ allow { needs_l1 { input.agent_id == "main-agent" 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.