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[]
- resource: mcp-tool://send_email actions: [call]
✅ tools/call send_email { ... } eligible for further checks ❌ anything else without a matching action/resource pair not matched
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
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. - resource: send_email actions: [call] argumentSchema: type: object required: [to, body] properties: to: { type: string } body: { type: string, maxLength: 10000 } additionalProperties: false
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
tools/call query_db {
sql: "DROP TABLE users"
}
conditions: - type: allowedOperations operations: [SELECT]
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
allowedExtensionstools/call read_file {
path: "/data/keys.pem"
}
conditions: - type: allowedExtensions extensions: [".csv", ".json", ".txt", ".md"]
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
tools/call insert_row {
table: "credentials",
row: { ... }
}
conditions: - type: allowedTables tables: [orders, customers, products]
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
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
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
timeWindowtools/call truncate_logs { ... }
conditions: - type: timeWindow notBefore: "2026-05-09T01:00:00Z" notAfter: "2026-05-09T03:00:00Z"
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
ipRangesourceIp: 203.0.113.7 tools/call rotate_token { ... }
conditions: - type: ipRange cidrs: ["10.0.0.0/8", "192.168.0.0/16"]
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
recipientDomaintools/call send_message {
to: "attacker@evil.com",
text: "..."
}
conditions: - type: recipientDomain domains: [company.com]
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{
"rows": [
{ "id": 1,
"email": "alice@x.com",
"ssn": "123-45-6789" },
...
]
}
conditions: - type: redactFields fields: ["rows.ssn", "rows.email"]
"[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
policymodule.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' };
},
});
};
# 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
--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
custommodule.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 };
},
},
);
};
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 ~/.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 -F ~/.euno/audit.jsonl | jq $ npx -y @euno/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.@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.
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.