From 887ed949fab1dac021231658b557a07a3e0652ef Mon Sep 17 00:00:00 2001 From: agent-cos Date: Tue, 9 Jun 2026 20:42:42 -0700 Subject: [PATCH] fix(opa): regenerate opa-policies-configmap from on-disk policies (eng#118, cycle 175) Configmap was stale from commit 39b21dc; on-disk policies were updated by scobleclaw#42 (commit bfe6e4c) but configmap was never re-applied. This caused OPA /decision (OLD policy) to disagree with /allow and /needs_l1 (NEW policy) for kamelet_ref=search-memory. Re-generates the configmap from the current policies/*.rego files. The configmap is also namespace-agnostic now (namespace field stripped from the manifest) so it can be applied to scoble-policy namespace via kubectl -n scoble-policy apply -f. --- scoble-policy/opa/opa-policies-configmap.yaml | 183 ++++++++++++++---- 1 file changed, 146 insertions(+), 37 deletions(-) diff --git a/scoble-policy/opa/opa-policies-configmap.yaml b/scoble-policy/opa/opa-policies-configmap.yaml index 45d46b1..addf3a0 100644 --- a/scoble-policy/opa/opa-policies-configmap.yaml +++ b/scoble-policy/opa/opa-policies-configmap.yaml @@ -9,14 +9,13 @@ apiVersion: v1 kind: ConfigMap metadata: name: opa-policies - namespace: scobleclaw labels: app: opa component: l0-judge data: 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` @@ -25,10 +24,25 @@ data: # 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) + # --------------------------------------------------------------------------- + # { + # "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 = { @@ -37,35 +51,60 @@ data: "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. 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 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. 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]) } - + + # Rule 2: no Forgejo writes outside marcus/engineering. base_deny[reason] { is_forgejo_write_outside_allowed(input.params) reason := sprintf( @@ -73,18 +112,36 @@ data: [input.params.owner, input.params.repo], ) } - + + # Rule 3: no payment kamelets. Period. 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.main_agent - + # main-agent.rego — policy for the primary orchestrator agent. - # Per scobleclaw#42: API path is `data.scobleclaw.main_agent.allow`. + # + # 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. + # + # 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 + # --------------------------------------------------------------------------- + + # 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", @@ -97,18 +154,36 @@ data: "kamelet:source/kubectl-exec", "kamelet:source/age-decrypt", } - - # Re-emit lib base_deny into the per-agent deny set. + + # --------------------------------------------------------------------------- + # 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 + # --------------------------------------------------------------------------- + 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] @@ -116,36 +191,53 @@ data: } demo-concierge.rego: | package scobleclaw.demo_concierge - + # demo-concierge.rego — policy for the demo concierge agent. - # Per scobleclaw#42: API path is `data.scobleclaw.demo_concierge.allow`. + # + # 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", "kamelet:source/forgejo-read", "kamelet:source/ob1-search", "kamelet:source/kamelet-dispatch", } - - # Re-emit lib base_deny into the per-agent deny set. + + # --------------------------------------------------------------------------- + # Deny set — re-emit base_deny from lib, plus per-agent rules. + # --------------------------------------------------------------------------- + deny[reason] { lib.base_deny[reason] } - - # Per-agent deny: demo-concierge is read-only. + + # 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" 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] @@ -153,31 +245,48 @@ data: } demo-watchdog.rego: | package scobleclaw.demo_watchdog - + # demo-watchdog.rego — policy for the demo watchdog agent. - # Per scobleclaw#42: API path is `data.scobleclaw.demo_watchdog.allow`. + # + # 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", "kamelet:source/ob1-list", "kamelet:source/discord-post", "kamelet:source/kamelet-dispatch", } - - # Re-emit lib base_deny into the per-agent deny set. + + # --------------------------------------------------------------------------- + # 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] count(deny) == 0 } - + needs_l1 { input.agent_id == "demo-watchdog" not demo_watchdog_allowed_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.