Skip to main content

neti

A preflight gate for agent tool calls.
Before an agent acts, neti resolves what the action will actually touch —
and stops it when that crosses a line you declared.

CI licence: Apache-2.0 python 3.12+ no telemetry offline by default

neti.claritty.ai

remove_group_members(group: "engineering-all")

Your policy engine sees a string on an allowlist. neti sees 412 people losing access to 9 applications — and stops the call, because you declared a ceiling of 200.

neti hook denying a call that resolves to 41,203 principals against a declared ceiling of 200

The agent gets back a number, not a refusal — which is what makes it narrow the target and try again instead of giving up or routing around. Nothing about the gate leaks into the prompt.

Size is the line nobody else can draw, so it leads. It is not the only one:

Read(.env)                        stopped — one file, under every ceiling, and off limits
Read(~/.ssh/id_rsa)               stopped — outside the tree you pointed the agent at
purge("customer_data")            stopped — this session already read a ticket a stranger wrote
Read(#1), Read(#2) … Read(#3,998) stopped — the session total, not any one call

None of those four is a statement about how big anything is, and all four are the same mechanism: a rule a human wrote, compared without a model, sealed into a chain you can re-derive offline.

Every image on this page is generated from a transcript the test suite pins byte for byte. They cannot show something the product no longer prints: change the wording and the build fails until the picture is regenerated. See tools/make_media.py.

Why

An agent asks to do one thing. That one thing turns out to be a million things. Nobody finds out until after. Alignment, authorization, sandboxing, anomaly detection and rollback all answer a different question; none of them answers how big is this.

That is the question neti was built for, and it is one of five it now answers. Each is a separate declaration, and a call is stopped if any of them says so:

the question you declare
magnitude how big is the set this argument addresses a ceiling, in a unit
sensitivity what is this target, whatever its size a glob, and why
location is it outside the tree the agent was pointed at one verdict
accumulation how much has this session touched already a budget per unit
provenance is this session downstream of untrusted input where untrusted input lives

Declared, not learned — and that is the part that matters more than the list. Nothing here is scored, inferred, or served from somebody's cloud. Every verdict is a static comparison against something a human wrote, which is why a false block is always "the rule you wrote is too tight" — readable in five seconds, fixable in one line, and never "the model found it suspicious."

That is the difference between a control you leave switched on and one that gets switched off in month two.

Getting value from it

One command, and it is already protecting. Then a week of your own traffic makes it precise.

you run you get it takes
1. Protected neti start secrets off limits, big calls flagged, hook wired 30 seconds
2. Observe work normally your agent's real distribution a few days
3. Tune neti propose ceilings derived from your traffic, with their impact 10 minutes

You are never asked to guess a number, and neither do we. The one thing neti start chooses for you is a size threshold, and a size we chose only ever flags — recorded, you get told, the call runs. Two things stop a call on day one, and neither is a number: an off-limits file, named on your screen when it was set, and a target outside the directory you pointed the agent at — your home folder, /etc, a sibling project. That second one is where your keys actually live: ~/.ssh/id_rsa is one file, under every ceiling anybody would write, and outside the tree the scan can see. Numbers you have to defend still come from step 3.

Prefer to be walked through it? neti console opens on Getting started — the same four steps, read live off your machine, with your paths in the commands. Each step ticks itself as you do it.


1. Protected — one command

$ pip install neti
$ neti start

That is the whole first run. No extras, no quoting: the gate, the hook, every filesystem and shell resolver, the record chain and the whole command line are in the base install. neti[all] adds the console, the Microsoft Graph resolvers and MCP over HTTP — and still pulls in no provider SDK, so gating a coding agent never installs an LLM client by accident. It measures the machine you are standing on:

3. Measuring this machine
   The largest set one gated call could touch, right now, here:

      5,011 objects

   That is capability, not an incident.

Why this is worth something on its own. Authorization answers may you. Sandboxing answers where. Approval answers did a human say yes. None of them answers how big. That number is the first time anyone has told you the blast radius of a single tool call in this repository — no credentials, no configuration, no traffic to wait for.

It is not an alert. Nothing has gone wrong. It is a fact about your capability surface that you could not previously obtain.

neti demo --here on a machine with no traffic: three layers listening, four dark for want of a credential, and the total an agent working here can reach

This is the whole of day zero, and the only picture on this page that needs neither traffic nor a credential — neti demo --here walks your own files through the same decision path the gate uses in production. Three layers are listening, four are dark for want of a credential, and two have no resolver at all. The tool tells you which is which rather than reporting a number and letting you assume it covered everything.

…and then it turns itself on:

4. Turning it on
   Off limits from now on — these are really here, and one file is
   under every ceiling anybody would write:

      **/.env*         credentials live here   (found .env)
      **/.git/**       rewriting history is not reversible   (found .git)

   And anything touching more than 5,933 at once is flagged — recorded, and
   you get told. Never stopped: a size threshold is our judgement, and we do
   not stop your work on one.

5. Putting it in front of your agent
   This adds a PreToolUse hook to .claude/settings.json:
   …
   Write it? [Y/n]

It shows the change, backs the file up, and asks once. Before this, a fresh install had nine gated parameters and zero ceilings — it could block nothing at all until somebody opened the YAML and learned fourteen keys, so the median install protected nothing, ever.

2. Observe — what does your agent actually do?

Work normally for a few days. After ~270 calls in a real project:

Read  /file_path   n=94  p50 1      max 1
Edit  /file_path   n=42  p50 1      max 1
Glob  /pattern     n=38  p50 24     max 1,091
Grep  /path        n=33  p50 30     max 5,009

This is the payoff of the whole exercise. Your agent's reads are one file, essentially always — until the one that is the entire repository. You would set very different ceilings for Read and for Glob, and now you can see which, instead of arguing about it.

3. Tune — replace our numbers with yours

$ neti propose

It reads your own recorded traffic and suggests ceilings, showing its working:

Glob /pattern:
  observed  n=38  p50=24  max=1,091 [objects]
  proposed  confirm above 50   block above 500
  rationale 2x and 10x the observed p50 (24). p95 was 1,091 here — your large calls are
            more than 5% of the traffic, so they *are* the p95, and multiplying it proposes
            a ceiling above everything you have ever done. Anchored on the median instead
  IMPACT    over the observed window this would have blocked 2 call(s) and asked about 0

Three things make this trustworthy rather than magic. It states the impact on traffic you already have, so you know the cost before you commit. It argues against itself when the statistics disagree. And it prints a fragment, it does not edit your policy — nothing computed is ever read at decision time, so the gate stays a static integer comparison you can audit.

It also refuses when it does not have the evidence: "only 4 observations; 30 needed before a ceiling means anything." That refusal is the feature.

You can also declare a ceiling from the console: /policy shows the distribution beside the field, tells you how many recorded calls it would have stopped, shows you the diff, and backs up the file.

And it proposes the ceiling a per-call ceiling cannot be. Four thousand reads of one file each are four thousand calls of magnitude 1, and every one of them is under every ceiling anybody would write (SCOPE.md NC-01, NC-12). Only a cumulative budget sees that — so propose also totals up whole sessions and whole days and suggests one:

objects per day:
  observed  9 day(s)  median=1,240  max=3,180  [objects]
  proposed  confirm above 2,500   block above 5,000
  rationale 2x and 5x the median day (1,240 objects). Anchored on the median rather than a
            percentile: a budget already sums a window of calls, and at 9 day(s) a p95 is the
            maximum under another name. Derived from 9 day(s), which is a judgement rather
            than a statistic — review it before committing
  IMPACT    0 of the 9 observed day(s) would have crossed this

The window matters as much as the number. A session budget catches one run going wrong; it starts every new conversation at zero, so an agent reading steadily for three days trips nothing. day, week and rolling:<n>h span conversations. A calendar window resets on its boundary — a day budget of 20,000 permits 40,000 across one midnight — which is why rolling: exists and why propose will not invent one for you: only session and day can be read off recorded traffic, and it says so.

And it proposes the other axis, which needs no traffic at all. A ceiling cannot reach a single file — .env is one object, under every ceiling anybody would write. So neti propose also looks at what is really on your disk:

  **/.env*    found: .env
  **/.git/**  found: .git

sensitive:
  - { match: "**/.env*",   verdict: confirm, why: credentials live here }
  - { match: "**/.git/**", verdict: confirm, why: rewriting history is not reversible }

Only rules that match something that is genuinely there — a **/*.pem rule in a repository with no certificate in it is a rule that can never fire. neti start shows them on day one, because they are facts about the disk rather than about behaviour.

Once you commit those, the gate stops calls rather than only flagging them — because now the number is yours. The same session, re-run:

Read(package.json)          silent
Glob(src/routes/*.ts)       silent
Bash(npm test)              silent
Glob(**/*)                  BLOCKED — 1,093 objects, above the declared ceiling of 500
Grep(.)                     BLOCKED — 5,011 objects, above the declared ceiling of 500

