MAQPNADocs

Policy evaluation#

The gateway decides every governed call with one engine, pkg/policy. Its input is a Request (namespace, agent, session, user, server, tool, arguments, groups, scopes, session taints, sinks, time); its output is a Decision (action, policy, rule, reason, optional approval) plus any dry-run decisions and the tool's labels.

The native rule language is MAQPNA's own JSON rules (rendered from ToolPolicy). Two adapters can vote alongside them: Cedar text inside a ToolPolicy (spec.cedar), and an engine-wide OPA sidecar (Rego). Every combination step is "strictest wins", and every adapter failure is a deny.

Evaluation order#

flowchart TD
    REQ[Request] --> LOOP["For each loaded policy document,<br/>sorted by namespace and name"]
    LOOP --> AP{"Applies?<br/>namespace glob and agents glob"}
    AP -- no --> SKIP["skip (trace: skipped, why)"]
    AP -- yes --> RULES["Native rules in order:<br/>servers, tools, argMatch, principals, scopes,<br/>sessionTaints, sinks, schedule, when"]
    RULES --> FM{"first rule that matches"}
    FM --> CED{"Cedar text in this document?"}
    CED -- "yes, determining" --> SW["stricter of rule and Cedar"]
    CED -- "no or not determining" --> KEEP[rule decision]
    FM -- "none matched and Cedar not determining" --> DEF["defaultAction (deny if unset)<br/>reason default_action"]
    SW & KEEP & DEF --> DR{"enforcement dryRun?"}
    DR -- yes --> DRREC["record would-be decision,<br/>contribute dryRunFallback"]
    DR -- no --> CONTRIB[contribute decision]
    DRREC & CONTRIB --> COMB["Combine across documents:<br/>deny > require_approval > allow,<br/>tie on require_approval: larger minApprovers"]
    SKIP --> NONE{"any document applied?"}
    NONE -- no --> NAP["deny · no_applicable_policy"]
    COMB --> GLOB["Engine-wide evaluators (OPA):<br/>stricter result wins"]
    GLOB --> RL{"result not deny?"}
    RL -- yes --> LIM["consume maxCallsPerMinute of matched rules<br/>over limit: deny · rate_limited"]
    RL -- no --> OUT[final decision]
    LIM --> OUT

Step by step:

  1. Applicability. A document applies when its namespace glob matches the caller's namespace and one of its agents globs matches the agent. A document that does not apply is skipped, with a reason in the trace (agent "coder" not in agents).
  2. Native rules. Rules run in order; the first rule whose matchers all pass decides. Matchers run in the order servers, tools, argMatch, principals, scopes, sessionTaints, sinks, schedule, when, and the first that fails is the why in the trace (tool "delete_pod" not in tools, session is not tainted).
  3. Cedar in the same document. When the document carries Cedar text and a Cedar policy is determining, its decision is folded in; the stricter of the native rule and Cedar wins. When neither matched, the document's defaultAction applies (deny when unset), reason default_action.
  4. Dry run. A document with enforcement: dryRun records its would-be decision and contributes only dryRunFallback (allow, or its defaultAction).
  5. Across documents. The most restrictive contribution wins: deny over require_approval over allow. Between two require_approval decisions, the one with the larger approval.minApprovers wins. If no document applied, the result is deny with no_applicable_policy.
  6. Engine-wide evaluators. An OPA sidecar is consulted only after the documents produced a decision, so it can never turn "no applicable policy" into an allow, and it contributes only when it is stricter.
  7. Rate limits. Only when the result is not deny, each matched rule's maxCallsPerMinute is consumed (60-second sliding window per session, policy and rule, per gateway replica). Exceeding it turns the result into deny with rate_limited. A call that is denied anyway never uses the limit.

Decide consumes rate limits; Explain (the policies:evaluate API and maqpna policy test) runs the same evaluation without side effects and returns the trace.

Cedar#

Cedar runs only in binaries built with -tags cedar (pkg/policy/cedarpolicy). In a binary without it, a document with Cedar text denies every call it applies to with reason cedar_unavailable, and the metric maqpna_gateway_cedar_available is 0. The schema is config/cedar/maqpna.cedarschema:

Element Value
principal Maqpna::Agent::"<namespace>/<agent>", attributes namespace, name, session, groups, scopes, optional user; parent Maqpna::Namespace::"<namespace>"
user Maqpna::User::"<user>", in its verified groups Maqpna::Group::"<group>"
action Maqpna::Action::"callTool" (MCP) or Maqpna::Action::"callModel" (/llm with modelPolicy: policy)
resource Maqpna::Tool::"<server>/<tool>", attributes server, name, taints, sinks; parent Maqpna::Server::"<server>". Model calls use server llm/<route> and the operation as tool
context args (tool arguments: integers as Long, other numbers as decimal, arrays as Set), sessionTaints, sinks, time {unix, hour, minute, weekday}

Decision mapping: a determining forbid gives deny; otherwise a determining permit gives allow, or require_approval when it is annotated @action("require_approval"); no determining policy means "not matched". Any evaluation error or panic gives deny with cedar_error. Annotations: @id (the rule name in the ledger, <ToolPolicy>/<id>), @reason, @action, @minApprovers, @approverGroups.

@id("large-transfer")
@reason("four-eyes for transfers above 10000")
@action("require_approval")
@minApprovers("2")
@approverGroups("treasury-supervisors")
permit (
  principal,
  action == Maqpna::Action::"callTool",
  resource == Maqpna::Tool::"payments/create_transfer"
)
when { context.args has amount && context.args.amount > 10000 };

@id("no-sinks-when-tainted")
forbid (principal, action, resource)
when { context.sessionTaints.contains("untrusted-input") && resource.sinks.contains("external") };

OPA sidecar (Rego)#

policy.rego in the gateway configuration registers an engine-wide evaluator. The gateway never links OPA: it POSTs {"input": ...} to OPA's Data API (for example http://127.0.0.1:8181/v1/data/maqpna/authz/decision, default timeout 250 ms) and maps the result: undefined is no contribution; true/false is allow/deny; an object {action, reason, rule, approval} or {allow, reason} is used as given. Transport errors, timeouts, non-2xx answers and malformed results are a deny (rego_unavailable).

What you see#

POST /v1/policies:evaluate (role auditor or admin) and maqpna policy test --explain return the trace. Its shape is {request, decision, dryRun?, labels, policies[]}, each policy {policy, namespace, enforcement, applies, skipped?, decision?, rules[]} and each rule {rule, matched, why}:

{
  "decision": {"action": "require_approval", "policy": "prod-guard", "rule": "k8s-delete", "reason": "destructive"},
  "policies": [
    {"policy": "prod-guard", "namespace": "team-a", "enforcement": "enforce", "applies": true,
     "decision": {"action": "require_approval", "policy": "prod-guard", "rule": "k8s-delete", "reason": "destructive"},
     "rules": [
       {"rule": "read-github", "matched": false, "why": "server \"kubernetes\" not in servers"},
       {"rule": "no-prod", "matched": false, "why": "argMatch[namespace] \"prod*\" not matched"},
       {"rule": "k8s-delete", "matched": true}
     ]}
  ]
}

See maqpna policy test, maqpna replay and maqpna policy lint.