Repository navigation
Releases: kiwi0719/jev-edge
Release list
v0.6.4
Install
luarocks install lua-resty-jev-edge
npm install @jev-edge/jsor opm get kiwi0719/lua-resty-jev-edge, or make install. Kong: luarocks install lua-resty-jev-edge installs the plugin too; KONG_PLUGINS=bundled,jev-edge (see adapters/kong).
The fixes from an external review of 0.6.3 (R1–R17), each checked against a
reproduction before and after, plus bench fixes. No config key changes.
Upgrade notes
- laya-server past
LAYA_MAX_CONNECTIONSanswers 503 at once, without
reading the request, so/healthzgets the 503 too while it is saturated.
The DockerHEALTHCHECKmarks the container unhealthy only after about 30 s
of continuous saturation. jev.questionsnow applies toopenai-compatin both cores. A config
that set wording for it used to be ignored silently; the judge now reads it.POST /_jev/configanswers 500 when the shared dict cannot store the
override (it used to answer{"ok":true}and keep the old config). A
validation error is still 422.- JS: with the default memory store, subject reputation lives in its own
store (Runtime.reputationStore), apart from the trajectories. A custom
subjectStorestill holds both. - Apigee / APIM recipes changed: copy them again if you use them (see
Fixed).
Security
- Text over
max_judge_chunkscould pass on a cached or trusted
fingerprint (both cores). The whole-request fingerprint covers only the
window judged, so a short text that reads like the window, sent first,
answered for a long request whose unjudged part held anything, and an entry
cached in monitor mode answered after the switch to enforce with
policy.unjudgeable = block. The capacity check now runs before trust and
the cache, and a capped request's cache entry is scoped apart. - Durable Object subject sessions checked only the first id (JS). With
duplicate cookies (sid=fresh; sid=banned) a banned subject passed when
the backend reads the last one. Every candidate id is now checked and
charged, as with the memory store. jev.max_inflightwas not enforced in the JS runtime. It is now, per
isolate: past the cap a call passes aserror(kindbusy) and does not
count against the breaker, as on nginx.- laya-server created a thread and read the request before refusing a
connection past the cap, so slow clients could hold any number of
threads. Admission now happens before the thread.
Fixed
- The whole-request cache entry is written only when every part answered: a
part that failed while another blocked left a score that outlived a
threshold change and skipped the re-judge of the failed part. - JS memory store: a flood of trajectory writes could evict a running
reputation block from the shared LRU. - Lambda@Edge dropped
X-Jev-Subjectbefore evaluation even when the config
consumes it; it is still stripped from the forwarded request. - The Envoy gRPC shim reads
header_mapwhen Envoy'sencode_raw_headersis
on (every header but method and path was lost). - Apigee recipe: the
ServiceCallouthascontinueOnError="true", so it
fails open as documented. APIM and Apigee recipes treat any 4xx with an
X-Jev-Verdictas a block (they matched only 403, soblock_status = 429
forwarded blocked requests) and pass jev-edge's status through. make calibraterecommends a threshold at the precision it prints and
measures its rates there (0.704 printed as 0.70 with "0% FP" could block a
benign 0.702).conformance/run.pysizes the recommendedtimeout_msfrom the NFKC case
when it is the slower one.bench/chart.luacomputes log10 under Lua 5.1 too.- Tests: the Test::Nginx raw-subject privacy check could never fail; the
laya truncation test passed on aTypeErrorinstead of the check it names.
Bench
- The
slowscenario pinstimeout_max_ms = 300: the adaptive timeout had
grown past the 500 ms mock and the scenario read ~480 ms at p99. make benchpassesCONN,DURandOUTinto the container; the bench
bodies are written tobench/out*/instead of a tracked file.bench/report.md: latency, soak, replay cache and L1 sections re-run. The
soak's p99 since 0.6.1 (46 ms against 3 ms) is explained there: the old run
had the breaker open for 92% of requests, and the new figure is wrk's
reading of the ~0.03% of requests that waited on the judge.
v0.6.3
Install
luarocks install lua-resty-jev-edge
npm install @jev-edge/jsor opm get kiwi0719/lua-resty-jev-edge, or make install. Kong: luarocks install lua-resty-jev-edge installs the plugin too; KONG_PLUGINS=bundled,jev-edge (see adapters/kong).
Fixed
- Unwatched requests cost twice what they did in 0.6.1 under LuaJIT
(OpenResty, APISIX, Kong). The default rule watches 44 routes instead of 4,
and the path check ran all of them as Lua patterns, three times per request;
a closure created inside the pattern-folding helper also kept LuaJIT from
compiling that loop. The check now turns a route away on its literal prefix
(a compiled plain comparison) and runs the pattern only on a prefix hit, and
a request's path is canonicalized once.access()on an unwatched path:
4–5 µs in 0.6.1, 9.5–10.7 µs in 0.6.2, 6 µs now; watched paths were and are
at parity (~20 µs, cached or breaker open). Matching is unchanged
(275,000 random paths agree with the plain pattern walk).
jev-edge 0.6.2
Install
luarocks install lua-resty-jev-edge
npm install @jev-edge/jsor opm get kiwi0719/lua-resty-jev-edge, or make install. Kong: luarocks install lua-resty-jev-edge installs the plugin too; KONG_PLUGINS=bundled,jev-edge (see adapters/kong).
The fixes from a full audit of 0.6.1 (correctness, security and latent bugs in
core, the rule set, every adapter, CI, release and ops), each checked against
a reproduction before and after. L1 now reads what the backends read in many
more request shapes and on many more routes, judges tool-call arguments and
tool definitions, and reads a body it used to pass as "no text" or skip as
"not watched". A client can no longer open the breaker with text the judge
refuses, read the score off a block, or make a relay fail open with a path,
header or body it cannot forward. Several defaults and cache keys change, and
some configs that used to load are refused: read Upgrade notes first.
Upgrade notes
- Tool definitions are judged by default.
llm-endpointsgains
tool_fields(tools,functions,response_format.json_schema,
text.format): a request that carries tools makes one more judge call the
first time a tool set is seen, and is a cache hit on the next turn. A rule
that extendsllm-endpointsturns it off withtool_fields = {}. - Cache and fingerprint keys change once. The fingerprint keeps digit
runs and UUIDs; the verdict-cache scope addsjev.endpointand the question
wording when either is set; chunks overlap by 1 KiB. Expect a cold verdict
cache after the upgrade. A fingerprint an operator trusted through
/_jev/feedbackfor a text with a digit run or a UUID must be reported
again. - Subject ids from
AuthorizationandProxy-Authorizationchange once
(the auth scheme is canonicalised). Upgrade a thin Worker and its origin
together. - The breaker counts 401, 404 and 405 (a bad key, endpoint or model), and
no longer counts 400, 403, 413, 422 or a reply it cannot use: those are the
client's text, not the provider's health. A misconfigured judge now opens
the breaker and pages, where every call used to fail with it closed. - IP reputation blocks at L1 only under a config with
async.rep_block_after > 0. Kong and APISIX routes sharerep:<ip>; a
route that keeps the default 0 no longer blocks the IPs a stricter route
flagged. - A block response tells the client
X-Jev-Verdictand
X-Jev-Request-Idonly: Kong, APISIX, every JS host, the forward-auth
denial, the gRPC shim's deny and the LiteLLM guardrail's exception. A
client or test that readX-Jev-Score,X-Jev-ReasonorX-Jev-Source
off a 403 must read the log, the upstream headers oronVerdict. - Configs that were silently wrong are refused:
policy.block_status
outside 400-499, a non-stringpolicy.block_bodyor judge setting, JSON
nullin an override, a rule field of the wrong type (a string where a list
goes, a map forwatch_paths, a limit that is not a number), an unknown
template in a rule or inuntrusted.templates, a malformed
untrusted.fieldspath, and a ruleextendsthat is not a rule set id.
methodsmay now be a list (["POST"]). A refused file keeps the previous
config, as before, and is now reported (config_error, 503 from
/_jev/health). - New shared dict
jev_subject_repfor subject reputation, apart from
the trajectories injev_subject: declare it where you declare
jev_subject(example.nginx.conf, Kong'snginx_http_lua_shared_dict,
APISIX'scustom_lua_shared_dict). Without it reputation shares
jev_subject, with one warning per worker. - APISIX: declare jev-edge's dicts under
nginx_config.http.custom_lua_shared_dict; APISIX 3.13 drops them from
lua_shared_dict, which left the plugin without a cache, breaker or
reputation. The plugin's priority is 1000 (was 2450), it declares
run_policy = "prefer_route", andcheck_schemarefuses a missing or
broken rule set, so such a route is not loaded. - Kong hybrid mode: this release adds plugin schema fields
(questions_json,ssl_verify,subject.reputation,max_tokens,
token_param,temperature,extra_body_json, providerlaya). Upgrade
the control plane first and every data plane right after; a data plane on
an older version refuses every config push that uses a new field. - HAProxy:
jev-spoalistens on127.0.0.1:9000by default (pass
-listen :9000in a container). Ship the newspoe.conf(it sendsuri
and at most 88 KiB of body) andhaproxy.cfg(adeny_status 400line)
with the new agent. - Envoy: copy three changes from the reference configs:
path_with_escaped_slashes_action: REJECT_REQUEST,x-jev-body-partial
removed with the otherX-Jev-*before ext_authz, andenvoy-http.yamlno
longer forwardsx-envoy-external-address(trusted_hops = 1reads the
hopuse_remote_addressappends). - LiteLLM guardrail: it appends the peer LiteLLM saw to
X-Forwarded-For, so behind N appending proxies set jev-edge's
trusted_hopsto N + 1 (one more than before). TheHTTPExceptiondetail
is{error, request_id}; the verdict is onexc.jev_verdictand in the
proxy's metadata. Settings come fromJEV_EDGE_*when no keyword is
given, and it warns at startup withoutdefault_on: true. - JavaScript:
/_jev/healthanswers{ ok, adapter, core }unless
health: "details";honoMiddlewareputs onlyX-Jev-Request-Idon the
response; pass the Durable Object namespace (env.JEV_STATE) asstate,
not a stub kept across requests. - Laya: the profile's L2 timeouts are 500 / 800 ms (were 100 / 300) and
max_inflight = 1. laya-server'sLAYA_GATEWAY_TIMEOUT_MS(default 500)
replacesLAYA_QUEUE_MSand must equal the gateway'stimeout_ms; run
make conformancewithBUDGET_MS=500andCONCURRENCY=<max_inflight summed over gateways>.
Security
What L1 reads:
- Watch paths match the path the backend routes on.
;segment
parameters are dropped, empty and.segments removed,..resolved and
ASCII case folded, in the path and the pattern alike, on every call site
and both cores (paths_case_sensitive = truekeeps the case). Express,
Spring and Tomcat routed/V1/Chat/Completionsor/v1;a=b/chat/completions
to the chat handler while L1 said "path not watched". Kong matches the
decodedngx.var.uri(/v1%2Fchat/completionswas skipped), and the TS
core matches bytes,.across line terminators, as Lua does. - The generation routes and prompt fields LLM servers accept are
watched. Ollama/api/generate; LiteLLM and llama.cpp without/v1
(/chat/completions,/completions,/responses,/completion,
/infill);/engines/<m>/, Azure/openai/deployments/<name>/and
/openai/v1/;/v1/responsesand/v1/messages; the AI SDK's
/api/completion; GeminigenerateContentandstreamGenerateContent
(the Gemini API, Vertex AI, LiteLLM's/models/<m>:...) and
/v1beta/openai/chat/completions; SGLang, TGI and vLLM native routes
(/generate,/generate_stream,/vertex,/invocations, and TGI's/
for a JSON body only, through the newjson_only_paths); Open WebUI's
/api/v1/...,/ollama/...and/openai/...; LM Studio's/api/v0/...
and/api/v1/chat; Cohere/v2/chatand/v1/generate. New text fields:
system,instructions,preamble,system_prompt, Gemini
systemInstruction,contents[*].partsand function responses, Cohere
chat_historyanddocuments, AI SDK 5messages[*].parts(tool parts'
input and output), llama.cpp'sprompt_string,input_prefix,
input_suffixandinput_extra(filename and text), Responses
prompt.variables, TGIinputsandinstances, andsuffix. - A media Content-Type is a hint. A body under
image/png(or another
skip_content_typestype) is read like any other: JSON or text under it is
judged, and only a binary body passes as "content-type not watched".
Ollama, llama.cpp and FastAPI parse the JSON whatever the header says. - JSON the decoder refuses is scanned, never passed as "no text". cjson
refuses a lone surrogate escape, nesting past 1000 and bytes after the
value; Python, Node and Go read all three. Such a body, declared JSON or
one that starts with{or[under any type, has its text fields, tool
definitions and object arguments read by the scanner; undertext/plain
or no type the whole body is judged too. With nothing to scan, declared
JSON isunjudgeable: invalid json. - JSON keys match without regard to case, plus U+017F and U+212A, as Go's
encoding/json(Ollama) matches them:{"MESSAGES":[...]}passed as "no
text". - Tool-call arguments are judged with the text (OpenAI, Ollama,
Anthropictool_use, Responsesfunction_callandmcp_call, custom tool
input, AI SDK tool parts): a**field path reads every key and string
below it, a string of JSON decoded, in document order with its turn. - Tool definitions are judged as a part of their own, with the rule's
question and their own cache entry keyed by their text: every key and
string oftools,functionsand output schemas except JSON Schema type
names, scanned whole byalways_suspect, one window judged. A
pre-registered false-positive bench over 30 real tool sets
(bench/tools) found none at 0.5 with the
injectionquestion (highest 0.12); theuntrustedquestion flagged 14 of
30, so it is not used for them. Detection of poisoned tools was not
measured: this is not a tool-poisoning defence. - More model-visible shapes are read: Anthropic
documentblocks,
Responsesfile_search_callresults and every*_call_output, Cohere
documents, Gemini function responses, a Bedrock ConversetoolResult
(its text and json blocks), a tool or function message ...
v0.6.1
Install
luarocks install lua-resty-jev-edge
npm install @jev-edge/jsor opm get kiwi0719/lua-resty-jev-edge, or make install. Kong: KONG_PLUGINS=bundled,jev-edge (see adapters/kong).
Two security fixes, a gap in what L1 reads, and documentation that is quicker
to read. The Responses fix is the one behaviour change without opting in:
Responses API tool output now reaches L2 as part of the whole text.
Security
- In-flight overflow no longer trips the breaker. A call refused by the
gateway's ownmax_inflightcap (judge.BUSY, "max_inflight exceeded")
was recorded as a breaker failure, so a burst of about 85 concurrent
requests with distinct text could open the breaker and switch L2 (and L3)
off for every path and tenant foropen_s, repeatable every 30 s. Such a
call never reached the provider and is now not recorded at all; when a
request is judged in parts (chunks, retrieved content), a busy part no
longer turns answered parts into a failure. The request still passes as
verdict=error. Lua and JS cores.
Fixed
- Responses API tool results are judged. The default text fields never
read a Responsesfunction_call_output(its text is underoutput), so
withuntrustedoff every tool result in that shape went unjudged (0.6.0
held-out: AUC 0.497, every attack passing).input[*].outputis a default
text field now (llm-endpoints,default, and the fallback for inline
rules), as OpenAIrole: tooland Anthropictool_resultcontent already
were. Judged only as whole text, most attacks in it still pass (0.6.1
held-out: AUC 0.966, 72% missed at 0.5); turnuntrustedon for tool
traffic.
Removed
- IP trust at L1.
core/rules.luaandrules.tspassed any IP whose
reputation entry hadtrusted_until, but nothing ever wrote it; the L3
safecounter that would have fed it is gone too. Reputation now only
blocks: a run of safe verdicts must not let an attacker warm up an IP and
skip L2. Golden vectorip trustedis nowip trust is not a bypass.
Changed
- README cut to a five-minute read: what jev-edge is, a quick look, how it
works, install, the benchmark table. The operating guide (body size,
deployment context, thresholds, false positives, subject reputation,
retrieved content, Laya, cost, repository layout, roadmap) moved to
docs/design.mdas its Part 1; links elsewhere point there. - The Chinese documents are rewritten in Chinese rather than translated
sentence by sentence: README, design, cost, recipes and ops. - The held-out set was re-run on 0.6.1 and the numbers in the docs are that
run (untrustedoff 77.8% missed at 0.5, on 19.0%, 1 false positive in 700);
the 0.6.0 run stays in git history.
v0.6.0
Install
luarocks install lua-resty-jev-edge
npm install @jev-edge/jsor opm get kiwi0719/lua-resty-jev-edge, or make install. Kong: KONG_PLUGINS=bundled,jev-edge (see adapters/kong). Retrieved-content judging is off by default: untrusted = { enabled = true } in the config, or PUT /_jev/config with {"untrusted":{"enabled":true}} (README, "Retrieved content").
Retrieved content judged on its own, accuracy measured beyond deepset, and
Laya as an L2 judge with a System One conformance suite. Nothing changes for
an existing deployment unless it opts in (untrusted.enabled,
provider = "laya"); every existing golden vector is unchanged.
Added
- Laya as an L2 judge.
provider = "laya"(Lua and JS) sends the jev
System One request to a server you run; its scores stay apart from jev's in
the cache, the log and calibration.adapters/laya-server/serves a
fine-tuned Laya model over that protocol (Python, Dockerfile; ONNX, your
own Python scorer, or a mock backend): long text is judged in overlapping
windows in one batch and refused with 413 pastLAYA_MAX_WINDOWS, never
silently cut;fit_temperature.pyfits the temperature that makesnoula
calibrated probability;jev-laya.conf.luais the gateway profile (L2
timeout 100 / 300 ms instead of 400 / 1000,max_judge_bytes = 4096,
monitor mode). The base Laya model is not usable for this task without
fine-tuning, so no Laya benchmark and no default thresholds ship. - Protocol conformance suite.
conformance/: System One vectors built
by the real provider from the real templates (make conformance-vectors,
conformance-checkinmake check) andrun.py, which checks any judge
server for the answer set,noulrange, determinism, error codes, long
input, keepalive, aborted and stalled clients and p99 latency
(make conformance ENDPOINT=... [STRICT=1] [MOCK=1]).make test-laya
runs it against laya-server, including a server that truncates, which must
fail. - Per-provider question wording.
jev.questions.<template>replaces
instructions,criteria,instructions_ctxorcriteria_ctxfor the
jev/layarequest only, for wording validated on another judge. - Calibration per judge. The access log records
providerandmodel;
make calibraterefuses a log that mixes judges untilPROVIDER=/
MODEL=(--provider/--model) picks one, since their scores are not
comparable. - Retrieved content judged on its own (
untrusted, off by default, hot
reloadable, overridable per rule). L1 cuts retrieved content out of a body
parsed whole: OpenAIrole: "tool"/"function"messages, Anthropic
tool_resultblocks, Responsesfunction_call_outputitems, and any
untrusted.fieldsJSON path. It gets its own judging window and is judged
in a parallel call with the newuntrustedquestion, without the deployment
context; the whole-text judgment is unchanged and the request gets the
higher score. Its own cache entry (tool results repeated in a conversation's
history are not paid for again), a request fingerprint that covers it (trust
and the verdict cache cannot pass new retrieved content under old text), and
retrieved content judged alone next to a message too short to judge
(reasonretrieved content). Lua and TypeScript cores, 12 new golden vectors
(judge.by_questionin the vector format answers only the questions a
prompt asked), APISIX and Kong schemas, the starter config, and Test::Nginx
cases through the OpenResty adapter (hot toggle through/_jev/config, the
Responses shape, a malformed section refused; themockprovider takes
mock_scoresper question for them). On a held-out
set of 1,200 tool results the question was not written against, the shipped
core with it on cut misses at 0.5 from 86.8% to 19.2% with 1 false positive
in 700; one more call per request carrying tool content, L2 p50 277 → 293 ms. - Held-out set (
bench/datasets/heldout-v1.jsonl,make suite-heldout):
InjecAgent and LLMail-Inject phase 1 attacks against benign InjecAgent
templates and Hermes function-calling results, rendered as OpenAI, Anthropic
or Responses bodies, run end to end throughcore.evaluate. Committed
before the run. - The untrusted-segment experiment (
make suite-untrusted): the question
measured on suite v1's indirect records and on non-email tool results
(suite-v1-tooldocs), committed before its first run. It showed that the
question, not separating the text, does the work. - Accuracy chart (
docs/bench-accuracy-*.svg,make bench-chart), drawn
from the committed live results. - Template wording pinned across cores: a vitest case compares every
TypeScript template to itscore/templates/*.luafile. - Suite v1: accuracy beyond deepset.
bench/datasets/suite-v1.jsonl, 2,735
whole chat request bodies from seven MIT / Apache-2.0 sources: Chinese
instruction attacks (Safety-Prompts) against Chinese benign instructions,
over-defense look-alikes (NotInject), indirect injection in retrieved emails
and tool results (LLMail-Inject, BIPIA), and attacks spliced into real
multi-turn threads (OpenAssistant, Gandalf).make suite-fetch/
suite-build/suite-live [CTX=1]/suite-report. First live run
committed with its results: strong on Chinese override and multi-turn, weak
on indirect injection (BIPIA 81.5% miss at 0.5), and the general-assistant
deployment context raised false positives instead of helping. See
bench/suite/README.md, including two source categories whose labels did
not hold up.
Changed
- The latency chart is redrawn from a new run, and its "hard cut at
300 ms" label is gone:timeout_msis the adaptive timeout's floor, so the
slow-provider scenario waits up totimeout_max_ms(p99 478 ms against a
500 ms mock). The 0.5.0 code gives the same 475 ms; the old chart no longer
matched the code it described.
Fixed
- CodeQL
js/incomplete-url-substring-sanitizationin
adapters/js/test/worker.test.ts: the fetch stub matched the judge by URL
prefix, whichapi.typesafe.ai.examplealso passes; it compares the parsed
host now. Test-only, no runtime change.
Known gap
- Without
untrusted, Responses APIfunction_call_outputitems are not
judged at all (their text is underoutput, which no default text field
reads). The held-out run shows every attack in that shape passing with it
off. Turnuntrustedon when fronting the Responses API with tools.
v0.5.0
Install
luarocks install lua-resty-jev-edge
npm install @jev-edge/jsor opm get kiwi0719/lua-resty-jev-edge, or make install. Kong: KONG_PLUGINS=bundled,jev-edge (see adapters/kong). For Content-Encoding: br on OpenResty install brotli-libs (Alpine) or libbrotli1 (Debian).
Subject reputation, a Kong plugin, a Deno preset and the npm package, an
operations kit, judge robustness (including long text judged in chunks),
and CI that runs the previous audit's bug classes as tripwires. Nothing
changes for an existing deployment unless it opts in (subject.reputation,
max_judge_chunks); the openai-compat judge now treats an echoed planted
answer as an injection and an incomplete reply as an error.
Added
- Subject reputation (
subject.reputation): judged verdicts add points
per subject (a header such as an API key, a cookie, or the IP) over a
sliding window (suspicious 1, malicious 3 by default); pastblock_atthe
subject is blocked at L1 forblock_ttl, from any address and for any
text. Off by default. Counters are two keys per subject in the subject
store (atomicincr); L1 blocks never count.make calibratereplays the
window per subject from monitor-mode logs (log lines now carryts) and
recommendsblock_at.jev_subject_blocks_total. Golden vectors carry
the subject store and its writes; both cores replay them. - Kong Gateway plugin (
adapters/kong, Kong 3.x, same engine as the
APISIX plugin): priority 905 (after auth, acl and rate limiting), body via
resty.jev.bodyincluding spooled bodies, fail-open on every error; schema
mirrors the APISIX keys and runs core's validation;make e2e-kong(real
Kong 3.9, DB-less, 18 checks) runs in CI. The rockspec ships the plugin. - Deno Deploy preset (
@jev-edge/js/deno:denoHandler, and
denoKvStorewith atomicincron Deno KV). - npm release workflow (
release-npm.yml): on av*tag, checks that
the tag matches the package version, tests, builds, checks every export
resolves and publishes with provenance whenNPM_TOKENis set (skips
otherwise). Package metadata,LICENSEandprepublishOnlyadded. - Operations (
ops/): a Grafana dashboard, Prometheus alert rules
(breaker open, L2 error ratio, unjudgeable ratio, L2 timeout at its
ceiling, async drops, L2 starved) withpromtool test rulesunit tests run
in CI; new metricsjev_feedback_total{label,result}and
jev_l2_timeout_max_ms. - Judge robustness:
bench/datasets/judge-directed.jsonl(45 cases:
verdict requests, text addressing the classifier, fake answer JSON, fake
end-of-input markers, retractions, other languages, buried instructions,
and benign look-alikes) andmake bench-judge(offline L1 pass; the live
part needs a provider key); sixalways_suspectpatterns for
judge-directed text, each with a golden positive, and negatives such as
"Is this email safe to open?". - Judging long text in chunks (
max_judge_chunksin a rule, default 1):
text overmax_judge_bytesis split into up to that many chunks (cut at
a newline where possible, never inside a character), judged in parallel
(judge.call_many:ngx.threadon OpenResty, APISIX and Kong,
Promise.allin JS), the highest chunk score wins, each chunk has its own
cache entry. Past the cap, the newest chunks plus a window over the rest,
orunjudgeable: text over max_judge_chunkswithpolicy.unjudgeable = "block". An instruction in the middle of a long message that no pattern
matches is no longer cut out when chunks are on. Golden vectors accept
inline rule specs (7 chunk cases in both cores). - Partial bodies are covered end to end: Envoy (HTTP and gRPC, cut at
max_request_bytes) and HAProxy (pasttune.bufsize, Content-Length and
chunked) flag them, and the e2e suites check that an attack in the part
that arrived is blocked and a benign one is judged.docs/design.mdhas
a "Traffic L1 does not see" section (WebSocket frames, Realtime API,
responses, multimodal content, headers-only and partial-body gateways). scripts/invariants.lua(make invariants, part ofmake checkand CI):
tripwires for the bug classes the 0.4.0 audit found (one version
everywhere, every module in the rockspec, headers read without the 100
limit, cache keys only throughcore.cache_key, SHA-256 fingerprints,
X-Forwarded-For read from the right, upstream URLs that keep their host,
gateway configs that drop forged identity headers,$(CURDIR)in the
Makefile, the same rule set in Lua and TypeScript). Run against v0.3.1 it
reports 25 problems.securityworkflow: CodeQL (TypeScript, Go, Python, workflows),
govulncheck on both Go binaries (blocks when a released fix exists) and
pnpm audit. Dependabot for actions, npm, Go modules and Dockerfiles.SECURITY.md(private reports through GitHub advisories, and what counts
as a vulnerability for a filter),CODE_OF_CONDUCT.md(Contributor
Covenant 2.1) and a pull request template with the CONTRIBUTING checklist.
Changed
- CI: core specs on Lua 5.1, 5.4, 5.5 and OpenResty's LuaJIT (plus the LuaJIT
bytecode check); the Test::Nginx suite and the four e2e jobs share one
image built fromDockerfile.testwith a layer cache; e2e is one matrix
job; the HAProxy agent's Go tests run (with-race, as the shim's do),
gofmtis enforced, Go is the latest 1.26 patch;pnpm buildruns;
LiteLLM getsruff(pyflakes, bugbear); docs-only changes skip CI; a
newer push cancels a PR's running CI; the token is read-only; a weekly
run catches upstream gateway images that moved.
Fixed
@jev-edge/jscould not be loaded by Node or Deno:dist/used
extensionless relative imports (fine for bundlers,ERR_UNSUPPORTED_DIR_IMPORT
for Node's ESM loader, sonodeMiddlewareand Lambda@Edge only worked
bundled). Every import has its.js, and the build usesNodeNextso an
extensionless import no longer compiles.- openai-compat judge prompt hardened, the same in Lua and TS: the text
sits between per-request random markers it cannot contain; every
top-level JSON object in the reply is read and each question takes its
highest value, so a low answer echoed from the input cannot lower the real
one; a reply missing an asked question is an error, not a lower score;
null,false,""and[]are no longer read as 0 in TS. The
injection template tells the judge that text addressing it is itself a
signal. - openai-compat: a reply that only repeats an answer planted in the judged
text scores 1, the same in Lua and TS (compared on parsed values). It
was read as the model's own answer, so a planted{"injection": 0}that a
small model echoed passed the request. - A byte cut through a multi-byte UTF-8 character no longer reaches the L2
prompt: the head and tail of an oversized body, and a gateway's partial
body, end on character boundaries. .gitignore'sjev-edge/matched any directory of that name; it is
anchored to the repository root.- The feedback endpoint and the mock provider read request headers without
the 100-header limit, like the rest of the adapter. - HAProxy SPOA image built with Go 1.26 (was 1.23, out of support, with
standard-library advisories open) on Alpine 3.22. - JS dev dependencies: vitest 4 and vite 7 (vitest 2 and its vite had a
critical and a high advisory; dev only, nothing shipped in the package).
v0.4.0
Install
luarocks install lua-resty-jev-edgeor opm get kiwi0719/lua-resty-jev-edge, or make install. For Content-Encoding: br on OpenResty install brotli-libs (Alpine) or libbrotli1 (Debian).
L1 now reads a watched request the way the backend will, and reports the
requests it still cannot read instead of passing them as "no text". Plus the
fixes from a full audit of 0.3.1. Four changes alter what an existing
deployment sees; read Changed before upgrading.
Changed
max_body_bytesis 1 MiB (was 64 KB), nginx's default
client_max_body_size. Bodies up to it are parsed whole. Past it the body is
no longer passed as "body too large": its firstmax_body_bytesand last
64 KiB are scanned for the text fields' string values (truncated JSON
included) and judged, with(window)at the end of the reason. Where to
raise it (rule, nginx, Envoy, HAProxy, Traefik, APISIX, JS) is in README
"Body size and what L1 reads"; every limit on the path has to agree.- Content-Type is a hint, not a gate.
llm-endpointslists
skip_content_types(media:image/,audio/,video/,font/, PDF,
zip, gzip) instead of an allow list, and the body decides the format: JSON
when it parses as JSON whatever the header (Ollama and FastAPI read it that
way;text/json, none,application/octet-streamwere "not watched"),
forms andmultipart/form-datafields (text file parts too), other text
whole. A rule that listscontent_typeskeeps the allow list. - Text over
max_judge_bytes(new, 32 KiB) is judged on a window: the
always_suspecthit (all of the text is scanned for it) with 1 KiB either
side, then values newest first. The fingerprint and L2 see the window; so
does L3 (rules.judged_text).ctx.re_findshould return the match's byte
span (from, to, asngx.re.finddoes) so the hit lands in the window. - Verdict-cache keys are
fp:<scope>:<fingerprint>(see Security); the
cache refills after the upgrade. Subject history in the JS runtime moves to
ring keys, so earlier KV history is not read again.
Added
Content-Encodinggzip, deflate and br are decoded before L1
(resty.jev.decodethrough FFI to zlib and libbrotlidec;DecompressionStream
andnode:zlibin JS), capped atmax_body_bytesso a small compressed body
cannot expand into memory. Express's body-parser inflates request bodies by
default, so a compressed prompt used to reach the app unjudged.brneeds
brotli-libs(Alpine) orlibbrotli1(Debian) on OpenResty images.policy.unjudgeable = "pass" | "block"(defaultpass) for a watched
request L1 cannot read: an encoding it cannot decode, a binary body, or an
oversized body with no text in its head or tail. It is passed as
X-Jev-Verdict: skippedwithX-Jev-Reason: unjudgeable: <why>, or
rejected in enforce mode withblock. Metrics:jev_unjudged_total{reason},
jev_window_total.- Gateways that hand over part of a body say so, and jev-edge scans it as a
head: Envoy'sx-envoy-auth-partial-body(example configs now allow 1 MiB
and forwardcontent-encoding), and the HAProxy SPOA agent's
X-Jev-Body-Partialfrom HAProxy's declared body size (newsizearg in
spoe.conf). nextMiddleware(request, event)forwards Next's event, sowaitUntilkeeps
the subject write alive.
Security
-
A repeated
Content-Typeheader no longer skips judging. OpenResty and
APISIX hand a repeated header to Lua as a list; L1 called:lower()on it,
raised, and the adapter failed open withX-Jev-Verdict: error. The values
are now joined (rules.content_type) and the content type is watched when
any of them is. -
The verdict cache is scoped to the prompt that produced the score. The
key wasfp:<fingerprint>, so a SAFE judged under a lenient tenant rule,
deployment context, provider or model (an APISIX route with
provider = "mock") was replayed on a strict one. The key is now
fp:<scope>:<fingerprint>(core.cache_key), scope = rule id, templates,
deployment context, provider and model. L3 writes the same key. Trust stays
keyed by fingerprint alone. Cached verdicts from earlier versions are not
read again; the cache refills. -
A judge answer with no score is an error, not SAFE.
{"answers":{}}or
only non-numeric values reduced to score 0, counted as a breaker success
and was cached as SAFE forfp_ttl. It now takes theon_errorpath and
writes nothing (L2 and L3).judge.reducereturns the count of numeric
answers as a third value. -
A UTF-8 BOM before a JSON body no longer hides the text. cjson rejects
it, extraction returned "no text" and L1 passed, while Python and Express
backends skip the BOM and read the prompt. -
The JS runtime fingerprints with SHA-256. 0.3.1 said it did; it still
passeddjb2, so a few appended letters could land an attack on a cached
SAFE fingerprint.core.sha256Hex(synchronous, same hex as
resty.sha256) is now the runtime's hash. -
Watch paths match the path the origin routes on.
forward_auth(from
X-Forwarded-Uri/X-Original-URI) and the JS runtime now percent-decode
the path and collapse duplicate slashes before the watch list, as nginx
does for$uri;/v1/%63hat/completionsand//v1/chat/completionswere
"path not watched" while the backend served/v1/chat/completions. -
Cloudflare Workers forward to the configured upstream only. The
forwarding URL wasnew URL(path, upstream), and a request path starting
with//made that a different host (an open proxy, unjudged). -
The LiteLLM guardrail keeps every text form core judges. It kept only
type == "text"parts and stringinputitems, so Responses API input,
input_textparts and Anthropictool_resultarrived empty or were
skipped without a call; apromptnext tomessageswas ignored. -
The client cannot pick the IP or path it is judged under.
forward_authno longer readsX-Envoy-External-Address(only Envoy sets
it;authzstill does). The HAProxy SPOA agent, the Caddyfile and the
nginxauth_requestconf drop client copies ofX-Envoy-External-Address
andX-Real-IP, and the nginx sub-request setsX-Forwarded-Uri/
X-Forwarded-Methoditself: a client'sX-Forwarded-Uri: /healthzmade the
request "path not watched" and skipped the reputation block. The JS runtime
readsX-Forwarded-Forfrom the right (client_ip.trusted_hops), trusts
cf-connecting-iponly on Cloudflare (or a configuredclientIpHeader),
and the Node middleware appends the socket address like any proxy. The
LiteLLM guardrail forwards the wholeX-Forwarded-Forchain, not its
client-written first entry. -
More than 100 request headers no longer hide
Content-Type. OpenResty
and APISIX read all headers (ngx.req.get_headers(0)); past the default
100 the rest were dropped and L1 passed "content-type not watched". -
A JSON
nullinmessagesor in content parts no longer ends the list.
The JS port stopped there (it followed the test decoder, which leaves a
hole, not cjson, which keepsnullas a value); the Lua specs now decode
bodies the way cjson does. -
The Node middleware judges the body the app will read. A form parsed by
express.urlencoded()was re-encoded as JSON and yielded no text; Express
4'sjson()placeholder{}for a body it did not parse was judged instead
of the stream; and a stream that had fully arrived but was not yet read
(req.complete) was taken for consumed, so the request was not judged. -
LiteLLM guardrail: the whole
X-Forwarded-Forchain wins over
requester_ip_address, which LiteLLM may itself take from the
client-written first entry (use_x_forwarded_for).
Fixed
- JS subject history is a ring (one atomic counter, one key per entry), as
on OpenResty: concurrent requests no longer lose entries. Atomic on the
memory store and the Durable Object; best effort on KV. Stores without
incrkeep the list layout. - APISIX picks the rule for L3 and sampling by path, method and content type
(rules.rule_for), like the OpenResty adapter; it used path alone. - APISIX keeps breaker, adaptive timeout and in-flight counters per provider,
endpoint and model instead of one set for every route, and skips L3 while
the breaker is not closed. jev_tokens_totalis recorded: the usage callback was called with an
extra argument and dropped every sample.- Subject trajectories: the ring counter's ttl is extended on every append
(it expiredhistory_ttlafter the subject's first request, however
active), and each slot carries its sequence number so a read between
another worker'sincrandset, or aftermax_entrieschanged, skips
the slot instead of returning a stale entry. - The breaker's half-open probe is claimed with an atomic
addwhere the
store has one, so exactly one worker probes. watch_pathsvalidation rejects capture errors (unbalanced parentheses,
back-references to a missing or open capture), which lstrlib only raises
once a request reaches them, silently failing that rule open.- Whitespace-only text has a fingerprint, so it is cached like any other
text instead of costing a judge call per request. - nginx
auth_request: a deny answers with a JSON body; the README says
block_statusmust be 401 or 403 there (anything else becomes a 500). - Traefik:
maxBodySizeremoved from the example; past it Traefik denied
with 401 instead of letting jev-edge pass the request as "body too large". - Envoy gRPC shim: default timeout 1.5 s, below Envoy's 2 s, so a slow
jev-edge still yieldsX-Jev-Verdict: errorinstead of a silent pass. - Makefile: Docker targets mount
$(CURDIR);$$(PWD)only worked on
case-insensitive filesystems.
v0.3.1
Install
luarocks install lua-resty-jev-edgeopm (opm get kiwi0719/lua-resty-jev-edge) is still indexing this version; LuaRocks and make install work now.
Patch release from a full audit of 0.3.0. Everything below is a fix; there is
no new feature and no config key was removed. Two entries change observable
values: fingerprints and subject ids are different strings than in 0.3.0
(caches simply refill), and X-Forwarded-For is read from the other end.
Security
- Content-parts bodies are judged.
messages[*].contentin the array
form every current chat API accepts ([{type="text", text=...}, ...],
Responses APIinput_text, Anthropictool_resultwith nestedcontent)
used to extract no text, so the request passed L1 as "no text" in every
mode.normalize.extractnow collects strings, each part'stextand
nestedcontent, on every adapter (golden vectors added). - Fingerprint is SHA-256 over the whole normalized text. 0.3.0 hashed a
2048-byte prefix withcrc32_long(djb2 in the JS runtime). Either let a
chosen text reuse another text's cached or operator-trusted verdict: any
suffix behind a shared prefix, or a few appended bytes to hit a chosen
32-bit value.fp_prefix_bytesnow only bounds sampled and logged text. - Client IP behind a proxy is the appended hop, not the first element.
authzandforward_authtook the leftmostX-Forwarded-Forvalue, which
the client writes, so reputation blocks could be aimed at any address and
evaded by rotating the header. They now take elementclient_ip.trusted_hops
from the right (default 1, the value the proxy in front appended);
x-envoy-external-addressis used when Envoy sends it. GET /_jev/configredactsjev.api_key,feedback.tokenand
subject.salt, in both the effective config and the override.- Admin endpoints on their own listener in the example config, the demo
(127.0.0.1:8090) and the recipes: a gateway that reaches/_jev/authzon
the traffic port could reach/_jev/configthrough..path tricks.
The Envoy configs turn onnormalize_path/merge_slashes, HAProxy and
the gRPC shim / SPOE agent refuse..,%2eand//(fail-open). - Inbound
X-Jev-*headers are stripped by the Envoy, Caddy, Traefik and
nginx auth_request reference configs too, so a forgedX-Jev-Verdict: safe
cannot reach the upstream on the fail-open path.X-Jev-Subjectis
stripped as well unless it is the configured subject header. - Subject values with
hashed = truemust look like an id we computed
(<from>:<hex>), and raw subject values over 512 bytes are dropped. - Subject ids use SHA-256 on every adapter (OpenResty and APISIX used SHA-1).
Fixed
- Core: the breaker re-tripped on the first success after a probe closed it,
because the window that tripped it was still counted; the window is reset
on close. A suspicious cache hit no longer setsasync, so a repeated
suspicious prompt is one L3 call, not one per hit.body_sizeis the
larger of the declared size and the body handed over.rules.resolveon a
string spec returns a copy with defaults applied instead of the shared
module table, and rejects malformedwatch_pathspatterns (a bad pattern
used to raise on every request and fail everything open).encode_reason
truncates after encoding, never inside a%XXescape, so the header is
really <= 200 bytes.defaults.validaterejects thresholds outside
[0,1], non-HTTPblock_status, zero breaker windows, zero
sampling.max_samplesand other values that made a gate degenerate.
Text that normalizes to nothing (digit runs, UUIDs) is fingerprinted as
typed instead of being judged on every request. - OpenResty: L3 builds the same prompt L2 did (deployment context, the rule
that actually matched on path, method and content type), gets the ceiling
timeout instead of the adaptive estimate that just failed, is not started
while the breaker is open, and its timeouts no longer feed the adaptive
estimate. In-flight counters (inflight:l2,inflight:l3) are released on
every exit including premature timers at reload and Lua errors, and never
go negative after an eviction.openai-compatno longer stores its
question set on the shared config table (concurrent requests with
different templates got each other's filter).PUT /_jev/configand
POST /_jev/feedbackread bodies nginx spooled to disk. A broken rule in an
override is a 422, not a log line./_jev/healthwith a misconfigured
provider is a 503 JSON body, not a traceback.X-Forwarded-Uriis
normalised (//,.,..) before matchingwatch_paths.resty.jev.cache
warns once when a dict starts evicting. The+jsoncontent types the
extractor already decoded are now watched byllm-endpoints. - Subject trajectories on OpenResty and APISIX are stored as a ring (one
atomic counter plus one key per entry) instead of one list per subject:
the old append was a read-modify-write in a per-request timer, so two
workers recording the same subject lost entries and a burst could exhaust
lua_max_pending_timers. Existingsubj:list keys are simply ignored. - New optional
lua_shared_dict jev_state: trust grants, breaker state,
in-flight counters and the adaptive estimate move there when it is
declared, so a flood of new prompts fillingjev_cachecannot evict them.
Without it everything stays injev_cacheas before. - JavaScript: fail-open now covers the whole request path (body read,
subject store,onVerdict, block response), not only the core call; the
provider timeout covers the response body, not just the headers; Lua
pattern conversion no longer corrupts-inside[...]; byte truncation
stops at a UTF-8 boundary; bodies withoutContent-Lengthare bounded;
nodeMiddlewareno longer hands the app a placeholder body;cf-rayis
only trusted on Cloudflare; Hono and Lambda@Edge presets strip inbound
X-Jev-*and honourblock_status;enginesisnode >= 20and@types/nodefollows the Node 20 line. - Envoy gRPC shim: a response without
X-Jev-Verdict(404, 5xx, a sidecar
error) is fail-open, as documented, instead of a deny; fail-open overwrites
forged headers. HAProxy SPOE agent and LiteLLM guardrail: block is
status >= 400withX-Jev-Verdict, so a non-403block_statusblocks
instead of failing open; the agent timeout is below HAProxy'stimeout processingso the fail-open answer can arrive;Expectand hop-by-hop
headers are not forwarded. LiteLLM percent-decodes the reason. APISIX
reports chunked bodies over the cap as "body too large", not "no body". - Docs: thin-adapter contract written down once in
docs/recipes.md; stale
test counts, the misplaced version note indocs/design.md, and the
Makefile comment;.gitignorecovers defaultgo buildoutputs.
v0.3.0
Added
- Subject extraction and storage behind the trajectory contract:
subject = { enabled, from = ip|header|cookie, name, salt, hashed, history_ttl, max_entries }on OpenResty, APISIX and the JavaScript hosts. The raw value
is hashed with the per-deploymentsalt(SHA-1 / SHA-256) before storage
or logging; the config is rejected without one. Trajectories live in their
own bounded store (lua_shared_dict jev_subject,subjectStore) so a
flood of subjects evicts trajectories, never verdicts or trust. The thin
Worker forwards its hashed id asX-Jev-Subject(hashed = trueat the
origin).$jev_logcarriessubject. Test::Nginx08-subject.t.
Added
-
False-positive feedback loop (
core/trust.lua,
adapters/js/src/core/trust.ts,POST /_jev/feedback,make labels): an
operator marks a blocked request "not an attack" and every later request
with that exact text passes at L1.5 — before the verdict cache, so it beats
a stale malicious score — without an L2 call.feedback = { enabled = true, token = ... }turns it on; the token is required, because the endpoint
writes bypasses.label = "attack"revokes, so undoing a mislabel costs
what making one did.Three decisions are part of the design, not configuration defaults to be
shrugged at:Trust expires.
trust_ttlis seven days, and traffic on the same
fingerprint extends it at mostmax_renewals(4) times — about five weeks,
after which the false positive deliberately comes back. A fingerprint is
derived from attacker-visible text, so a permanent entry would be a standing
bypass nobody reviews again; a template still tripping the judge after five
weeks is a rule ordeployment_contextbug, and the alert is the point.
Renewals are bounded, so a fingerprint costs at mostmax_renewalswrites
ever, and only past half its life.Trust is local to the gateway. It lives in the existing shared dict: no
new component to run or make highly available, and no partition that fails
the loop open across a fleet. Other gateways converge as they see the same
text. Core reads and writes it throughctx.trust, which defaults to
ctx.cache, so moving trust to a shared store is an adapter change rather
than a core one — deliberately not the default path.The labels file is derived, never written. The worker appends to no
file: no hot-path write, no multi-worker race, nothing lost with the
container. Each report is one line in the jev access log (src="feedback",
withfp,label,by,rid), which is already collected, rotated and
auditable, and correctable by reporting again.make labels LOG=... OUT=...
(bench/labels-from-log.lua) replays those lines, last report per
fingerprint winning, into the labels filemake calibratereads.Six golden vectors pin the behaviour across both cores (trusted pass, trust
over a cached malicious score, expiry, disabled, renewal, renewal cap);
core/spec/trust_spec.luaandadapters/openresty/t/07-feedback.tcover
the module and the endpoint end to end. -
Subject trajectory contract (
core/subject.lua,adapters/js/src/core/subject.ts):
evaluateaccepts an optionalctx.subject = { id, history, record }and
hands one flat trajectory entry torecordon every exit that made a
decision (not the L1 pass path). The entry carries the raw score, not only
the label, so it can be replayed later as calibration input.This version records; it does not decide.
historyis accepted and
ignored, and both golden vectors andcore/spec/subject_spec.luaassert
that a request with a subject and a non-empty history gets the same verdict,
field for field, as one without. Scoring a subject over time needs a window
length, a decay factor and a threshold, and there is no traffic yet to
calibrate any of them; a guess shipped as a default would be worse than the
absent feature. The shape is frozen now so that turning recording into
deciding is an implementation change in two files rather than a contract
change in every adapter and vector.The read/write split is part of the contract:
historyis one store lookup
on the request path and may always be absent (cold subject, evicted entry,
a store that lost it — never an error), whilerecordis a sink the core
hands a value to and never waits for. On nginx a shared dict satisfies that
as is; on Workers it keeps a Durable Object hop a deployment choice rather
than something the request path pays for. -
Golden vectors in
core/golden/: 131 cases across six suites (normalize,
extract, rules, policy, verdict, evaluate) generated from the Lua core by
core/golden/gen.lua, replayed bycore/spec/golden_spec.lua, and checked
for drift bymake golden-checkin CI (make checkincludes it). They are
the cross-implementation contract the Cloudflare TypeScript core will be
held to;core/golden/README.mdstates what parity covers and what is left
to each platform. -
make calibrate LOG=<jev log> LABELS=<labels> [MAX_FP=]
(bench/calibrate.lua): score distribution, would-have-blocked table, AUC,
false-positive and miss rates per threshold, and a recommended
block_threshold/suspect_thresholdunder a false-positive budget, from a
monitor-mode$jev_logfile plus labels keyed by request id or fingerprint.
--jsonfor scripts. Install step 7 and a "Choosing thresholds" README
section point to it. -
JavaScript adapter (
adapters/js, npm package@jev-edge/js, first as Cloudflare only):
a TypeScript port of core that replays the same golden vectors under vitest
(116/116), plusthinWorker(L1 and cache at the edge, judgment by an
existing jev-edge via/_jev/authz),fullWorker(KV cache, Durable Object
JevStatefor breaker and adaptive timeout,jev/openai-compat/
mockproviders) andpagesMiddleware.handle()andevaluate()for
other frameworks. Wrangler examples, README with the parity boundary, CI job. -
make context-lint CONF=<conf.lua> | TEXT=<context>(bench/context_lint.lua):
checks a deployment context for length, generic phrasing, a refusal list, an
audience, "be safe" instructions and proper nouns; FAIL on missing or
generic, WARN otherwise,--jsonfor scripts. -
make test-js. -
Apache APISIX plugin (
adapters/apisix): same engine, per-route config with
the config-file keys,$jev_logas an APISIX variable; e2e against real
APISIX 3.13 (make e2e-apisix, CI job). -
HAProxy SPOE agent (
adapters/haproxy/spoa, Go) withspoe.confand a
referencehaproxy.cfg; forwards the original headers and body to
/_jev/authz, setstxn.jev.*, fails open; e2e against real HAProxy 3.1
(make e2e-haproxy, CI job). -
LiteLLM proxy guardrail (
adapters/litellm/jev_edge_guardrail.py):
async_pre_call_hookthat asks/_jev/authz, annotates
metadata.jev_verdict, blocks with 403 in enforce mode, fails open;
make test-litellm, CI job. -
docs/recipes.md(en, zh-CN): the/_jev/authzcontract and configuration
for Istio, Envoy Gateway, Azure API Management and Apigee. -
@jev-edge/js(renamed from@jev-edge/cloudflare, directory
adapters/js):nextMiddleware,nodeMiddleware,honoMiddlewareand
lambdaEdgeHandleron the shared runtime, with tests; Cloudflare presets
unchanged under./cloudflare. -
Multi-tenant rules:
rulesentries may be inline tables withextends
(core/rules.luaresolve()), each with its ownwatch_pathsand
deployment_context; first match wins. Supported in the config file and
PUT /_jev/config, the APISIX plugin conf, and the JavaScriptrules
option. Test::Nginx06-samples-tenants.t. -
Decision sampling:
samplingconfig section (core/sampling.lua,
adapters/js/src/sampling.ts),GET|DELETE /_jev/sampleson OpenResty,
shared-dict ring on APISIX,onSamplecallback on the JavaScript hosts.
Normalized text only, off by default.
Changed
- README (en, zh-CN) restructured for first-time readers: Status table,
"Try it in 30 seconds" ondemo/, latency figures quoted with their
measurement conditions, Contributing spells out the test and bench
commands. Design moved todocs/design.md, the cost table to
docs/cost.md. Architecture diagram no longer claims a fixed 300 ms cut.
v0.2.0
Gateways beyond OpenResty, one engine. Every adapter in this release calls the same Lua evaluate path; none adds judgment logic of its own.
Envoy
- HTTP ext_authz:
location /_jev/authz/on the OpenResty adapter. 200 +X-Jev-*headers or 403 + block body; client IP fromx-envoy-external-address/x-forwarded-for; adapter errors fail open. - gRPC ext_authz:
adapters/envoy/grpc-shim, a ~150-line GoAuthorization/Checkthat forwards to/_jev/authzand fails open when the adapter is unreachable. - Reference configs
envoy-http.yaml,envoy-grpc.yaml; Docker Compose e2e against real Envoy 1.31 on both transports (12 checks, in CI).
Traefik, Caddy, nginx (forward-auth)
location = /_jev/forward-auth: original method/URI fromX-Forwarded-*orX-Original-*, client fromX-Forwarded-For.- Traefik ≥ 3.3 with
forwardBody: truegets full L2 verdicts. Caddyforward_authand nginxauth_requestforward headers only: path, method and IP reputation are enforced, everything else isskippedwith reasonno body. - Reference configs
traefik.yml,Caddyfile,nginx-auth-request.conf; Docker Compose e2e against real Traefik v3.3, Caddy 2 and nginx 1.27 (10 checks, in CI).
Changed
- L1 checks IP reputation immediately after the path match, before method, content-type and body gates, so headers-only requests from a blocked IP are denied. A watched request without a body passes with reason
no body. - License: MIT → Apache 2.0. 0.1.x releases remain under MIT.
Docs
- README (en + zh-CN): deployment-context writing guide with three examples, cost estimate from live token counts and TypeSafe's published price, independent-project note up top, dataset caveat.
Install: make install, or opm get kiwi0719/lua-resty-jev-edge once the opm index picks it up. Start in monitor mode.