270 allowed, 2 blocked. Ordinary work never notices it is there.

And the agent gets back a number, not a refusal:

Preflight blocked this call: /pattern resolves to 1,093 objects, above the declared ceiling of 500. Narrow the target and try again.

That sentence is why it retries with a narrower target instead of giving up or routing around. It is byte-for-byte identical on all fifteen seams, so the door a call arrives through never changes the answer.

Two things you now have for free:

$ neti verify        # recompute the hash chain over every decision
$ neti report        # the distribution, any time

One thing to know before you rely on it

A deletion ceiling is a judgement, and propose says so. shell.paths sizes rm -rf, find -delete, git clean -fd and git checkout -- . — but deletions are rare, so a destructive gate never accumulates the 30 observations the ordinary rule wants. Rather than say "keep observing" forever and leave the gate unconfigured, neti propose proposes from whatever it has and labels it:

Bash /command:
  observed  n=5  p50 of the deletions seen=968  max=5,011 [objects]
  proposed  confirm above 1,000   block above 5,000
  rationale …anchored on the median deletion (968) rather than the largest (5,011), because the
            largest is usually the one you would want stopped and a ceiling above it would catch
            nothing. Treat these as a starting point you edit: for a deletion the question is not
            what is normal here but what you would not want to lose
  IMPACT    would have blocked 2 call(s) and asked about 0

