Skip to main content

Deterministic, non-LLM scope and boundary enforcement classifier for agent loops.

Project description

ARYEH

Deterministic, non-LLM scope and boundary enforcement for agent loops.

@reshimu/aryeh answers one question before an agent action executes: is this action authorized by the caller's stated scope? It returns a four-level verdict — IN_SCOPE, BOUNDARY, OUT_OF_SCOPE, or INDETERMINATE — in under 5 ms, with zero external dependencies and no model calls.

Part of the Reshimu validator triad:

Validator Question
ARYEH Is this action within the agent's declared scope?
SHOR Are the action's inputs grounded in the provided context?
NESHER Is the action irreversible or destructive?

All three are independent and designed to be composed at the call site.


Install

npm install @reshimu/aryeh
# or
pnpm add @reshimu/aryeh

Node ≥ 18 required. Ships as dual ESM + CJS.


Quick start

import { classify } from '@reshimu/aryeh'

const result = classify({
  action: "read_file('src/index.ts')",
  scope: {
    allowedTools:   ['read_file', 'list_dir'],
    allowedActions: ['read', 'list'],
  },
})

console.log(result.level)       // 'IN_SCOPE'
console.log(result.matchedRules) // ['allowedTools: read_file', 'allowedActions: read']
console.log(result.confidence)   // 1

API

classify(input: ClassifyInput): ClassifyResult

The single exported function. Synchronous. Pure. No side effects.

ClassifyInput

interface ClassifyInput {
  action: string | StructuredAction
  scope:  ScopeDefinition
}

action accepts either a free-form string (ARYEH extracts tool, verb, domain, and resource heuristically) or a pre-parsed StructuredAction. Structured input is strictly more reliable; the string path is a convenience for callers without a parsed call available.

interface StructuredAction {
  tool?:     string   // e.g. 'send_email', 'db.query'
  verb?:     string   // e.g. 'delete', 'read', 'post'
  domain?:   string   // hostname only: 'api.stripe.com'
  resource?: string   // file path, table name, any identifier
  raw?:      string   // original text, preserved for logging
}

ScopeDefinition

interface ScopeDefinition {
  allowedTools?:     string[]  // exact-match tool names
  deniedTools?:      string[]
  allowedDomains?:   string[]  // glob: '*.github.com', 'api.stripe.com'
  deniedDomains?:    string[]
  allowedActions?:   string[]  // verb-level: 'read', 'list', 'get'
  deniedActions?:    string[]  // 'delete', 'write', 'post', 'send'
  allowedResources?: string[]  // glob: 'src/**', 'users/*'
  deniedResources?:  string[]
  strictMode?:       boolean   // default false — see Strict mode below
}

All fields are optional. Omitting a field means that dimension is unconstrained (non-strict) or not applicable. An empty array (allowedTools: []) is meaningfully different from omitted — under strictMode: true, an empty allowlist means nothing is allowed in that dimension.

Glob syntax (domains and resources only): * matches any run of non-separator characters; ** matches across separators (path segments or domain labels); ? matches exactly one non-separator character. Tools and verbs are exact-match only.

ClassifyResult

interface ClassifyResult {
  level:        'IN_SCOPE' | 'BOUNDARY' | 'OUT_OF_SCOPE' | 'INDETERMINATE'
  reason:       string    // single sentence, human-readable
  matchedRules: string[]  // structured rule identifiers that fired
  confidence:   number    // 0.0–1.0, informational — branch on level, not this
}

matchedRules format:

  • "allowedTools: read_file" — exact allowlist match
  • "deniedActions: delete" — exact denylist match
  • "allowedDomains: *.github.com → api.github.com" — glob match (pattern → value)
  • "strictMode: tool did not match allowlist" — strict-mode synthetic rule
  • "INDETERMINATE: empty scope" — structural reason

Classification levels

Level Meaning Default caller action
IN_SCOPE All checked dimensions matched their allowlists. Proceed.
BOUNDARY Partial match — some dimensions allowed, some missed; or only misses in non-strict mode. Block and escalate to a human.
OUT_OF_SCOPE At least one dimension matched a denylist, or all checked dimensions missed in strict mode. Block.
INDETERMINATE ARYEH could not check — scope is empty, or action provided no fields the scope has rules for. Treat as BOUNDARY for irreversible actions; route to human.

INDETERMINATE is not the same as OUT_OF_SCOPE. It means ARYEH could not check, not ARYEH checked and the action is unauthorized. Conflating them causes over-blocking.


Strict mode

By default, a scope dimension with no allowlist is permissive — if nothing is explicitly denied, it contributes an implicit allow. This is safe for callers who pass a partial scope and don't want to accidentally block dimensions they haven't thought about.

strictMode: true inverts the default: a dimension with no allowlist produces a no-match instead of an implicit allow. The result is that anything not explicitly allowed is either BOUNDARY (if something else was explicitly allowed) or OUT_OF_SCOPE (if nothing was).

// Non-strict: domain matched → IN_SCOPE (tool/verb are unconstrained)
classify({
  action: "fetch('https://api.github.com/repos')",
  scope: { allowedDomains: ['api.github.com'] },
})
// → IN_SCOPE

// Strict: domain matched, but tool/verb have no allowlist → BOUNDARY
classify({
  action: "fetch('https://api.github.com/repos')",
  scope: { allowedDomains: ['api.github.com'], strictMode: true },
})
// → BOUNDARY

strictMode never demotes a partial match to OUT_OF_SCOPE. A mix of explicit allow and no-match is always BOUNDARY — the human-in-the-loop tier — regardless of strict mode.


Examples

Deny beats allow

If the same tool appears in both allowedTools and deniedTools, deny wins.

