Features

The full condition matrix

Every guardrail you can attach to a capability — with a worked tool call, the policy that catches it, and the structured outcome the agent receives.

Policy controls

Every condition runs before the upstream is contacted. Decisions return a structured CapabilityDenied with a stable errorCode, the conditionType that fired, and a human-readable reason — so agents and humans can both reason about the outcome.

allowed actions (actions[]) — base capability gate

actions[] is the primary allowlist for each capability. A tool call must first match both resource and action. For MCP tool calls, action is usually call, so only constraints listing call are eligible.

Allow only call on one tool

actions[]
Policy
- resource: mcp-tool://send_email
                    actions: [call]
What this means
tools/call send_email { ... }  eligible for further checks
                    ❌ anything else without a matching action/resource pair not matched
Outcome: action/resource matching is evaluated before argumentSchema and before conditions[]. Keep actions[] explicit and minimal.

argumentSchema — JSON Schema validation

Validate the shape of the agent's arguments before condition handlers run. argumentSchema is a top-level capability field (not a conditions[] entry), and string pattern rules are evaluated as whole-value matches.

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.
- resource: send_email
  actions: [call]
  argumentSchema:
    type: object
    required: [to, body]
    properties:
      to:   { type: string }
      body: { type: string, maxLength: 10000 }
    additionalProperties: false
Outcome: ARGUMENT_VALIDATION_FAILED · body must be string — upstream never contacted.

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
    operations: [SELECT]
Outcome: OPERATION_NOT_ALLOWED · 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. This condition is allowlist-based, not denylist-based, and checks filePath / path / file / filename arguments.

Block reading a private key

allowedExtensions
Tool call
tools/call read_file {
  path: "/data/keys.pem"
}
Policy
conditions:
  - type: allowedExtensions
    extensions:
      [".csv", ".json", ".txt", ".md"]
Outcome: EXTENSION_NOT_ALLOWED · .pem ∉ allowedExtensions.

allowedTables — database table allowlist

Restrict which tables an agent can read or write to. Inspects table / tables arguments.

Block writes to credentials

allowedTables
Tool call
tools/call insert_row {
  table: "credentials",
  row: { ... }
}
Policy
conditions:
  - type: allowedTables
    tables:
      [orders, customers, products]
Outcome: TABLE_NOT_ALLOWED · 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: MAX_CALLS_EXCEEDED · 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: TIME_WINDOW_DENIED · 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: IP_RANGE_DENIED. With a stdio session the same condition is denied with reason "ipRange requires sourceIp in request context".

recipientDomain — email / handle allowlist

Stop accidental data exfiltration over messaging tools. Inspects to, recipients, cc, bcc arguments.

Slack DM only to your domain

recipientDomain
Tool call
tools/call send_message {
  to:   "attacker@evil.com",
  text: "..."
}
Policy
conditions:
  - type: recipientDomain
    domains: [company.com]
Outcome: RECIPIENT_DOMAIN_DENIED · evil.com ∉ allowlist.

redactFields — strip from upstream responses

A response-path obligation: enforcement always allows the call, but the named JSON paths are redacted from what the agent sees.

Hide PII from query results

redactFields
Upstream response
{
  "rows": [
    { "id": 1,
      "email": "alice@x.com",
      "ssn": "123-45-6789" },
    ...
  ]
}
Policy
conditions:
  - type: redactFields
    fields:
      ["rows.ssn",
       "rows.email"]
Outcome: response forwarded with the named dotted-path fields replaced by "[redacted]"; the original is never seen by the agent.

policy — delegate to an external engine

Plug in OPA, Cedar, an authorization service, or anything callable from Node.js. Modules are loaded with the repeatable --policy-backend flag and registered by name.

OPA-backed authorization

policy
Backend module
module.exports = (api) => {
  api.registerPolicyBackend('opa', {
    validate(cfg) { /* ... */ },
    async enforce(cfg, input, ctx) {
      const ok = await opa.eval(cfg.path, {
        input, ip: ctx.sourceIp,
      });
      return ok
        ? { allow: true }
        : { allow: false,
            reason: 'opa: denied' };
    },
  });
};
Policy + invocation
# in euno.policy.yaml
conditions:
  - type: policy
    backend: opa
    config:
      path: data.tools.allow

# run with
$ npx -y @euno/mcp proxy \
  --policy ./euno.policy.yaml \
  --policy-backend ./opa-backend.js \
  -- node ./upstream.js
Outcome: the proxy delegates the decision; --policy-backend is repeatable and module errors fail-fast at startup.

custom — register your own condition types

Need something specific to your domain? Register a handler under a name and reference it from any policy. Loaded with the repeatable --custom-condition flag.

Block recipients on a deny-list

custom
Custom handler
module.exports = (api) => {
  api.registerCustomCondition(
    'denyBlockedRecipient',
    {
      validate(cfg) { /* schema */ },
      async evaluate(cfg, args) {
        if (BLOCKLIST.has(args.to)) {
          return { allow: false,
            reason: 'recipient blocked' };
        }
        return { allow: true };
      },
    },
  );
};
Policy
conditions:
  - type: custom
    name: denyBlockedRecipient
    config: { }
Outcome: handler-defined deny code surfaces in the audit log alongside built-in conditions.

OCSF audit log

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

Inspect & verify

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

$ npx -y @euno/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.

@euno/langchain — same enforcement, in-process

For LangChain.js agents that run tools in the same process, @euno/langchain places the same condition engine inside the tool wrapper — no proxy process, no transport hop, same YAML policy.

import { createLocalRuntime, wrapAsLangChainTool } from '@euno/langchain';

const runtime = await createLocalRuntime({ policyFile: './euno.policy.yaml' });

const queryTool = wrapAsLangChainTool(runtime, {
  name: 'query_db',
  description: 'Run a read-only SQL query',
  schema: { type: 'object', required: ['sql'],
            properties: { sql: { type: 'string' } } },
  handler: async ({ sql }) => db.query(String(sql)),
});

If the model produces { sql: 'DROP TABLE users' }, the wrapper throws CapabilityDenialError with OPERATION_NOT_ALLOWED before db.query is ever called.

Schema parity. The policy file is a literal subset of AgentCapabilityManifest from @euno/common-core. @euno/mcp imports condition types directly — there is exactly one shape and zero drift between the proxy and the rest of the system.

Try it in 60 seconds →