Routine build cleanup passes; the repository-wide wipe does not. Review that number — it is the one place the tool is reasoning from too few samples to be statistical, and it tells you so.

A command it recognises as destructive but cannot size — cat list.txt | xargs rm — comes back flagged: the call runs, and it appears in neti report under unmeasured deletions with what the agent said it was doing. What escapes entirely is a wrapper: ./cleanup.sh deletes and no textual signal will ever see it (SCOPE.md NC-15).

Stated here rather than discovered later, because a gap you know about is a decision and a gap you do not is a surprise.


If your agent talks to a directory, not a filesystem

The path above assumes a coding agent. For an MCP server against Entra, GitHub, a database or object storage, neti init reads the MCP client configs already on your machine, launches each server the way its client does, asks tools/list, and writes a policy matching the tools it found:

$ neti init
Found 1 MCP server(s):
  entra    npx -y @acme/entra-mcp     (Claude Code (project))

  gated   remove_group_members   /group → entra.principals, /group#apps → entra.apps
  ungated search_directory       nothing here can be sized

Wrote neti.yaml — 3 tool(s) gated, every ceiling left blank on purpose.

Then, still with no traffic:

$ neti inventory
tool                  param        resolver          max reachable  risk
remove_group_members  /group       entra.principals         52,400  no ceiling declared
send_email            /to          entra.principals         52,400  no ceiling declared
neti inventory listing each gated parameter, its resolver, and the maximum it could reach in one call

That is the day-one finding for a directory: this agent holds a credential that can, in one call, reach 52,400 people, and nothing today would stop it.

Add --demo to any command to run the whole path against a synthetic tenant with no credentials at all — same engine, same records, only the numbers differ.


Want the whole arc in one command?

$ neti demo --here

Six acts against your own machine, no credentials: what an agent can reach here, what it actually did, the ceilings that follow, the same calls re-run with those ceilings on, and a chain that re-derives every verdict offline.

the six acts of neti demo --here: discover, reach, observe, report and propose, enforce, audit

[all] is everything one machine needs, which is the entire free tier. A bare install leaves a neti command with no CLI behind it, and says so rather than failing obscurely.

That install is checked rather than asserted. tools/verify_install.py builds a fresh virtualenv, installs the published wheel into it, and walks this whole page — measure, gate, block, seal, tamper, verify, serve — asserting the numbers. Four defects in this project were only ever visible from outside the repository.

Putting it in front of an agent

No SDK, no code change, no redeploy — a config edit in the client you already use. The agent never learns that the gate exists; it learns that a call was too big, which it already knows how to handle.

An MCP server (.mcp.json, claude_desktop_config.json, ~/.cursor/mcp.json). Whatever command launched the server becomes an argument to the gate:

   "entra": {
-    "command": "npx",
-    "args": ["-y", "@acme/entra-mcp"]
+    "command": "neti",
+    "args": ["gate", "--stdio", "--", "npx", "-y", "@acme/entra-mcp"]
   }