classify({
  action: "send_email({ to: 'all@company.com' })",
  scope: {
    allowedTools:  ['send_email'],
    deniedActions: ['send'],
  },
})
// { level: 'OUT_OF_SCOPE', matchedRules: ['deniedActions: send'], confidence: 1 }

Domain glob

classify({
  action: "fetch('https://api.github.com/repos')",
  scope: { allowedDomains: ['*.github.com'] },
})
// { level: 'IN_SCOPE', matchedRules: ['allowedDomains: *.github.com → api.github.com'], confidence: 1 }

Resource path

classify({
  action: "write_file('src/lib/util.ts')",
  scope: {
    allowedTools:     ['write_file'],
    allowedActions:   ['write'],
    allowedResources: ['src/**'],
  },
})
// { level: 'IN_SCOPE', confidence: 1 }

INDETERMINATE — unextractable action

classify({
  action: 'do the thing we discussed',
  scope: { allowedTools: ['read_file'] },
})
// { level: 'INDETERMINATE', confidence: 0 }

Structured action input

classify({
  action: { tool: 'db.users.delete_all', verb: 'delete', resource: 'users_archive' },
  scope: { deniedActions: ['delete', 'drop', 'truncate'] },
})
// { level: 'OUT_OF_SCOPE', matchedRules: ['deniedActions: delete'], confidence: 1 }

String-form extraction rules

When action is a plain string, ARYEH applies these rules in order (best-effort — missing fields are undefined and skipped, never treated as a violation):

Field Rule
tool First token matching identifier( or dotted.identifier(
verb First _-separated prefix of the tool name; falls back to the leading word if no tool call
domain First URL host extracted via https?://host
resource First quoted string; falls back to the first path-like token

Verbs and tools are lowercased before matching. Domains are lowercased per RFC. Resources are case-sensitive.


Composing with NESHER and SHOR

import { classify as aryeh }  from '@reshimu/aryeh'
import { classify as shor }   from '@reshimu/shor'
import { classify as nesher } from '@reshimu/nesher'

// Run cheapest check first; stop on the first block.
const scopeCheck = aryeh({ action, scope })
if (scopeCheck.level === 'OUT_OF_SCOPE') return block(scopeCheck)
if (scopeCheck.level === 'BOUNDARY')      return escalate(scopeCheck)

const groundCheck = shor({ output: action.raw, context })
if (groundCheck.level === 'UNGROUNDED')   return block(groundCheck)
if (groundCheck.level === 'PARTIAL')      return escalate(groundCheck)

const riskCheck = nesher({ tool, verb, params })
if (riskCheck.color === 'RED')            return blockOrEscalate(riskCheck)

return execute()

All three validators are synchronous, sub-5 ms p99, and zero-dependency. The chained budget is well inside any tool call's latency floor.


Performance

  • p99 latency: < 5 ms on scope definitions with ≤ 100 rules per dimension and action strings ≤ 4 kB
  • Deterministic: identical (action, scope) input produces identical output across every run
  • Zero allocation in the hot path: glob patterns are compiled to RegExp once per (pattern, separator) pair and cached — no new RegExp per call

Enforced by tests/performance.test.ts on every npm test run (coverage runs exempt due to instrumentation overhead).


What ARYEH checks vs. does not check

ARYEH checks ARYEH does not check
Tool calls outside the agent's allowlist Whether an allowed call's outcome is dangerous (NESHER)
Verb violations: delete when only read/list allowed Whether the inputs to an allowed call are grounded (SHOR)
Domain violations: outbound calls to non-allowlisted hosts OS-level permissions (file mode, OAuth scope, ACLs)
Resource path violations: writes outside src/** Semantic intent or adversarial parameters
Empty / underspecified scope — reported as INDETERMINATE, never a silent pass Multi-step orchestration where each individual step is in-scope

Design principles

No LLM inside ARYEH. The classifier is regex, string equality, and glob matching. Classification is reproducible and deterministic.

Zero runtime dependencies. The build is a single ESM + CJS bundle. No glob library, no parser, no external service.

Caller-supplied scope. ARYEH does not invent or infer a scope. It checks the ScopeDefinition the caller provides. Default scope is empty → INDETERMINATE.

Pre-commitment, not runtime permissions. ARYEH gates intent before execution. OS-level capability checks (file permissions, network ACLs) are a separate layer that composes with ARYEH, not a replacement for it.


License

MIT © Reshimu

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

reshimu_aryeh-0.1.0.tar.gz (10.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

reshimu_aryeh-0.1.0-py3-none-any.whl (10.2 kB view details)

Uploaded Python 3

File details

Details for the file reshimu_aryeh-0.1.0.tar.gz.

File metadata

  • Download URL: reshimu_aryeh-0.1.0.tar.gz
  • Upload date:
  • Size: 10.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.5

File hashes

Hashes for reshimu_aryeh-0.1.0.tar.gz
Algorithm Hash digest
SHA256 3a6d10f9de16e282bb471af69c8d2d7a0aca51b2a95d770297ef1ab5035de9c8
MD5 1fe2b08a5fecdf123faa89925fe0ab53
BLAKE2b-256 239febab35ccf318e9721409a20e0e275a67f1f9ced7f254696b19556e6599d7

See more details on using hashes here.

File details

Details for the file reshimu_aryeh-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: reshimu_aryeh-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 10.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.5

File hashes

Hashes for reshimu_aryeh-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ceef98a4b0f71bda700e8a5a2de4ef44047085bef40b04822f7cb8757dd51b72
MD5 a40ac5b80794612e39e67c65af5deed9
BLAKE2b-256 191c843b6f44c71d8ec8398d09553ccd6f9dc487fff6f6f9a8086ea8f5240f33

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page