Skip to content

Releases: kiwi0719/jev-edge

v0.6.4

Choose a tag to compare

@kiwi0719 kiwi0719 released this 28 Sep 15:01
541396a

Install

luarocks install lua-resty-jev-edge
npm install @jev-edge/js

or 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_CONNECTIONS answers 503 at once, without
    reading the request, so /healthz gets the 503 too while it is saturated.
    The Docker HEALTHCHECK marks the container unhealthy only after about 30 s
    of continuous saturation.
  • jev.questions now applies to openai-compat in both cores. A config
    that set wording for it used to be ignored silently; the judge now reads it.
  • POST /_jev/config answers 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
    subjectStore still holds both.
  • Apigee / APIM recipes changed: copy them again if you use them (see
    Fixed).

Security

  • Text over max_judge_chunks could 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_inflight was not enforced in the JS runtime. It is now, per
    isolate: past the cap a call passes as error (kind busy) 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-Subject before evaluation even when the config
    consumes it; it is still stripped from the forwarded request.
  • The Envoy gRPC shim reads header_map when Envoy's encode_raw_headers is
    on (every header but method and path was lost).
  • Apigee recipe: the ServiceCallout has continueOnError="true", so it
    fails open as documented. APIM and Apigee recipes treat any 4xx with an
    X-Jev-Verdict as a block (they matched only 403, so block_status = 429
    forwarded blocked requests) and pass jev-edge's status through.
  • make calibrate recommends 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.py sizes the recommended timeout_ms from the NFKC case
    when it is the slower one.
  • bench/chart.lua computes log10 under Lua 5.1 too.
  • Tests: the Test::Nginx raw-subject privacy check could never fail; the
    laya truncation test passed on a TypeError instead of the check it names.

Bench

  • The slow scenario pins timeout_max_ms = 300: the adaptive timeout had
    grown past the 500 ms mock and the scenario read ~480 ms at p99.
  • make bench passes CONN, DUR and OUT into the container; the bench
    bodies are written to bench/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

Choose a tag to compare

@kiwi0719 kiwi0719 released this 27 Sep 16:08
57a581a

Install

luarocks install lua-resty-jev-edge
npm install @jev-edge/js

or 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

Choose a tag to compare

@kiwi0719 kiwi0719 released this 26 Sep 21:08
1eb472f

Install

luarocks install lua-resty-jev-edge
npm install @jev-edge/js