The harness's own built-in tools, which no proxy can see. Claude Code's PreToolUse hook is the only seam that exists for those, and its contract maps onto the verdict lattice directly — BLOCK → deny, CONFIRM → ask, and a pass says nothing at all, so the permission rules you already configured keep working exactly as they were. In .claude/settings.json:

{"hooks": {"PreToolUse": [{"matcher": "*",
  "hooks": [{"type": "command", "command": "neti hook"}]}]}}

An agent SDK. Each has one place where a request becomes an execution, and each adapter sits at that place — so a blocked call comes back to the model as a tool result with the number in it, never as an exception that kills the run:

from neti.adapters.anthropic_tools import gate_tools     # Anthropic tool_runner
from neti.adapters.openai_agents  import neti_guardrail  # OpenAI Agents SDK
from neti.adapters.langchain_tools import gate_tools     # LangChain + LangGraph
from neti.adapters.pydantic_ai    import neti_hooks      # Pydantic AI
from neti.adapters.google_adk     import neti_plugin     # Google ADK
from neti.adapters.autogen_tools  import gate_workbench  # AutoGen
from neti.adapters.crewai_hooks   import gate_tools      # CrewAI
from neti.adapters.llamaindex_tools import gate_tools    # LlamaIndex
from neti.adapters.smolagents_tools import gate_tools    # smolagents
from neti.adapters.semantic_kernel_filters import neti_filter  # Semantic Kernel

runner = client.beta.messages.tool_runner(tools=gate_tools(pf, TOOLS), ...)
agent  = create_react_agent(model, gate_tools(pf, TOOLS))          # LangGraph
agent  = Agent("anthropic:claude-opus-4-5", capabilities=[neti_hooks(pf)])   # Pydantic AI
app    = App(name="ops", root_agent=agent, plugins=[neti_plugin(pf)])        # ADK

Four of the ten need nothing wrapped: ADK, Pydantic AI, OpenAI Agents and Semantic Kernel each have a callback or filter the gate attaches to. The other six wrap the one method that executes, and copy name, description and schema across verbatim — an agent must not be able to tell a gated tool from an ungated one by looking at it, or the gate leaks into the prompt and into what the model believes it may attempt. One neti.yaml governs a tool whichever runtime it arrives through, because names are normalised the same way everywhere.

Two of these are less obvious than they look, and the adapters say so where they live. CrewAI has a before_tool_call hook that can block a call, and it is not the seam: CrewAI substitutes a fixed "blocked by hook" string for the reason and returns immediately, so the number never reaches the model and no after-hook runs to put it back. The tool gets wrapped instead. AutoGen has no before-tool callback at all, so it is the workbench that gets wrapped.

They agree. tests/e2e/test_seam_equivalence.py drives all twelve seams — including neti gate and the hook — across all five resolver families, and asserts the same verdict, the same magnitude and the same denial sentence byte for byte. A verdict that depends on which door a call came through is a bug in the product, not in the adapter.

neti prove runs that same comparison on your machine, against whatever is installed, and hands you the chain to re-check.

Run the framework the way you run it, with no model at all

The seam table proves each adapter honours its framework's contract. It does that by calling the integration point directly, which leaves a different question open: when the framework executes the tool its own way, is the gate in the path? just conformance answers it by building a real agent in each installed framework and running it.

None of these rows has a model. Each one scripts the response — a hand-written message, a fake chat model, a mock HTTP transport — because that is what shows the gate is at the execution seam: the model chooses what to call, and the gate decides whether it runs. No key, no network, no provider, no cost, and the same answer every time, so it runs in CI on every push.

runtime version what was driven depth
anthropic 0.120.2 the Anthropic tool_runner, read off the wire full agent loop driven
autogen 0.7.5 AutoGen AssistantAgent.run over a workbench full agent loop driven
crewai 1.6.1 a real Crew.kickoff, read from the agent's observation full agent loop driven
google-adk 2.6.1 an ADK App run by InMemoryRunner full agent loop driven
langchain 1.3.14 langchain.agents.create_agent, via the model interface full agent loop driven
langgraph 1.2.10 a compiled StateGraph executing a ToolNode full agent loop driven
llamaindex 0.14.23 a LlamaIndex FunctionAgent over a FunctionCallingLLM full agent loop driven
openai-agents 0.19.2 the OpenAI Agents SDK Runner full agent loop driven
pydantic-ai 2.22.0 a Pydantic AI Agent over FunctionModel full agent loop driven
semantic-kernel 1.36.0 Semantic Kernel's auto function-calling loop full agent loop driven
smolagents 1.26.0 a smolagents ToolCallingAgent full agent loop driven

