eve-policy
v0.1.0
Published
Declarative governance and policy enforcement for Vercel Eve agents — deny, escalate, audit, and allow tool calls with composable rules, OWASP Agentic Top 10 coverage, and tamper-evident audit trails.
Maintainers
Readme
eve-policy
Declarative governance and policy enforcement for Vercel Eve agents.
Define rules once. Attach them to any Eve tool. Get deny, escalate (human-in-the-loop), audit, and allow semantics — automatically wired into Eve's needsApproval and execute lifecycle — with zero boilerplate and no runtime overhead on the evaluation hot path.
Why eve-policy?
AI agents that call real tools — banking APIs, data stores, messaging systems — need governance that goes beyond "trust the model." You need to be able to say: this tool cannot be called by a subagent, this amount requires human approval, these fields must be redacted in audit logs, this call pattern is a known OWASP Agentic risk. Writing that logic inside individual tools creates drift; writing it inside the agent loop couples your business rules to the framework.
eve-policy puts governance in its own layer: a composable, testable, auditable policy that sits between Eve's lifecycle events and your tool's implementation — the same way a firewall sits between a network and your servers.
Features
- Four-tier governance —
deny(block + throw) →escalate(human-in-the-loop vianeedsApproval) →audit(log + execute) →allow - Fail-closed by default — no rule match defaults to
deny, notallow - Eve-native lifecycle — integrates with
needsApproval(sync, limited context) andexecute(async, full session) exactly as the Eve spec requires - Two-phase evaluation — input-based controls fire at approval time; session-aware controls (subagent detection, principal checks) fire at execution time
- Composable —
compose()merges any number of policies; strictest effect wins across all of them - OWASP Agentic Top 10 (2026) — built-in reference policy covering all 10 ASI risks out of the box
- Financial services baseline — pre-built rules for CTR thresholds (USD/NGN/KES), OFAC/FATF sanctions screening, card data protection, KYC/AML hooks
- Rich matchers —
toolNameIs,amountExceeds,isSubagentCall,fieldMatches,and,or,not, and more - SIEM-compatible audit trail — structured NDJSON, auto-sanitized inputs, pluggable backends
- Coverage reporting —
uncoveredOwaspRisks()tells you exactly which ASI risks your policy does not address - TypeScript-first — full generics, strict mode, ESM-only
Installation
pnpm add eve-policy
# or
npm install eve-policyRequirements: Node.js >= 24, Eve >= 0.10.0 (peer dependency)
Quick start
import { definePolicy, withNamedPolicy } from "eve-policy";
import { deny, escalate, audit } from "eve-policy/rules";
import { toolNameIs, amountExceeds, isSubagentCall, and } from "eve-policy/rules";
import { FileAuditLogger } from "eve-policy/audit";
// 1. Define your policy
const transferPolicy = definePolicy({
name: "transfer-controls",
version: "1.0.0",
rules: [
deny("no-self-transfer",
ctx => ctx.toolInput.source === ctx.toolInput.destination,
"Source and destination accounts cannot be the same"),
escalate("ctr-threshold",
amountExceeds("amount", 10_000),
"Exceeds $10,000 CTR threshold — compliance review required",
{ riskLevel: "critical", owaspRisks: ["ASI02"] }),
escalate("subagent-financial-write",
and(isSubagentCall(), toolNameIs("transfer_funds")),
"Financial writes from subagents require human approval",
{ riskLevel: "critical", owaspRisks: ["ASI03"] }),
audit("all-transfers",
toolNameIs("transfer_funds"),
"All fund transfers logged for compliance"),
],
});
// 2. Wrap your Eve tool — one line, drop-in replacement
const safeTool = withNamedPolicy("transfer_funds", transferTool, transferPolicy, {
auditLogger: new FileAuditLogger("/var/log/agent-audit.jsonl"),
});
// 3. Use it in your agent — Eve calls needsApproval and execute automaticallyHow it works
Eve exposes two lifecycle hooks on every tool: needsApproval (synchronous, called before the turn completes to decide whether to pause for human approval) and execute (async, called when the tool actually runs). eve-policy wraps both.
Eve lifecycle eve-policy evaluation
─────────────────────────────────────────────────────────────
turn starts
│
▼
needsApproval(ctx) ──────► evaluatePolicy(policy, approvalCtx)
│ │ rule 1: match? → effect
│ │ rule 2: match? → effect
│ escalate? return true │ ...
│ otherwise: return false │ default effect
│
│ [human approves if needsApproval=true]
│
▼
execute(input, ctx) ──────► evaluatePolicy(policy, executeCtx)
│ │ full session available here:
│ │ session.id, session.auth,
│ │ session.parent (subagent?)
│
├── deny? → throw PolicyDenialError (tool never called)
├── escalate? → run tool, write audit entry
├── audit? → run tool, write audit entry
└── allow? → run tool (no audit by default)Why two evaluation passes? Eve's needsApproval is synchronous and does not provide session context — it runs before the turn resolves, so there is no session yet. This means input-based controls (large amounts, sensitive field patterns) can fire at approval time, while session-based controls (subagent detection, principal type) fire again in execute where the full ToolContext.session is available.
Core concepts
The four effects
| Effect | needsApproval | execute | Audit |
|--------|----------------|-----------|-------|
| deny | false | throws PolicyDenialError | yes (always) |
| escalate | true | runs tool if approved, writes audit | yes |
| audit | false | runs tool, writes audit | yes |
| allow | false | runs tool | opt-in |
Rules are evaluated in declaration order. First match wins. When multiple policies are composed with compose(), the strictest effect across all policies wins: deny > escalate > audit > allow.
Fail-closed semantics
When no rule matches, the defaultEffect applies. The default for defaultEffect is "deny" — a governance layer with no matching rules should not silently permit execution. Set defaultEffect: "allow" explicitly if your policy's stated intent is "allow by default, restrict specific patterns."
Synchronous evaluation
All rule match predicates must be synchronous. This is not a limitation — it is a deliberate constraint. Governance decisions on the hot path should never block on network calls, database lookups, or async I/O. External lookups belong in pre-execution middleware, not inside policy rules. Async work (audit logging) runs fire-and-forget after the decision is made.
Composing policies
compose() is the primary way to build production policies. It flattens the rules from all component policies in order and applies deny-wins semantics for the default effect.
import { compose } from "eve-policy";
import { owaspTop10Policy, financialBaselinePolicy } from "eve-policy/profiles";
import { ndpaPolicy, cbnFintechPolicy } from "@comply54/adapter-eve/profiles";
// Full Nigerian fintech stack: OWASP + financial baseline + NDPA + CBN
const nigeriaFintechPolicy = compose(
owaspTop10Policy, // 20 rules — ASI01–ASI10
financialBaselinePolicy, // 15 rules — CTR, sanctions, card data, KYC
ndpaPolicy, // 10 rules — NDPA 2023 full NDPC profile
cbnFintechPolicy, // 8 rules — CBN AML/CFT, NFIU reporting, structuring
);
// compose() semantics:
// • Rules are evaluated in the order they appear across all component policies
// • First match wins within a single evaluate() call
// • defaultEffect is the STRICTEST default across all component policies
// • If any component is defaultEffect:"deny", the composed policy is defaultEffect:"deny"Avoiding catch-all rules in composable policies: If you add a rule that matches always() as a fallback in a component policy, it will intercept every tool call before later policies' specific rules can fire. Use defaultEffect for fallthrough behaviour instead.
API reference
definePolicy(config)
Validate and freeze a policy at definition time. Throws PolicyDefinitionError immediately for duplicate rule names, empty rule names, or invalid effect values — fail fast at startup, not at runtime.
const p = definePolicy({
name: "my-policy",
version: "1.0.0",
description: "Optional, shown in audit entries and coverage reports",
defaultEffect: "deny", // "deny" | "escalate" | "audit" | "allow" — default: "deny"
rules: [ /* PolicyRule[] */ ],
});compose(...policies)
import { compose } from "eve-policy";
const fullPolicy = compose(owaspTop10Policy, myDomainPolicy, myBusinessRules);withNamedPolicy(toolName, tool, policy, options?)
Wrap an Eve ToolDefinition with governance. Returns a new ToolDefinition with governed needsApproval and execute.
The toolName parameter must match the slug Eve derives from the tool file path (e.g. agent/tools/transfer_funds.ts → "transfer_funds"). This is the value matched by toolNameIs() and toolNameIn() matchers.
const safeTool = withNamedPolicy("transfer_funds", originalTool, policy, {
auditLogger: new ConsoleAuditLogger(),
logAllowDecisions: false, // default: only log deny/escalate/audit decisions
});withPolicy(tool, policy, options?)
Convenience wrapper that uses the policy's name as the tool name. Prefer withNamedPolicy when toolNameIs/toolNameIn matchers must match an exact Eve tool slug.
defineGovernanceHook(policy, options?)
Create a cross-cutting audit observer using Eve's event system. Unlike withNamedPolicy, this watches all tool calls without modifying individual tools — suitable for global monitoring or compliance reporting.
import { defineGovernanceHook } from "eve-policy";
import { ConsoleAuditLogger } from "eve-policy/audit";
const hook = defineGovernanceHook(myPolicy, {
auditLogger: new ConsoleAuditLogger(),
eventTypes: ["actions.requested", "action.result", "authorization.required"],
});
agent.addHook(hook.eventTypes, hook.handle.bind(hook));evaluatePolicy(policy, ctx)
Low-level synchronous evaluation. Returns a PolicyDecision without executing anything. Use this in tests, custom integrations, or pre-flight checks.
import { evaluatePolicy } from "eve-policy";
import type { PolicyContext } from "eve-policy";
const ctx: PolicyContext = {
toolName: "transfer_funds",
toolInput: { amount: 6_500_000, currency: "NGN" },
approvedTools: new Set(),
// session is optional — provide it when testing session-aware rules
session: { id: "sess-001", turn: { id: "turn-1", sequence: 1 }, auth: { current: null, initiator: null } },
};
const decision = evaluatePolicy(policy, ctx);
// {
// effect: "escalate",
// ruleName: "cbn-ngn-ctr",
// policyName: "cbn-fintech",
// requiresApproval: true,
// reason: "Exceeds ₦5M CBN CTR threshold — NFIU reporting required",
// riskLevel: "critical",
// owaspRisks: ["ASI02"],
// }Rule helpers (eve-policy/rules)
Effect factories
import { deny, escalate, audit, allow } from "eve-policy/rules";
deny("rule-name", matchFn, "Reason surfaced in errors and audit logs", {
riskLevel: "critical", // "critical" | "high" | "medium" | "low"
owaspRisks: ["ASI01"], // OWASP Agentic Top 10 risk tags
})Pre-built matchers
import {
// Tool name
toolNameIs, toolNameIn, toolNameStartsWith, toolNameEndsWith,
toolNameContains, toolNameMatches,
// Input fields
amountExceeds, amountBetween,
fieldEquals, fieldContains, fieldMatches,
fieldPresent, fieldAbsent,
// Session / identity
isSubagentCall, principalTypeIs, initiatorTypeIs,
// Approval state
notYetApproved, alreadyApproved,
// Logic combinators
and, or, not, always, never,
} from "eve-policy/rules";Examples
// Deny all shell-like tool names
deny("no-shell", toolNameIn("bash", "shell", "exec", "run_command"), "Shell execution prohibited")
// Escalate large NGN transfers (CBN CTR threshold)
escalate("ngn-ctr",
ctx => {
const i = ctx.toolInput as { amount?: number; currency?: string };
return i.currency === "NGN" && (i.amount ?? 0) > 5_000_000;
},
"Exceeds ₦5M CBN reporting threshold",
{ riskLevel: "critical", owaspRisks: ["ASI02"] })
// Require human approval for subagent financial writes
escalate("subagent-financial-write",
and(isSubagentCall(), toolNameMatches(/^(transfer|payment|disburse)/)),
"Subagent financial write — principal separation required",
{ riskLevel: "critical", owaspRisks: ["ASI03"] })
// Deny card PAN patterns in any input field
deny("no-card-pan",
fieldMatches("*", /\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13})\b/),
"Card PAN detected in tool input — ASI02 data exfiltration vector",
{ riskLevel: "critical", owaspRisks: ["ASI02"] })
// Escalate cross-border data transfers to non-adequate countries
deny("no-cross-border-inadequate",
ctx => {
const dest = (ctx.toolInput as Record<string, unknown>).destination_country as string | undefined;
const inadequate = new Set(["CN", "RU", "BY", "KP", "IR"]);
return dest !== undefined && inadequate.has(dest);
},
"Cross-border transfer to non-adequate country — NDPA §43 requires adequacy decision",
{ riskLevel: "critical" })Built-in profiles (eve-policy/profiles)
owaspTop10Policy
Full coverage of the OWASP Top 10 for Agentic Applications 2026:
| Risk | Coverage |
|------|----------|
| ASI01 — Goal Hijack | Deny shell tools, prompt injection patterns, goal-modification language |
| ASI02 — Tool Misuse | Deny unapproved file writes, card PAN in input; escalate network calls |
| ASI03 — Identity Abuse | Escalate privileged tools invoked from subagents |
| ASI04 — Supply Chain | Escalate runtime package installs and dynamic tool registration |
| ASI05 — Code Execution | Deny eval()/exec() patterns, escalate code runner tools |
| ASI06 — Context Poisoning | Escalate memory writes; audit memory reads |
| ASI07 — Inter-Agent Comms | Escalate cross-agent and cross-session invocations |
| ASI08 — Cascading Failures | Escalate bulk operations exceeding 100 items |
| ASI09 — Trust Exploitation | Deny impersonation language; escalate approval-bypass patterns |
| ASI10 — Rogue Agents | Deny self-replication and self-deployment; escalate autonomous scheduling |
financialBaselinePolicy
Jurisdiction-agnostic baseline for financial services agents:
- Deny: self-transfers, negative amounts, sanctioned countries (OFAC/FATF list), card PANs, CVV/CVV2 in input
- Escalate: USD/NGN/KES CTR thresholds, international wire transfers, KYC bypass language, account closure, subagent financial writes
- Audit: customer PII access, moderate-value transactions (USD 1K–10K), report generation
- Allow: explicit read-only tools (
get_exchange_rate,get_balance,check_kyc_status)
readOnlyFinancialPolicy
Deny-by-default policy that only allows get_*, list_*, check_*, query_* tools. Use for read-only agent modes or scoped sub-agents with restricted access.
Audit logging (eve-policy/audit)
Backends
import {
ConsoleAuditLogger, // stdout NDJSON — integrates with any log shipper (Datadog, Splunk, etc.)
FileAuditLogger, // append-only NDJSON file
MultiAuditLogger, // fan out to multiple backends simultaneously
InMemoryAuditLogger, // for tests — entries readable at logger.entries
NoopAuditLogger, // discard all (default when no logger provided)
} from "eve-policy/audit";
// Production: SIEM + local backup
const logger = new MultiAuditLogger([
new FileAuditLogger("/var/log/agent-audit.jsonl"),
new ConsoleAuditLogger(),
]);AuditEntry schema
interface AuditEntry {
id: string; // UUID v4 — unique per decision
timestamp: string; // ISO 8601
toolName: string;
effect: "deny" | "escalate" | "audit" | "allow";
ruleName: string; // which rule fired (or "default")
policyName: string;
reason: string;
riskLevel: "critical" | "high" | "medium" | "low";
owaspRisks: string[]; // e.g. ["ASI01", "ASI03"]
sessionId?: string; // from Eve session context
principalId?: string;
principalType?: string;
sanitizedInput: Record<string, unknown>; // secrets and PII redacted
evaluationMs: number; // policy evaluation latency
outcome: "denied" | "escalated" | "executed" | "approved";
}Automatic sanitization: Keys matching patterns like password, token, api_key, cvv, secret, ssn, nin, pin are replaced with [REDACTED]. Strings longer than 500 characters are truncated to prevent log bloat.
Compliance reporting
import { owaspCoverageReport, uncoveredOwaspRisks } from "eve-policy";
import { owaspTop10Policy, financialBaselinePolicy } from "eve-policy/profiles";
import { compose } from "eve-policy";
const myPolicy = compose(owaspTop10Policy, financialBaselinePolicy);
// Full coverage breakdown — which rules address which OWASP risks
const report = owaspCoverageReport(myPolicy);
// {
// ASI01: { risk: "ASI01", coveredBy: ["asi01-prompt-injection-shell", "asi01-goal-hijack"] },
// ASI02: { risk: "ASI02", coveredBy: ["no-card-pan", "ctr-threshold"] },
// ...
// }
// Audit gate — fail your CI pipeline if coverage is incomplete
const gaps = uncoveredOwaspRisks(myPolicy);
if (gaps.length > 0) {
throw new Error(`Policy missing OWASP coverage: ${gaps.join(", ")}`);
}Error handling
PolicyDenialError is the only error thrown by eve-policy itself. All other errors propagate from the underlying tool.
import { PolicyDenialError } from "eve-policy";
try {
await safeTool.execute(input, ctx);
} catch (err) {
if (PolicyDenialError.is(err)) {
// Structured — safe to serialise and return to the caller
console.log(err.policyName); // "transfer-controls"
console.log(err.ruleName); // "cbn-ngn-ctr"
console.log(err.reason); // "Exceeds ₦5M CBN reporting threshold"
console.log(err.riskLevel); // "critical"
console.log(err.owaspRisks); // ["ASI02"]
console.log(err.toJSON()); // serializable shape for API responses
return { error: "policy_denial", rule: err.ruleName, reason: err.reason };
}
throw err; // re-throw — not a policy error
}Testing policies
Use evaluatePolicy and InMemoryAuditLogger to write fast, dependency-free policy tests. No Eve runtime needed.
import { describe, it, expect } from "@jest/globals";
import { evaluatePolicy } from "eve-policy";
import { InMemoryAuditLogger } from "eve-policy/audit";
import { withNamedPolicy } from "eve-policy";
import { PolicyDenialError } from "eve-policy";
import { nigeriaFintechPolicy } from "@comply54/adapter-eve/compose";
import type { PolicyContext } from "eve-policy";
// Helpers
function ctx(toolName: string, input: Record<string, unknown> = {}): PolicyContext {
return { toolName, toolInput: input, approvedTools: new Set() };
}
// Test the policy directly (no tool needed)
describe("nigeriaFintechPolicy", () => {
it("escalates NGN transfers above ₦5M", () => {
const d = evaluatePolicy(nigeriaFintechPolicy,
ctx("transfer_funds", { amount: 6_000_000, currency: "NGN" }));
expect(d.effect).toBe("escalate");
expect(d.owaspRisks).toContain("ASI02");
});
it("denies breach suppression language", () => {
const d = evaluatePolicy(nigeriaFintechPolicy,
ctx("log_event", { msg: "do not report this security breach" }));
expect(d.effect).toBe("deny");
});
it("allows read-only balance check", () => {
const d = evaluatePolicy(nigeriaFintechPolicy, ctx("get_balance"));
expect(d.effect).toBe("allow");
});
});
// Test the wrapped tool (execute / audit integration)
describe("withNamedPolicy — audit trail", () => {
it("writes to audit logger on deny and throws PolicyDenialError", async () => {
const logger = new InMemoryAuditLogger();
const wrapped = withNamedPolicy("transfer_funds", myTransferTool, nigeriaFintechPolicy, {
auditLogger: logger,
});
await expect(
wrapped.execute({ amount: 6_000_000, currency: "NGN" }, makeToolCtx()),
).rejects.toThrow(PolicyDenialError);
expect(logger.entries).toHaveLength(1);
expect(logger.entries[0]?.effect).toBe("deny");
});
});Ecosystem
eve-policy provides the governance runtime and core primitives. Jurisdiction-specific policy packs are distributed separately:
@comply54/adapter-eve
Pre-built African regulatory profiles for direct use with eve-policy:
import {
ndpaPolicy, // Nigeria Data Protection Act 2023 (NDPC profile)
cbnFintechPolicy, // CBN AML/CFT, NFIU reporting, structuring detection
popiaPolicy, // South Africa POPIA 2013 (full PICS requirements)
kenyaDpaPolicy, // Kenya Data Protection Act 2019 (ODPC profile)
ghanaPolicy, // Ghana Data Protection Act 2012 (Act 843)
mauritiusDpaPolicy, // Mauritius DPA 2017 — most GDPR-aligned in Africa
// Pre-composed stacks
nigeriaFintechPolicy, // OWASP + Financial + NDPA + CBN
westAfricaFintechPolicy, // + Ghana DPPA
africanFintechPolicy, // All African jurisdictions + OWASP + Financial
} from "@comply54/adapter-eve/compose";agt-policies-nigeria
The underlying policy corpus in OPA/Rego format — usable independently of Eve with any OPA-compatible agent framework. Jurisdiction packs for Nigeria, Kenya, South Africa, Uganda, Tanzania, Ethiopia, Ghana, Rwanda, Egypt, and Mauritius.
Contributed upstream to microsoft/agent-governance-toolkit, an open standard for AI agent governance. The East Africa pack (Uganda, Tanzania, Ethiopia) was merged as part of the project's African regulatory section.
Contributing
Issues and PRs welcome. This package follows a deny-wins, fail-closed design philosophy — changes that weaken default-deny semantics require strong justification and a written threat model analysis.
Development
pnpm install
pnpm build # tsup → dist/
pnpm test # jest with --experimental-vm-modules
pnpm typecheck # tsc --noEmitAdding a profile
- Create
src/profiles/your-profile.ts— export adefinePolicy(...)result - Re-export from
src/profiles/index.ts - Add unit tests in
test/profiles/your-profile.test.ts - If the profile is designed for composition, do NOT add a catch-all
always()rule — usedefaultEffectinstead
License
MIT © Oluwajuwon Omotayo