or 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-endpoints gains
    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 extends llm-endpoints turns it off with tool_fields = {}.
  • Cache and fingerprint keys change once. The fingerprint keeps digit
    runs and UUIDs; the verdict-cache scope adds jev.endpoint and 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/feedback for a text with a digit run or a UUID must be reported
    again.
  • Subject ids from Authorization and Proxy-Authorization change 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 share rep:<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-Verdict and
    X-Jev-Request-Id only
    : 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 read X-Jev-Score, X-Jev-Reason or X-Jev-Source
    off a 403 must read the log, the upstream headers or onVerdict.
  • Configs that were silently wrong are refused: policy.block_status
    outside 400-499, a non-string policy.block_body or judge setting, JSON
    null in an override, a rule field of the wrong type (a string where a list
    goes, a map for watch_paths, a limit that is not a number), an unknown
    template in a rule or in untrusted.templates, a malformed
    untrusted.fields path, and a rule extends that is not a rule set id.
    methods may 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_rep for subject reputation, apart from
    the trajectories in jev_subject: declare it where you declare
    jev_subject (example.nginx.conf, Kong's nginx_http_lua_shared_dict,
    APISIX's custom_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", and check_schema refuses 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, provider laya). 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-spoa listens on 127.0.0.1:9000 by default (pass
    -listen :9000 in a container). Ship the new spoe.conf (it sends uri
    and at most 88 KiB of body) and haproxy.cfg (a deny_status 400 line)
    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 other X-Jev-* before ext_authz, and envoy-http.yaml no
    longer forwards x-envoy-external-address (trusted_hops = 1 reads the
    hop use_remote_address appends).
  • LiteLLM guardrail: it appends the peer LiteLLM saw to
    X-Forwarded-For, so behind N appending proxies set jev-edge's
    trusted_hops to N + 1 (one more than before). The HTTPException detail
    is {error, request_id}; the verdict is on exc.jev_verdict and in the
    proxy's metadata. Settings come from JEV_EDGE_* when no keyword is
    given, and it warns at startup without default_on: true.
  • JavaScript: /_jev/health answers { ok, adapter, core } unless
    health: "details"; honoMiddleware puts only X-Jev-Request-Id on the
    response; pass the Durable Object namespace (env.JEV_STATE) as state,
    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's LAYA_GATEWAY_TIMEOUT_MS (default 500)
    replaces LAYA_QUEUE_MS and must equal the gateway's timeout_ms; run
    make conformance with BUDGET_MS=500 and CONCURRENCY=<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 = true keeps the case). Express,
    Spring and Tomcat routed /V1/Chat/Completions or /v1;a=b/chat/completions
    to the chat handler while L1 said "path not watched". Kong matches the
    decoded ngx.var.uri (/v1%2Fchat/completions was 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/responses and /v1/messages; the AI SDK's
    /api/completion; Gemini generateContent and streamGenerateContent
    (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 new json_only_paths); Open WebUI's
    /api/v1/..., /ollama/... and /openai/...; LM Studio's /api/v0/...
    and /api/v1/chat; Cohere /v2/chat and /v1/generate. New text fields:
    system, instructions, preamble, system_prompt, Gemini
    systemInstruction, contents[*].parts and function responses, Cohere
    chat_history and documents, AI SDK 5 messages[*].parts (tool parts'
    input and output), llama.cpp's prompt_string, input_prefix,
    input_suffix and input_extra (filename and text), Responses
    prompt.variables, TGI inputs and instances, and suffix.
  • A media Content-Type is a hint. A body under image/png (or another
    skip_content_types type) 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; under text/plain
    or no type the whole body is judged too. With nothing to scan, declared
    JSON is unjudgeable: 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,
    Anthropic tool_use, Responses function_call and mcp_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 of tools, functions and output schemas except JSON Schema type
    names, scanned whole by always_suspect, one window judged. A
    pre-registered false-positive bench over 30 real tool sets
    (bench/tools) found none at 0.5 with the
    injection question (highest 0.12); the untrusted question 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 document blocks,
    Responses file_search_call results and every *_call_output, Cohere
    documents, Gemini function responses, a Bedrock Converse toolResult
    (its text and json blocks), a tool or function message ...
Read more

v0.6.1

Choose a tag to compare

@kiwi0719 kiwi0719 released this 22 Sep 22:16
3826c82

Install

luarocks install lua-resty-jev-edge
npm install @jev-edge/js

or 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 own max_inflight cap (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 for open_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 Responses function_call_output (its text is under output), so
    with untrusted off every tool result in that shape went unjudged (0.6.0
    held-out: AUC 0.497, every attack passing). input[*].output is a default
    text field now (llm-endpoints, default, and the fallback for inline
    rules), as OpenAI role: tool and Anthropic tool_result content 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); turn untrusted on for tool
    traffic.

Removed

  • IP trust at L1. core/rules.lua and rules.ts passed any IP whose
    reputation entry had trusted_until, but nothing ever wrote it; the L3
    safe counter 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 vector ip trusted is now ip 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.md as 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 (untrusted off 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

Choose a tag to compare

@kiwi0719 kiwi0719 released this 22 Sep 21:46
c28b74d

Install

luarocks install lua-resty-jev-edge
npm install @jev-edge/js

or 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 past LAYA_MAX_WINDOWS, never
    silently cut; fit_temperature.py fits the temperature that makes noul a
    calibrated probability; jev-laya.conf.lua is 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-check in make check) and run.py, which checks any judge
    server for the answer set, noul range, 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_ctx or criteria_ctx for the
    jev / laya request only, for wording validated on another judge.
  • Calibration per judge. The access log records provider and model;
    make calibrate refuses a log that mixes judges until PROVIDER= /
    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: OpenAI role: "tool" / "function" messages, Anthropic
    tool_result blocks, Responses function_call_output items, and any
    untrusted.fields JSON path. It gets its own judging window and is judged
    in a parallel call with the new untrusted question, 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
    (reason retrieved content). Lua and TypeScript cores, 12 new golden vectors
    (judge.by_question in 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; the mock provider takes
    mock_scores per 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 through core.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 its core/templates/*.lua file.
  • 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_ms is the adaptive timeout's floor, so the
    slow-provider scenario waits up to timeout_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-sanitization in
    adapters/js/test/worker.test.ts: the fetch stub matched the judge by URL
    prefix, which api.typesafe.ai.example also passes; it compares the parsed
    host now. Test-only, no runtime change.

Known gap

  • Without untrusted, Responses API function_call_output items are not
    judged at all (their text is under output, which no default text field
    reads). The held-out run shows every attack in that shape passing with it
    off. Turn untrusted on when fronting the Responses API with tools.

v0.5.0

Choose a tag to compare

@kiwi0719 kiwi0719 released this 22 Sep 20:02
2ebd05b

Install

luarocks install lua-resty-jev-edge
npm install @jev-edge/js

or 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); past block_at the
    subject is blocked at L1 for block_ttl, from any address and for any
    text. Off by default. Counters are two keys per subject in the subject
    store (atomic incr); L1 blocks never count. make calibrate replays the
    window per subject from monitor-mode logs (log lines now carry ts) and
    recommends block_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.body including 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
    denoKvStore with atomic incr on Deno KV).
  • npm release workflow (release-npm.yml): on a v* tag, checks that
    the tag matches the package version, tests, builds, checks every export
    resolves and publishes with provenance when NPM_TOKEN is set (skips
    otherwise). Package metadata, LICENSE and prepublishOnly added.
  • Operations (ops/): a Grafana dashboard, Prometheus alert rules
    (breaker open, L2 error ratio, unjudgeable ratio, L2 timeout at its
    ceiling, async drops, L2 starved) with promtool test rules unit tests run
    in CI; new metrics jev_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) and make bench-judge (offline L1 pass; the live
    part needs a provider key); six always_suspect patterns 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_chunks in a rule, default 1):
    text over max_judge_bytes is split into up to that many chunks (cut at
    a newline where possible, never inside a character), judged in parallel
    (judge.call_many: ngx.thread on OpenResty, APISIX and Kong,
    Promise.all in 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,
    or unjudgeable: text over max_judge_chunks with policy.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 (past tune.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.md has
    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 of make check and 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 through core.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.
  • security workflow: 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 from Dockerfile.test with a layer cache; e2e is one matrix
    job; the HAProxy agent's Go tests run (with -race, as the shim's do),
    gofmt is enforced, Go is the latest 1.26 patch; pnpm build runs;
    LiteLLM gets ruff (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/js could not be loaded by Node or Deno: dist/ used
    extensionless relative imports (fine for bundlers, ERR_UNSUPPORTED_DIR_IMPORT
    for Node's ESM loader, so nodeMiddleware and Lambda@Edge only worked
    bundled). Every import has its .js, and the build uses NodeNext so 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's jev-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

Choose a tag to compare

@kiwi0719 kiwi0719 released this 22 Sep 18:08
b937274

Install

luarocks install lua-resty-jev-edge

or 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_bytes is 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 first max_body_bytes and 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-endpoints lists
    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-stream were "not watched"),
    forms and multipart/form-data fields (text file parts too), other text
    whole. A rule that lists content_types keeps the allow list.
  • Text over max_judge_bytes (new, 32 KiB) is judged on a window: the
    always_suspect hit (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_find should return the match's byte
    span (from, to, as ngx.re.find does) 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-Encoding gzip, deflate and br are decoded before L1
    (resty.jev.decode through FFI to zlib and libbrotlidec; DecompressionStream
    and node:zlib in JS), capped at max_body_bytes so 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. br needs
    brotli-libs (Alpine) or libbrotli1 (Debian) on OpenResty images.
  • policy.unjudgeable = "pass" | "block" (default pass) 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: skipped with X-Jev-Reason: unjudgeable: <why>, or
    rejected in enforce mode with block. 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's x-envoy-auth-partial-body (example configs now allow 1 MiB
    and forward content-encoding), and the HAProxy SPOA agent's
    X-Jev-Body-Partial from HAProxy's declared body size (new size arg in
    spoe.conf).
  • nextMiddleware(request, event) forwards Next's event, so waitUntil keeps
    the subject write alive.

Security

  • A repeated Content-Type header 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 with X-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 was fp:<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 for fp_ttl. It now takes the on_error path and
    writes nothing (L2 and L3). judge.reduce returns 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
    passed djb2, 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/completions and //v1/chat/completions were
    "path not watched" while the backend served /v1/chat/completions.

  • Cloudflare Workers forward to the configured upstream only. The
    forwarding URL was new 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 string input items, so Responses API input,
    input_text parts and Anthropic tool_result arrived empty or were
    skipped without a call; a prompt next to messages was ignored.

  • The client cannot pick the IP or path it is judged under.
    forward_auth no longer reads X-Envoy-External-Address (only Envoy sets
    it; authz still does). The HAProxy SPOA agent, the Caddyfile and the
    nginx auth_request conf drop client copies of X-Envoy-External-Address
    and X-Real-IP, and the nginx sub-request sets X-Forwarded-Uri /
    X-Forwarded-Method itself: a client's X-Forwarded-Uri: /healthz made the
    request "path not watched" and skipped the reputation block. The JS runtime
    reads X-Forwarded-For from the right (client_ip.trusted_hops), trusts
    cf-connecting-ip only on Cloudflare (or a configured clientIpHeader),
    and the Node middleware appends the socket address like any proxy. The
    LiteLLM guardrail forwards the whole X-Forwarded-For chain, 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 null in messages or 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 keeps null as 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's json() 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-For chain 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
    incr keep 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_total is 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 expired history_ttl after the subject's first request, however
    active), and each slot carries its sequence number so a read between
    another worker's incr and set, or after max_entries changed, skips
    the slot instead of returning a stale entry.
  • The breaker's half-open probe is claimed with an atomic add where the
    store has one, so exactly one worker probes.
  • watch_paths validation 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_status must be 401 or 403 there (anything else becomes a 500).
  • Traefik: maxBodySize removed 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 yields X-Jev-Verdict: error instead of a silent pass.
  • Makefile: Docker targets mount $(CURDIR); $$(PWD) only worked on
    case-insensitive filesystems.

v0.3.1

Choose a tag to compare

@kiwi0719 kiwi0719 released this 22 Sep 17:07
00a6a5c

Install

luarocks install lua-resty-jev-edge

opm (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[*].content in the array
    form every current chat API accepts ([{type="text", text=...}, ...],
    Responses API input_text, Anthropic tool_result with nested content)
    used to extract no text, so the request passed L1 as "no text" in every
    mode. normalize.extract now collects strings, each part's text and
    nested content, on every adapter (golden vectors added).
  • Fingerprint is SHA-256 over the whole normalized text. 0.3.0 hashed a
    2048-byte prefix with crc32_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_bytes now only bounds sampled and logged text.
  • Client IP behind a proxy is the appended hop, not the first element.
    authz and forward_auth took the leftmost X-Forwarded-For value, which
    the client writes, so reputation blocks could be aimed at any address and
    evaded by rotating the header. They now take element client_ip.trusted_hops
    from the right (default 1, the value the proxy in front appended);
    x-envoy-external-address is used when Envoy sends it.
  • GET /_jev/config redacts jev.api_key, feedback.token and
    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/authz on
    the traffic port could reach /_jev/config through .. path tricks.
    The Envoy configs turn on normalize_path / merge_slashes, HAProxy and
    the gRPC shim / SPOE agent refuse .., %2e and // (fail-open).
  • Inbound X-Jev-* headers are stripped by the Envoy, Caddy, Traefik and
    nginx auth_request reference configs too, so a forged X-Jev-Verdict: safe
    cannot reach the upstream on the fail-open path. X-Jev-Subject is
    stripped as well unless it is the configured subject header.
  • Subject values with hashed = true must 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 sets async, so a repeated
    suspicious prompt is one L3 call, not one per hit. body_size is the
    larger of the declared size and the body handed over. rules.resolve on a
    string spec returns a copy with defaults applied instead of the shared
    module table, and rejects malformed watch_paths patterns (a bad pattern
    used to raise on every request and fail everything open). encode_reason
    truncates after encoding, never inside a %XX escape, so the header is
    really <= 200 bytes. defaults.validate rejects thresholds outside
    [0,1], non-HTTP block_status, zero breaker windows, zero
    sampling.max_samples and 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-compat no longer stores its
    question set on the shared config table (concurrent requests with
    different templates got each other's filter). PUT /_jev/config and
    POST /_jev/feedback read bodies nginx spooled to disk. A broken rule in an
    override is a 422, not a log line. /_jev/health with a misconfigured
    provider is a 503 JSON body, not a traceback. X-Forwarded-Uri is
    normalised (//, ., ..) before matching watch_paths. resty.jev.cache
    warns once when a dict starts evicting. The +json content types the
    extractor already decoded are now watched by llm-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. Existing subj: 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 filling jev_cache cannot evict them.
    Without it everything stays in jev_cache as 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 without Content-Length are bounded;
    nodeMiddleware no longer hands the app a placeholder body; cf-ray is
    only trusted on Cloudflare; Hono and Lambda@Edge presets strip inbound
    X-Jev-* and honour block_status; engines is node >= 20 and @types/node follows 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 >= 400 with X-Jev-Verdict, so a non-403 block_status blocks
    instead of failing open; the agent timeout is below HAProxy's timeout processing so the fail-open answer can arrive; Expect and 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 in docs/design.md, and the
    Makefile comment; .gitignore covers default go build outputs.

v0.3.0

Choose a tag to compare

@kiwi0719 kiwi0719 released this 21 Sep 18:42

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-deployment salt (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 as X-Jev-Subject (hashed = true at the
    origin). $jev_log carries subject. Test::Nginx 08-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_ttl is seven days, and traffic on the same
    fingerprint extends it at most max_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 or deployment_context bug, and the alert is the point.
    Renewals are bounded, so a fingerprint costs at most max_renewals writes
    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 through ctx.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",
    with fp, 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 file make calibrate reads.

    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.lua and adapters/openresty/t/07-feedback.t cover
    the module and the endpoint end to end.

  • Subject trajectory contract (core/subject.lua, adapters/js/src/core/subject.ts):
    evaluate accepts an optional ctx.subject = { id, history, record } and
    hands one flat trajectory entry to record on 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. history is accepted and
    ignored, and both golden vectors and core/spec/subject_spec.lua assert
    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: history is one store lookup
    on the request path and may always be absent (cold subject, evicted entry,
    a store that lost it — never an error), while record is 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 by core/spec/golden_spec.lua, and checked
    for drift by make golden-check in CI (make check includes it). They are
    the cross-implementation contract the Cloudflare TypeScript core will be
    held to; core/golden/README.md states 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_threshold under a false-positive budget, from a
    monitor-mode $jev_log file plus labels keyed by request id or fingerprint.
    --json for 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), plus thinWorker (L1 and cache at the edge, judgment by an
    existing jev-edge via /_jev/authz), fullWorker (KV cache, Durable Object
    JevState for breaker and adaptive timeout, jev / openai-compat /
    mock providers) and pagesMiddleware. handle() and evaluate() 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, --json for scripts.

  • make test-js.

  • Apache APISIX plugin (adapters/apisix): same engine, per-route config with
    the config-file keys, $jev_log as an APISIX variable; e2e against real
    APISIX 3.13 (make e2e-apisix, CI job).

  • HAProxy SPOE agent (adapters/haproxy/spoa, Go) with spoe.conf and a
    reference haproxy.cfg; forwards the original headers and body to
    /_jev/authz, sets txn.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_hook that 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/authz contract and configuration
    for Istio, Envoy Gateway, Azure API Management and Apigee.

  • @jev-edge/js (renamed from @jev-edge/cloudflare, directory
    adapters/js): nextMiddleware, nodeMiddleware, honoMiddleware and
    lambdaEdgeHandler on the shared runtime, with tests; Cloudflare presets
    unchanged under ./cloudflare.

  • Multi-tenant rules: rules entries may be inline tables with extends
    (core/rules.lua resolve()), each with its own watch_paths and
    deployment_context; first match wins. Supported in the config file and
    PUT /_jev/config, the APISIX plugin conf, and the JavaScript rules
    option. Test::Nginx 06-samples-tenants.t.

  • Decision sampling: sampling config section (core/sampling.lua,
    adapters/js/src/sampling.ts), GET|DELETE /_jev/samples on OpenResty,
    shared-dict ring on APISIX, onSample callback 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" on demo/, latency figures quoted with their
    measurement conditions, Contributing spells out the test and bench
    commands. Design moved to docs/design.md, the cost table to
    docs/cost.md. Architecture diagram no longer claims a fixed 300 ms cut.

v0.2.0

Choose a tag to compare

@kiwi0719 kiwi0719 released this 21 Sep 17:19

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 from x-envoy-external-address / x-forwarded-for; adapter errors fail open.
  • gRPC ext_authz: adapters/envoy/grpc-shim, a ~150-line Go Authorization/Check that forwards to /_jev/authz and 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 from X-Forwarded-* or X-Original-*, client from X-Forwarded-For.
  • Traefik ≥ 3.3 with forwardBody: true gets full L2 verdicts. Caddy forward_auth and nginx auth_request forward headers only: path, method and IP reputation are enforced, everything else is skipped with reason no 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.