Each row asserts the tool body never executed, that the sentence the agent was shown is byte-for- byte what Preflight produced, and that the decision was sealed. Versions are recorded, because "works with LangChain" is a sentence that outlives the version it was true of.

It earns its keep. The CrewAI row above is the reason CrewAI's gate is a wrapped tool: driving the hook pair by hand reported a perfect denial, and running Crew.kickoff() showed the agent being handed Tool execution blocked by hook. Tool: Glob with no number in it at all.

Which runtime is yours

neti score prints the full list. The short version: twelve runtimes have an adapter here, and everything that speaks MCP — Cursor, Claude Desktop, Windsurf, Cline, Continue, VS Code, Zed, Goose, Strands, OpenAI's Codex CLI, OpenClaw — is reached without neti knowing they exist, because the gate goes in front of the MCP server and whatever launched it launches neti gate instead.

Those two claims are not equally strong and the card keeps them apart. An adapter row was driven by the seam table. An MCP client is reached: tests/e2e/test_real_mcp_client.py connects the official SDK's own client to neti gate over a real pipe and watches an oversized call refused — so the protocol is tested at the version those clients ship, and that Cursor speaks it is a fact about Cursor rather than something claimed here.

What it does not reach, also on the card: an agent whose tools are in-process functions in a language this package cannot wrap and which does not go through MCP — a Vercel AI SDK or Mastra app with locally-defined TypeScript tools — and hosted runtimes that execute tools server-side, where there is no local seam to sit at.

A tool loop you wrote yourself — an Anthropic or OpenAI function-calling loop, anything that speaks neither MCP nor a hook protocol. One substitution gates every tool in your dispatch table:

from neti import Preflight
from neti.adapters.tool_loop import gate_tools

pf = Preflight.from_config("neti.yaml")
TOOLS = gate_tools(pf, TOOLS)  # once, at the top. The loop below is unchanged.

for block in message.content:
    if block.type == "tool_use":
        out = TOOLS[block.name](https://github.com/Clarittyai/neti/blob/main/**block.input)

Wrapping the whole table rather than each call is the point: it makes forgetting an all-or-nothing mistake instead of a per-tool one. pf.dispatch(...) gates a single call and @pf.guard a single function, and both are still there — but both can be forgotten for one tool out of five, and nothing would say so.

dispatch returns the tool's own return value when the call fits and the denial sentence when it does not, because your next line hands that string back to the model — and reading a specific number is what makes it retry with a narrower scope instead of giving up. pf.check(...) and @pf.guard are the same decision in the other two shapes. This is the one seam you can forget to use, and nothing detects the omission; the other two cannot be bypassed that way, which is why they come first.

A remote MCP server: neti gate --upstream https://mcp.internal/rpc, then point the client at the gate instead.

Add --demo to either command to rehearse the whole thing against the synthetic tenant with no credentials at all. Same engine, same decision procedure, same records — only the numbers differ.

The audit trail

Every decision is sealed into a hash chain as it is made, and the chain re-derives offline, forever:

neti verify reporting the record chain intact

neti verify --config goes further and replays each decision against the policy, so "the chain is unbroken" becomes "and every verdict in it still follows from its evidence". Alter one byte of one record and it names the decision it broke at.

neti propose shows the same working on a busier window — here the p95 is the outlier, so multiplying it would propose a ceiling above everything that has ever happened:

neti propose deriving ceilings from observed traffic, with the rationale and the impact of each

What it costs to run

Measured against real Claude Code sessions, not modelled:

Hook overhead, per tool call p50 ~140ms · p95 ~145ms, and flat as the record file grows
Records ~1.1–1.4 KB per call, so roughly 1.3 MB per thousand
Decision itself microseconds — the overhead above is almost entirely Python interpreter start

"Flat" is the row that changed. neti hook used to read the entire record file twice per call — once to seed the chain, once under the append lock — so the cost grew with everything you had already recorded: 133ms fresh, 273ms at ten thousand records, 816ms at fifty thousand, measured on a lean install. The advice on this page is to run a week in observe mode, which is how you get to fifty thousand. The head is cached in a .head sidecar now, keyed on the record file's byte length so anything that touches the file outside the gate invalidates it and every reader falls back to the full walk — it can fail to be fast, never to be right.

The record figure said ~700 bytes until somebody measured it again: a coding agent's calls against examples/coding-agent.yaml write a median of 1,057 bytes with short relative paths, and 1,230 with a realistic absolute one. Most of it is causes — the per-argument evidence that makes a verdict re-derivable — and the args the call carried, so the number moves with how long your paths are. tests/property/test_docs_are_true.py now measures it rather than trusting the table, because a published number that drifts in the flattering direction is the kind of thing this project exists not to do.

That last row is the one to act on. As a PreToolUse hook with matcher: "*", neti starts a fresh process for every tool call, and interpreter start dominates a sub-millisecond decision. If ~170ms on every call is too much, narrow the matcher to the tools you actually gate:

{"hooks": {"PreToolUse": [{"matcher": "Bash|Write|Edit",
  "hooks": [{"type": "command", "command": "neti hook"}]}]}}

The MCP paths do not pay this: neti gate is one long-lived process, so the decision is the microseconds it measures.

Concurrent agents are safe. Claude Code runs tool calls in parallel and each hook invocation is its own process, so several writers can reach the record file at once. Appends take an exclusive lock and re-read the chain head under it — many processes, one file, no forked chain. There is a test that runs eight real subprocesses and asserts exactly that, because a single-writer test cannot see this and an earlier version of the sink forked the chain the first time a real agent ran two tools at once.

When a confirm needs an actual human

The local install is not a demo. It resolves real magnitudes off your real machine, enforces real verdicts, and seals a real hash chain you can re-verify. There is no reduced mode, no trial and no watermark on the numbers. If you never install anything else, the gate is doing its whole job.

The one thing a single machine genuinely cannot do is ask somebody else.

A confirm band means somebody other than the agent's operator should decide this one. On one machine there is nobody to ask, so the gate stops the call and says so. That is correct, and it is what a free install will keep doing.

The hosted tier — neti-cloud, BUSL-1.1 — is the somewhere the question can go:

$ neti-cloud serve --key $KEY                        # the control plane
$ neti login --url http://localhost:8730 --key $KEY  # on the agent's machine
$ neti gate --stdio --org -- npx -y @acme/entra-mcp  # in front of an MCP server
$ neti hook --org                                    # or Claude Code's built-in tools

The agent's call stops with "approval a_b271… is pending; retry this exact call once it is granted." A reviewer opens the console and sees 500 recipients, above a ceiling of 50, and approves. The agent's retry proceeds. The grant is bound to that exact call under that exact policy, is single-use, expires, and is refused if the target has grown since a human looked at it.

If the control plane is unreachable, absent, or unpaid, the gate behaves exactly as the free tier. A control plane can only ever make a decision more permissive, and only through a named human — so nothing about paying adds availability risk to enforcement. That is a test, not a promise.

Free — Apache-2.0, this repository Hosted — BUSL-1.1, a separate one
the engine, all twelve seams, observe and enforce a second human approving a confirm
init · inventory · report · propose · verify · prove · score org policy, one version across the fleet
the record chain, and replaying it with neti verify --config session budgets that survive a restart
the console, every screen audit across every agent
the detection rule table, and every resolver the reviewed detection catalogue

The rule is "can one machine do this?" — which is why enforcement is free and why every hosted feature is a hole SCOPE.md already documents, or work that only exists because more than one person did it. See LICENSING.md.

The client for the control plane is in this repository, under Apache-2.0, along with the tests pinning every property a grant is allowed to have: bound to one call, single-use, expiring, refused if the target has grown since a human looked at it. Read the protocol, write your own server, hold it to the same tests. What the hosted tier sells is a server that is running, not a secret about how to talk to it.

What can be sized

A seam without a resolver is a place to write allow, so this list is the real measure of coverage.

resolver unit one call resolves cost
fs.paths objects a path, directory or glob local walk, capped
shell.paths objects a shell command's destructive targets reads rm, find -delete, git clean; declines everything else
db.rows rows DELETE/UPDATE → select count(*) one scan
storage.objects objects s3://bucket/prefix paginated list, capped
github.repos repositories owner → every repo in the org one request
github.files objects owner/repo → files on the default branch one request
entra.principals principals a group → everyone in it, nested included one $count
entra.apps apps a group → applications assigned to it one $count
entra.guests principals a group → the external members only one $count
entra.principals_with_guests principals the above, split internal/guest two requests
terraform.destroy resources a plan file → what it would destroy local read

Two properties hold across all of them, and they are what the decision procedure rests on. None can return 0 for something it could not reach — a failure is UNRESOLVED and routes through the verdict you declared, because an unreachable target and an empty one are opposite situations with the same number. And anything capped or estimated reports a LOWER_BOUND, which can block soundly and can never allow — so the targets too large to count are exactly the ones that cannot slip through quietly.

Sizing something else is ~80 lines against RESOLVER_CONTRACT.md, and it is the contribution that matters most.

What it does not do

Read SCOPE.md. It is short, it is honest, and the non-coverage list is numbered so tests and write-ups can cite it. The headline: neti answers how big, not whether this is a good idea, and a per-call gate cannot see 4,000 individual sends unless you declare a session budget.

Documents

file what it fixes in place
SCOPE.md what is and is not covered, and the sentences we do not say
DECISION.md the verdict lattice and the decision procedure, one page
RESOLVER_CONTRACT.md the resolver spec — this is the actual product

Development

just install          # uv pip install -e '.[dev,cli,graph,mcp,console,sdks,sdks-extended,storage,database]'
just test-all         # NETI_REQUIRE_SDKS=1 uv run pytest -q — what CI runs
uv run mypy
uv run ruff check

Install the extras. Without them the Anthropic, OpenAI Agents, LangChain and LangGraph tests importorskip and vanish into a skip count, which is indistinguishable from a pass — that is how three runtimes went untested through a green build for a release. NETI_REQUIRE_SDKS=1 turns those skips back into failures, and tests/e2e/test_no_silent_skips.py keeps the CI install line honest.

The suite has four tiers:

tests/property/ executable invariants over the whole codebase — determinism, purity, direction soundness, the docs being true
tests/integration/ each component against its own seam
tests/e2e/ the product: all eleven seams agreeing across every resolver family, the operator's first week as one flow, every resolver through record and report, and neti gate --stdio in front of a real MCP server
tests/live/ real providers, opt-in

tests/live/ is skipped unless you give it something real to talk to. It is worth running: every defect it has ever found was invisible to the offline suite, because an offline test asserts what happens given a shape and only a live one tells you the shape is real. Most of it needs no cloud account at all — Postgres and MinIO in Docker, and Terraform's null provider:

just live-up && just live      # Postgres, S3 via MinIO, Terraform, and GitHub if `gh` has a token
just live-down                 # removes both containers

neti check is the Entra half, and the one thing here that still needs a tenant. It answers the tenant-side questions the scorecard lists as unverified, needs an app registration with GroupMember.Read.All (application permission, admin-consented), and is read-only throughout.

NETI_TENANT_ID=… NETI_CLIENT_ID=… NETI_CLIENT_SECRET=… uv run neti check

Two of those tiers exist because this pass added them, and both found defects immediately: db.rows had never once run through psycopg — thirty-nine tests, all against stdlib sqlite — and was holding a connection it never closed; storage.objects's pagination loop had never seen a server that paginates. boto3 and psycopg were not even installed in a working checkout, because nothing needed them.

Field trials

eval/ is the tier above tests/live/: real agents and real servers, run by hand rather than by CI, because it is non-deterministic and costs tokens. See eval/README.md.

uv run python -m eval.surveys.mcp_coverage --markdown

That one is M10, and it used to be the least comfortable number in the project. Run against a config carrying the MCP servers people actually install, neti init gated 0 of 160 discovered tools across the 13 that launch, because the matcher knew two parameter shapes and could therefore only ever propose 2 of the 10 resolvers that exist. It is 25 of 160 now, against 34 that carry a parameter a shipped resolver could size.

The remaining nine are not an oversight, and neither are the 135: tests/corpus/ holds 170 real tool schemas and a written judgement on every parameter of every one of them, including 401 parameters no rule claims and 41 the rule table declined with a reason — query on a search server is not SQL, repo next to owner addresses one repository rather than the set of them. A gate that guesses is worse than no gate, so the honest ceiling on a hand-written table is visible rather than hidden. neti score prints the number, and prints it as absent when the survey has not been run.

Asking your own model about the rest

neti suggest takes those 401 unclaimed parameters to a model, using your key from your shell. neti never proxies the request and never sees the answer: assist_client.py is the only module that opens a socket, it constructs the SDK with no base_url, and a property test asserts it names no host but your provider and that import neti never loads it at all.

$ neti suggest --dry-run     # prints exactly what would be sent, and sends nothing
$ neti suggest               # asks, and writes neti.suggested.yaml

Or point it at a model on your own machine, and nothing leaves it at all:

$ neti suggest --provider local --model qwen2.5:32b

Any OpenAI-compatible runner — Ollama, LM Studio, llama.cpp, vLLM — with --base-url if it is not Ollama's default. No key, no account, no third party, and no extra to install: the local client is stdlib only, because reaching for an SDK to talk to a process on your own machine is a dependency for nothing. The default address is loopback and a test asserts it, so the only way schemas leave this machine is somebody typing an address.

What comes back is a commented-out fragment in a file neti gate never loads, with every band empty. Deleting the # is your confirmation, and even then a merged suggestion resolves and records without being able to block anything. It never edits your policy.

Four things make a wrong answer harmless rather than merely unlikely. The model is never asked for a quantity — the response schema has nowhere to put a magnitude, a direction, a unit or a ceiling. The resolver list is a closed enum derived from the rule table. The 41 parameters the rule table already declined with a written reason are excluded when the batch is built, so the command structurally cannot ask a model to overturn a judgement somebody already made. And it is scored: just assist measures it against the committed answer key and reports what the model got wrong before what it got right. SCOPE.md carries the one contamination path that does exist rather than hiding it.

What it actually scored, on a local 8B model, nothing leaving the machine:

gates the rule table already makes recovered 33 of 34, zero wrong resolvers
parameters it declined with a written reason claimed 19 of 41 — all wrong by construction, and never sent
the 401 no rule claims 92 claims on things that are not sets, 7 gates genuinely found

Both halves of that last row are the point. The seven are real: filename on seven browser tools is a path on this machine, fs.paths ships and would answer it, and the rule table's name rule simply does not know the word. It found every one that exists. It also cost ninety-nine wrong claims to do it — roughly six percent of what it said was right.

That is why the output arrives commented out, with empty bands, in a file the gate never loads. A suggestion here is a reading prompt, not an answer, and the number saying so is printed on neti score rather than left for you to discover. The answer key for that last row is an opinion (eval/answers/adjudicate.py), written as rules with reasons so you can disagree with it and see exactly which parameters move.

Contributing

The highest-value contribution is a resolver: something that turns a tool's parameter into a count. tests/corpus/ names 401 parameters across 170 real tools that nothing here can size, and some of them are sets somebody could measure — a Slack channel's members, the pages in a Notion database, the rows a Linear filter matches.

It is about 80 lines against RESOLVER_CONTRACT.md, which is short and is the actual specification of the product. Two rules do most of the work: never return 0 for something you could not reach — a failure is UNRESOLVED, because an unreachable target and an empty one are opposite situations with the same number — and anything capped or estimated reports a LOWER_BOUND, which can block soundly and can never allow.

Open an issue with the tool and the parameter and we will tell you honestly whether it can be sized. CONTRIBUTING.md is the rest.


neti.claritty.ai · Apache-2.0 · no telemetry, no phone-home, no licence check

Metadata

Release files for neti 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for neti 0.4.0
File Size Uploaded
neti-0.4.0.tar.gz 2.3 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for neti 0.4.0
File Interpreter ABI Platform
neti-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 3.3 MB

Release files / neti-0.4.0.tar.gz

Download URL neti-0.4.0.tar.gz
Size 2.3 MB
Tags Source
SHA-256 checksum
How to use checksums
09375aab8cf095bcf5afba723668b6ba36e48264cd0088c8341d57cd6586a6c3
BLAKE2b-256 checksum
How to use checksums
e0712ea42bde272d0d9a4ac3076f5f8074f58bd7942b3ba87b0e5f22a57b2c62
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release files / neti-0.4.0-py3-none-any.whl

Download URL neti-0.4.0-py3-none-any.whl
Size 1.1 MB
Tags Python 3
SHA-256 checksum
How to use checksums
1b99c0b1cf043414ee1f4f4c3d206a8c535d933c8c90ba51a33abd3ad1b9543d
BLAKE2b-256 checksum
How to use checksums
c5828f100e5f0a2d0acbae801598606d3a6f55d57d8de005ec8eca54621bc09f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 release files

0.3.3

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page