This is an automated email from the ASF dual-hosted git repository. luigidemasi pushed a commit to branch fix/security-model-verification-153-158 in repository https://gitbox.apache.org/repos/asf/camel-kit.git
commit 58fb578cb23d4ca77a14cdcc216e6f2077641a26 Author: Luigi De Masi <[email protected]> AuthorDate: Mon Nov 9 19:15:20 2026 +0100 Fix #158: verify Knowledge boundaries and enforce offline models Fixes #158 Co-authored-by: OpenAI Codex <[email protected]> --- CHANGELOG.md | 2 + docs/modules/ROOT/pages/knowledge.adoc | 18 ++ docs/security-model.md | 65 +++-- docs/security-model.yaml | 9 +- docs/security-verification.md | 45 +++ .../kit/knowledge/embedding/OfflineTokenizer.java | 33 +++ .../knowledge/embedding/OnnxEmbeddingProvider.java | 2 +- .../kit/knowledge/embedding/OnnxReranker.java | 2 +- .../kit/knowledge/mcp/LuceneSearchService.java | 12 +- .../knowledge/mcp/KnowledgeInputBoundaryTest.java | 191 +++++++++++++ .../knowledge/mcp/LuceneFallbackBoundaryTest.java | 191 +++++++++++++ .../camel/kit/knowledge/mcp/OfflineModelsTest.java | 222 +++++++++++++++ tools/FixtureIndex.java | 21 ++ tools/verify_knowledge_runtime.py | 311 +++++++++++++++++++++ 14 files changed, 1082 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be030dc3..95ba9893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Knowledge runtime boundaries (#158)** — enforce offline CPU tokenizer initialization, cover query/cap/fallback and transport behavior, document native caches, and accept conventional CVE severity spellings. + - **Generated and cached path confinement (#157)** — refuse descendant symlinks before init/catalog writes and validate catalog versions before extraction or replacement. - **Claude MCP permissions (#155)** — replace server-wide wildcard approvals with explicit manifest-listed tools, with generation and actual-host permission checks. diff --git a/docs/modules/ROOT/pages/knowledge.adoc b/docs/modules/ROOT/pages/knowledge.adoc index 1fe36de3..0bbdae8e 100644 --- a/docs/modules/ROOT/pages/knowledge.adoc +++ b/docs/modules/ROOT/pages/knowledge.adoc @@ -153,3 +153,21 @@ lookup in a read-only research role where the agent has one (Bob 2's reviewer pr reviewer leaf, OpenCode's researcher task) and run it inline on Pi. The role returns a concise answer with references instead of raw search results, and a failed lookup is reported as missing evidence rather than replaced with invented documentation. + +== Local model initialization and host effects + +The monorepo Knowledge runner loads its bundled embedding and reranking models locally. +Tokenizer initialization enforces offline CPU mode, disables DJL telemetry/native downloads and +implicit CUDA subprocess discovery. Remove conflicting `DJL_OFFLINE` values other than `true` +or `RUST_FLAVOR` values other than `cpu`; startup reports these conflicts before loading native code. +This applies to the monorepo source runtime, not the previously released Knowledge 0.0.2 runner. + +Native initialization still writes DJL's cache (normally `~/.djl.ai/tokenizers`, configurable with +`ENGINE_CACHE_DIR` or `DJL_CACHE_DIR`) and ONNX/native temporary files. The embedding model also +uses a temporary directory. These paths are separate from the Knowledge index cache. + +Stdio is the default and disables the HTTP host. If enabling HTTP/SSE, explicitly configure +`quarkus.http.host` and `quarkus.http.port`; the transport has no built-in authentication. +Use loopback or provide an authenticated deployment boundary. A local `knowledge.index.path` +bypasses index downloads. Endpoint-validation tools inspect catalog metadata and do not execute +an endpoint or fetch its URI. diff --git a/docs/security-model.md b/docs/security-model.md index 008ac56c..a33b0ff3 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -236,7 +236,7 @@ notes, CVE advisories and component metadata" that "also works with MCP clients root `README.md`)*. Kit configures it for every harness at a pinned coordinate (§4.8 P4). This section models the server, the index it acquires and the indexer that produces that index *(maintainer, 2026-10-27 — Q11)*; family K in §4.2 is the server with its index acquisition and bundled models, K2 the indexer. Its statements come from the -Knowledge README and code and await maintainer verification (V6). +Knowledge README, code and executable checks recorded in [V6 evidence](security-verification.md#v6-knowledge). **Processes.** @@ -256,11 +256,17 @@ Knowledge README and code and await maintainer verification (V6). with the JDK's default temporary-directory permissions and removed on orderly shutdown; a pinned client receives the error instead. The monorepo runner bundles no index, so the fallback serves legacy uber-jars that do *(documented — `knowledge/README.md`, "Index Distribution" step 3; `LuceneSearchService.resolveIndex`; the - permission and bundling statements are inferred — V6)*. -- *Bundled embedding model and reranker* (`knowledge/embedding`): ONNX models packaged at build time ("The MCP module - downloads the ONNX embedding model from HuggingFace during `generate-resources`") and loaded at runtime from the - classpath into a private `camel-kit-onnx-*` temporary directory *(documented — `knowledge/README.md`; - `OnnxEmbeddingProvider`)*; no model is downloaded at runtime *(inferred — V6)*. + permission and fallback checks are covered by `LuceneFallbackBoundaryTest`; bundling is checked against the packaged runner)*. +- *Bundled embedding model and reranker* (`knowledge/embedding`): model/tokenizer resources are + acquired at build time and loaded locally. The embedding model uses a `camel-kit-onnx-*` temporary + directory; the reranker loads model bytes directly. Native libraries additionally use DJL's engine + cache (normally `~/.djl.ai/tokenizers`, configurable by `ENGINE_CACHE_DIR`/`DJL_CACHE_DIR`), + ONNX Runtime's `onnxruntime-java*` directory and dependency-managed native temporary files. + `OfflineTokenizer` enforces offline CPU initialization, disables DJL's optional CUDA subprocess + and avoids JNA's implicit `ldconfig` lookup. Conflicting `DJL_OFFLINE` or `RUST_FLAVOR` environment + values fail before native initialization. This prevents DJL metadata/telemetry and native downloads + on the supported bundled CPU path; it is not an OS sandbox or permission to install arbitrary + native-library overrides. `OfflineModelsTest` exercises both model initialization orders. - *Indexer* (`knowledge/indexer`, `IndexerMain`): a maintainer-run build tool that "runs during CI/CD to produce the pre-built index artifact", invoked by the `-Prebuild-index` profile and the `Index Release` workflow. It clones Apache repositories at release tags with JGit, converts AsciiDoc, downloads Camel Catalog JARs, fetches Apache JIRA @@ -271,7 +277,7 @@ Knowledge README and code and await maintainer verification (V6). 1. *MCP client → server.* Tool arguments are model output and therefore untrusted (§4.7). The server escapes free-text queries for Lucene (`QueryParser.escape`), clamps result counts to 25, matches identifiers as structured fields and - validates endpoint URIs against the bundled Camel catalog; no argument names a file, URL or command *(documented — + validates endpoint URIs against the bundled Camel catalog; no argument selects a filesystem target, remote fetch, executable command or index source *(documented — `LuceneSearchService`, `KnowledgeMcpServer`)*. 2. *Release origin → cache.* Discovery reads `https://api.github.com/repos/apache/camel-kit/releases` (at most 10 pages of 100 releases, each at most 2 MiB, and pagination must stay on the configured endpoint), selects only @@ -282,7 +288,7 @@ Knowledge README and code and await maintainer verification (V6). version when an update fails; a pinned version never falls back to another *(documented — `knowledge/README.md`, "Index Distribution"; `IndexDownloader`)*. Connections time out after 3 s, manifests after 5 s and archives after 5 min including the body; redirects are followed only after each target passes the same transport rule — `https`, - or `http` to a loopback host — for at most five hops, so no hop can downgrade to cleartext *(documented — + or `http` to a loopback host — for at most five hops, so no hop can use non-loopback cleartext *(documented — `IndexDownloader.send`; apache/camel-kit#143)*. 3. *Index → results.* The index is "data, not code": it is regenerated on a branch, committed with a signed commit, reviewed through a pull request, built unchanged by the release workflow and published as a GitHub release asset @@ -293,16 +299,16 @@ Knowledge README and code and await maintainer verification (V6). *(documented — `knowledge/README.md`, "Data Sources", "CVE enrichment and fallback")*. These are trusted upstream sources; a hostile document would require a compromised Apache repository or mirror (§4.7). -**What the Knowledge server does to its host** (row in the §4.5 inventory): no listening socket in the default -configuration *(documented — README; the running-process claim is inferred — V6)*; no child process *(inferred — -V6)*; network only to the configured release endpoint, manifest and asset URLs *(documented — `IndexDownloader`; "no -other network" inferred — V6)*; writes the cache directory — version directories, the `current` marker, the `etag` -validator, staging files and an `.update.lock` — and the private `camel-kit-onnx-*` temporary directory *(documented — -`IndexDownloader`, `OnnxEmbeddingProvider`)*, plus, only when index resolution fails and no version is pinned, a -`knowledge-index*` temporary directory holding the classpath-extracted fallback index, removed on orderly shutdown -*(documented — `LuceneSearchService`)*; nothing else *(inferred — V6)*; reads `knowledge.index.*` properties and their -`KNOWLEDGE_INDEX_*` -environment equivalents *(documented — README)*; "No Docker. No Docling. No external tokens." *(documented — README)*. +**What the Knowledge server does to its host:** stdio disables the HTTP host; explicit HTTP/SSE +opt-in listens on the configured address without built-in authentication. Set the host explicitly; +framework defaults are not a loopback guarantee. The application fetches index releases, manifests +and archives when acquisition needs them; a local index bypasses acquisition. Bundled models initialize +in process under `OfflineTokenizer`'s offline CPU policy. Writes include index cache/staging/metadata/ +locks, temporary fallback and embedding data, DJL's native cache and ONNX/JNA native temporary files. +It reads `knowledge.index.*`, `KNOWLEDGE_INDEX_*`, Quarkus, JVM home/temp and native-library/cache +configuration. These are scoped application/dependency behaviors, not restrictions on JVM startup +agents, custom native libraries or the operator's OS account. See the §4.5 inventory and +[V6 evidence](security-verification.md#v6-knowledge) for regression and packaged-process checks. **Knobs** (also in §4.5a): `knowledge.index.path`; `knowledge.index.url`, which accepts `https` and `file` anywhere and `http` only to a loopback host (apache/camel-kit#143); `knowledge.index.version`; `knowledge.index.releases-url`; `knowledge.index.cache-dir`; @@ -645,10 +651,9 @@ violation warrants coordinated disclosure and a CVE; **hardening** means it is f 12. **P12 — Knowledge server transport.** "The server uses stdio by default and does not open an HTTP port"; the HTTP and SSE transports exist only when the operator enables them (`quarkus.http.host-enabled=true`) on the - configured host and port *(documented — `knowledge/README.md`)*; the no-listener claim for the running default - configuration is *(inferred — V6)*. *Violation:* a listening socket without the opt-in, or on a host other than + configured host and port *(documented — `knowledge/README.md`)*; the default and opt-in transports have packaged-process probes ([V6 evidence](security-verification.md#v6-knowledge)). *Violation:* a listening socket without the opt-in, or on a host other than the configured one. *Severity:* security-critical. -13. **P13 — Knowledge index acquisition integrity.** A local `knowledge.index.path` is used in place without network. +13. **P13 — Knowledge index acquisition integrity.** A local `knowledge.index.path` is used in place without index-acquisition network access. Otherwise the server fetches a published `knowledge-index-v1-*` release (the newest compatible one, or the exact pinned version) or the configured manifest with bounded pages and deadlines; the manifest must match the release tag, format version 1, the bundled embedding model and 384 dimensions; the archive's SHA-256 must equal the @@ -667,9 +672,12 @@ violation warrants coordinated disclosure and a CVE; **hardening** means it is f sources; the published `0.0.2` server has the standalone repository's download behaviour (§4.4a). 14. **P14 — Knowledge tool arguments are queries, not code.** `camel_docs_*` arguments are used as escaped Lucene query text (`QueryParser.escape`), structured field lookups, version strings and an endpoint URI validated - against the bundled Camel catalog; `max_results` is clamped to 25; no argument names a file path, URL or command - *(documented — `knowledge/README.md`, "MCP Tools"; `LuceneSearchService`; `KnowledgeMcpServer`)*. The server - spawns no process and performs no network access other than index acquisition *(inferred — V6)*. *Violation:* + against the bundled Camel catalog; `max_results` is clamped to 25; no argument selects a filesystem target, remote fetch, executable command or index source + *(documented — `knowledge/README.md`, "MCP Tools"; `LuceneSearchService`; `KnowledgeMcpServer`)*. `KnowledgeInputBoundaryTest` exercises query syntax, structured identifiers, every result cap and + HTTP/XSLT/exec/file endpoint canaries. Bundled model initialization enforces offline CPU behavior + through `OfflineTokenizer` and is tested by `OfflineModelsTest`. These paths do not execute tool + arguments or use them to select an external fetch. JVM local-interface discovery is distinct from + application requests; the runtime is not an OS sandbox. *Violation:* a tool argument reaching the filesystem, the network or a code path, or changing which index or files are read. *Severity:* security-critical. @@ -736,8 +744,7 @@ This is the most important section for a developer adopting Camel-Kit and for an as information, not enforced. - **Knowledge: no authentication or authorization on the MCP transport.** stdio is the invoking user; the opt-in - HTTP/SSE transport carries no authentication beyond its bind address *(documented — `knowledge/README.md`; inferred - for the SSE posture — V6)*. + HTTP/SSE transport carries no authentication beyond its bind address *(documented — `application.properties`; packaged SSE probe in [V6 evidence](security-verification.md#v6-knowledge))*. - **Knowledge: no independent signature on the index.** Integrity rests on HTTPS to the GitHub release origin and the SHA-256 in a manifest served from the same origin; a configured `file://` manifest, or a loopback `http://` one, is trusted as operator input, and remote cleartext is refused before any request *(documented — `knowledge/README.md`; @@ -1050,11 +1057,7 @@ own issue, linked below. `VALID`. - **V5 — Init and download path controls (Q7, Q9; #157): implemented.** Regression tests cover shared init outputs, every agent, generated leaves, cache ancestors, malicious versions and archive paths for Citrus, Forage and Knowledge. [V5 evidence](security-verification.md#v5-generated-and-extracted-paths). Redirected writes remain `VALID`. -- **V6 — Knowledge section verification (Q11; #158) → §4.4a, §4.8 P12–P14.** Maintainer verification of the statements - drafted from code and the README: no listener in the default configuration, no process execution and no network - access other than index acquisition, tool-argument handling, cache permissions, the SSE posture, and the classpath - fallback (temporary-directory permissions; no bundled index in the monorepo runner). Related hardening: #137 - (apache/camel-kit#143), #139 (apache/camel-kit#142), #140. *Interim triage:* a breach of P12–P14 is `VALID`. +- **V6 — Knowledge verification (Q11; #158): implemented.** Tool argument/cap tests, pinned/temp fallback tests, real-model offline probes and packaged stdio/SSE checks are recorded in [V6 evidence](security-verification.md#v6-knowledge). Native caches are disclosed, DJL offline initialization is enforced and ordinary severity spellings work. Existing #143/#142 hardening remains covered. A P12–P14 breach remains `VALID`. --- diff --git a/docs/security-model.yaml b/docs/security-model.yaml index 72acec8f..b307c3d4 100644 --- a/docs/security-model.yaml +++ b/docs/security-model.yaml @@ -339,17 +339,17 @@ claimed_properties: property: Knowledge server transport - stdio by default with no listening socket; the HTTP/SSE transport exists only when the operator enables it (quarkus.http.host-enabled=true) on the configured host and port. violation_symptom: A listening socket without the opt-in, or on a host other than the configured one. severity: security-critical - provenance: documented (knowledge/README.md "Running the MCP Server"); no-listener claim for the default configuration inferred (V6) + provenance: "application.properties and knowledge/README.md; packaged stdio and explicit loopback SSE probe recorded in security-verification.md V6. HTTP/SSE has no built-in authentication; configure bind host explicitly." - id: P13 property: Knowledge index acquisition integrity - a local knowledge.index.path is used in place without network; otherwise a published knowledge-index-v1-* release (newest compatible, or the exact pinned version) or an explicitly configured manifest is fetched with bounded pages and deadlines, the manifest must match the release tag, format version 1, the bundled embedding model and 384 dimensions, the archive SHA-256 must equal the manifest value before unpacking, zip entries are co [...] violation_symptom: An archive accepted on SHA-256 mismatch; an entry written outside the staging or version directory; a version name outside the safe-name rules accepted; a pinned client using another version or the classpath fallback; a manifest from a different release tag accepted; a remote fetch or redirect issued over non-loopback http; activation of a partially unpacked version. severity: security-critical (integrity and path escape) provenance: documented (knowledge/README.md "Index Distribution", IndexDownloader, LuceneSearchService, apache/camel-kit#143) - id: P14 - property: Knowledge tool arguments are queries, not code - camel_docs_* arguments are used as escaped Lucene query text, structured field lookups, version strings and an endpoint URI validated against the bundled Camel catalog; no argument names a file path, URL or command; the server spawns no process and performs no network access other than index acquisition. + property: Knowledge tool arguments are queries, not code - camel_docs_* arguments are used as escaped Lucene query text, structured field lookups, version strings and an endpoint URI validated against the bundled Camel catalog; no argument selects a filesystem target, remote fetch, executable command or index source. OfflineTokenizer enforces offline CPU model initialization and disables implicit DJL/JNA subprocess discovery; native libraries still use caches and temporary files. App [...] violation_symptom: A tool argument reaching the filesystem, the network or a code path, or changing which index or files are read. severity: security-critical - provenance: documented (knowledge/README.md tool table, LuceneSearchService QueryParser.escape, KnowledgeMcpServer MAX_RESULTS_CAP); no-process and no-other-network claims inferred (V6) + provenance: "KnowledgeMcpServer, LuceneSearchService, OfflineTokenizer; KnowledgeInputBoundaryTest, OfflineModelsTest and packaged-process observations (security-verification.md V6)." correctness_only_properties: - Deterministic graph merge order. @@ -624,8 +624,9 @@ verification_items: sections: ["4.4a", "4.8 P12", "4.8 P13", "4.8 P14"] work: "Maintainer verification of the Knowledge statements drafted from code and README - no listener in the default configuration, no process execution and no network access other than index acquisition, tool-argument handling, cache permissions, the SSE posture, and the classpath fallback (temporary-directory permissions, no bundled index in the monorepo runner). Related hardening - #137 (PR #143), #139 (PR #142), #140." interim_triage: A breach of P12-P14 is VALID. - status: open + status: "implemented" + evidence: "KnowledgeInputBoundaryTest, LuceneFallbackBoundaryTest, OfflineModelsTest, existing IndexDownloader/ReleaseDiscovery regressions and packaged stdio/SSE probe; security-verification.md V6" revision_triggers: - New CLI command, parser or input format reading external content; parser moving to later compilation phase or symbol resolution. - Change to the graph parser timeout or the POM parser path, or addition of size, memory or symbolic-link checks in kit/camel-kit-graph (closes V2 items). diff --git a/docs/security-verification.md b/docs/security-verification.md index 494fedc0..6277ca6c 100644 --- a/docs/security-verification.md +++ b/docs/security-verification.md @@ -79,3 +79,48 @@ An explicitly chosen root alias is allowed. Extraction creates ordinary files an materializes ZIP symlink metadata. These checks do not provide descriptor-relative isolation against concurrent same-account directory substitution; existing same-account exclusions apply. Website impact: required; initialization behavior documented in the agent reference. + + +## V6: Knowledge + +`KnowledgeInputBoundaryTest` supplies synthetic Lucene documents to actual service/tool methods. +It checks escaped free text, structured identifiers, upper/default/lower result bounds across all +call sites and catalog-only HTTP/XSLT/exec/file endpoint validation. A conventional HIGH severity +query exposed duplicate elements in `Set.of`; distinct case variants now accept HIGH/high/High. +`LuceneFallbackBoundaryTest` checks pinned failures, readable unpinned temporary fallback, +POSIX temporary-directory privacy where supported, cleanup, persistent-index preservation and +failure when no bundled index exists. Existing downloader tests cover cache permissions, hash, +transport/redirect, pin, staging and activation boundaries. Removing query escaping, increasing +the cap or disabling fallback cleanup each made the respective regression fail. + +`OfflineModelsTest` forks fresh JVMs with isolated caches and real bundled embedding/reranker +resources, in both initialization orders. Both return valid inference results with zero observed +HTTP opens, JFR process starts or socket I/O. Four conflicting-environment cases fail before +native/model extraction. All six cases passed directly on Java 17, 21 and 25. Normal cases start +with deliberately conflicting system properties to check enforcement. Removing either provider's +offline call produced four blocked EC2 metadata requests. No real metadata request was sent. +An earlier probe caught JNA launching `ldconfig`; the shared initializer now suppresses implicit +platform-library discovery while preserving an explicitly configured search path. + +The offline policy is process-wide DJL configuration for the bundled CPU runtime, not an OS +sandbox. DJL cache and ONNX/native temporary writes remain expected. Conflicting environment +values are rejected rather than silently enabling telemetry or GPU-native downloads. +Packaged transport verification uses `tools/verify_knowledge_runtime.py` with a local synthetic +index: stdio initialize/list-tools/first query, and explicit loopback SSE initialize/list-tools/ +query without authorization. It inspects the child process's sockets and syscall trace where +available; JVM local-interface discovery is reported separately from application network. +Website impact: required; Knowledge runtime/reference updated. Published Knowledge0.0.2 is not +retroactively changed by these source fixes. + + +### Packaged V6 observation (2026-11-09) + +The checked-in probe passed on Temurin 21.0.8+9 with runner SHA-256 +`d5e8e7b1a379e75bea6f432d0538d1bc54a75a1143bbb040730f7edb6575e6a6`, with no +baseline/offline/CUDA/JNA command overrides. Both fresh JVMs initialized MCP, listed seven +tools and returned the local fixture from the first query with reranking. Default stdio had +no IP sockets, binds or listeners; explicit SSE had one 127.0.0.1 listener and accepted the +unauthenticated initialization/query. Lifetime syscall traces contained only the initial JVM +exec, no child executable and no application service connection/send. JVM netlink and UDP +port-zero interface discovery was recorded separately. The packaged runner contained all five +model/tokenizer resources and no `knowledge-index/` payload. diff --git a/knowledge/embedding/src/main/java/org/apache/camel/kit/knowledge/embedding/OfflineTokenizer.java b/knowledge/embedding/src/main/java/org/apache/camel/kit/knowledge/embedding/OfflineTokenizer.java new file mode 100644 index 00000000..a1654eac --- /dev/null +++ b/knowledge/embedding/src/main/java/org/apache/camel/kit/knowledge/embedding/OfflineTokenizer.java @@ -0,0 +1,33 @@ +package org.apache.camel.kit.knowledge.embedding; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + +import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer; + +/** Keeps bundled tokenizer initialization local, including DJL native-library discovery. */ +final class OfflineTokenizer { + private OfflineTokenizer() { + } + + static synchronized HuggingFaceTokenizer load(InputStream input, Map<String, String> options) throws IOException { + // DJL gives environment variables precedence over system properties. + String offline = System.getenv("DJL_OFFLINE"); + String flavor = System.getenv("RUST_FLAVOR"); + if (offline != null && !Boolean.parseBoolean(offline)) { + throw new IllegalStateException( + "Camel Kit requires DJL_OFFLINE=true; remove the conflicting environment value"); + } + if (flavor != null && !"cpu".equals(flavor)) { + throw new IllegalStateException( + "Camel Kit requires RUST_FLAVOR=cpu; remove the conflicting environment value"); + } + System.setProperty("ai.djl.offline", "true"); + System.setProperty("RUST_FLAVOR", "cpu"); + System.setProperty("ai.djl.util.cuda.fork", "false"); + // JNA otherwise runs ldconfig while DJL probes CUDA, even for the bundled CPU tokenizer. + System.getProperties().putIfAbsent("jna.platform.library.path", ""); + return HuggingFaceTokenizer.newInstance(input, options); + } +} diff --git a/knowledge/embedding/src/main/java/org/apache/camel/kit/knowledge/embedding/OnnxEmbeddingProvider.java b/knowledge/embedding/src/main/java/org/apache/camel/kit/knowledge/embedding/OnnxEmbeddingProvider.java index f4a182de..4b56878c 100644 --- a/knowledge/embedding/src/main/java/org/apache/camel/kit/knowledge/embedding/OnnxEmbeddingProvider.java +++ b/knowledge/embedding/src/main/java/org/apache/camel/kit/knowledge/embedding/OnnxEmbeddingProvider.java @@ -143,7 +143,7 @@ public class OnnxEmbeddingProvider implements EmbeddingProvider { if (is == null) { throw new RuntimeException("tokenizer.json not found on classpath"); } - tokenizer = HuggingFaceTokenizer.newInstance(is, Map.of( + tokenizer = OfflineTokenizer.load(is, Map.of( "padding", "false", "truncation", "true", "maxLength", String.valueOf(MAX_SEQ_LENGTH))); diff --git a/knowledge/embedding/src/main/java/org/apache/camel/kit/knowledge/embedding/OnnxReranker.java b/knowledge/embedding/src/main/java/org/apache/camel/kit/knowledge/embedding/OnnxReranker.java index f2133180..d9bd02e5 100644 --- a/knowledge/embedding/src/main/java/org/apache/camel/kit/knowledge/embedding/OnnxReranker.java +++ b/knowledge/embedding/src/main/java/org/apache/camel/kit/knowledge/embedding/OnnxReranker.java @@ -70,7 +70,7 @@ public class OnnxReranker { if (is == null) { throw new RuntimeException(TOKENIZER_FILE + " not found on classpath"); } - tokenizer = HuggingFaceTokenizer.newInstance(is, Map.of( + tokenizer = OfflineTokenizer.load(is, Map.of( "padding", "false", "truncation", "true", "maxLength", String.valueOf(MAX_SEQ_LENGTH))); diff --git a/knowledge/mcp/src/main/java/org/apache/camel/kit/knowledge/mcp/LuceneSearchService.java b/knowledge/mcp/src/main/java/org/apache/camel/kit/knowledge/mcp/LuceneSearchService.java index 794a02ee..64c2b99e 100644 --- a/knowledge/mcp/src/main/java/org/apache/camel/kit/knowledge/mcp/LuceneSearchService.java +++ b/knowledge/mcp/src/main/java/org/apache/camel/kit/knowledge/mcp/LuceneSearchService.java @@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.util.*; +import java.util.LinkedHashSet; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; @@ -507,11 +508,12 @@ public class LuceneSearchService { /** Case variants for exact StringField matching — indexed severities vary ("HIGH" vs "Important"). */ private static BooleanQuery caseVariantsClause(String field, String value) { BooleanQuery.Builder b = new BooleanQuery.Builder(); - for (String v : Set.of(value, value.toUpperCase(Locale.ROOT), value.toLowerCase(Locale.ROOT), - value.isEmpty() - ? value - : Character.toUpperCase(value.charAt(0)) - + value.substring(1).toLowerCase(Locale.ROOT))) { + for (String v : new LinkedHashSet<>( + List.of(value, value.toUpperCase(Locale.ROOT), value.toLowerCase(Locale.ROOT), + value.isEmpty() + ? value + : Character.toUpperCase(value.charAt(0)) + + value.substring(1).toLowerCase(Locale.ROOT)))) { b.add(new TermQuery(new Term(field, v)), BooleanClause.Occur.SHOULD); } return b.build(); diff --git a/knowledge/mcp/src/test/java/org/apache/camel/kit/knowledge/mcp/KnowledgeInputBoundaryTest.java b/knowledge/mcp/src/test/java/org/apache/camel/kit/knowledge/mcp/KnowledgeInputBoundaryTest.java new file mode 100644 index 00000000..9e3e0e07 --- /dev/null +++ b/knowledge/mcp/src/test/java/org/apache/camel/kit/knowledge/mcp/KnowledgeInputBoundaryTest.java @@ -0,0 +1,191 @@ +package org.apache.camel.kit.knowledge.mcp; + +import java.lang.reflect.Field; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.camel.kit.knowledge.schema.KnowledgeDocument; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import org.apache.lucene.analysis.standard.StandardAnalyzer; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.store.ByteBuffersDirectory; +import org.apache.lucene.store.Directory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.*; + +/** Exercises the MCP entry points against a real, deterministic BM25 index without model initialization. */ +class KnowledgeInputBoundaryTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private Directory directory; + private LuceneSearchService service; + private KnowledgeMcpServer server; + + @TempDir + Path temp; + + @BeforeEach + void createIndex() throws Exception { + directory = new ByteBuffersDirectory(); + try (StandardAnalyzer analyzer = new StandardAnalyzer(); + IndexWriter writer = new IndexWriter(directory, new IndexWriterConfig(analyzer))) { + for (int i = 0; i < 40; i++) { + writer.addDocument(new KnowledgeDocument("release-" + i, "apache_camel") + .source("apache-camel").docType("release-notes").sourceVersion("4.22") + .component("safecomponent").jiraId("CAMEL-999") + .sectionTitle("Release notes").content("needle release ordinary documentation").build()); + writer.addDocument(new KnowledgeDocument("advisory-" + i, "apache_camel") + .source("apache-camel").docType("cve").sourceVersion("4.22") + .cveId("CVE-2099-0001").erratumId("advisory-" + i).severity("HIGH") + .sectionTitle("Security advisory").content("CVE security vulnerability advisorytoken").build()); + } + writer.addDocument(new KnowledgeDocument("secretmarker", "apache_camel") + .docType("guide").sectionTitle("Decoy").content("unrelated orchard").build()); + writer.addDocument(new KnowledgeDocument("foreign-domain", "another_domain") + .docType("guide").sectionTitle("Other domain").content("needle release").build()); + } + service = new LuceneSearchService(); + DirectoryReader reader = DirectoryReader.open(directory); + // Install the real Lucene fixture without invoking unrelated ONNX/native startup. + setField(service, "reader", reader); + setField(service, "searcher", new IndexSearcher(reader)); + server = new KnowledgeMcpServer(); + server.searchService = service; + } + + @AfterEach + void closeIndex() throws Exception { + if (service != null) { + service.close(); + } + if (directory != null) { + directory.close(); + } + } + + @Test + void querySyntaxCannotSelectFieldsOrTurnIntoMatchAll() throws Exception { + assertEquals(0, results(server.camel_docs_search("id:secretmarker", null, 25)).size()); + assertEquals(0, results(server.camel_docs_search("*:*", null, 25)).size()); + + for (String query : List.of("needle", "needle OR *:*", "(\"needle")) { + JsonNode matches = results(server.camel_docs_search(query, null, 25)); + assertEquals(25, matches.size(), query); + for (JsonNode match : matches) { + assertTrue(match.path("id").asText().startsWith("release-"), match.toString()); + } + } + assertTrue(service.searchErrata(null, null, null, "*:*", 25).isEmpty()); + assertEquals(25, service.searchErrata(null, null, null, "advisorytoken", 25).size()); + } + + @Test + void identifiersAndVersionFiltersCannotBecomeQueries() throws Exception { + assertEquals(20, results(server.camel_docs_jira_lookup("CAMEL-999")).size()); + assertEquals(0, results(server.camel_docs_jira_lookup("CAMEL-999 OR *:*")).size()); + assertEquals(20, results(server.camel_docs_cve_search("CVE-2099-0001", null, null, null, 100)).size()); + assertEquals(0, results(server.camel_docs_cve_search("CVE-2099-0001 OR *:*", null, null, null, 100)).size()); + assertEquals(10, results(server.camel_docs_component_info("safecomponent", null, null)).size()); + assertEquals(0, results(server.camel_docs_component_info("id:secretmarker", null, null)).size()); + assertEquals(0, results(server.camel_docs_search("needle", "absent OR *:*", 25)).size()); + assertTrue(service.lookupComponent("apache_camel", "safecomponent OR *:*", null, null).isEmpty()); + } + + @Test + void everyConfigurableResultLimitIsBoundedAtTheToolEntryPoint() throws Exception { + assertEquals(25, results(server.camel_docs_search("needle", null, Integer.MAX_VALUE)).size()); + assertEquals(25, results(server.camel_docs_release_info("4.22", Integer.MAX_VALUE)).size()); + assertEquals(25, results(server.camel_docs_cve_search(null, null, null, null, Integer.MAX_VALUE)).size()); + assertEquals(1, results(server.camel_docs_search("needle", null, -1)).size()); + assertEquals(1, results(server.camel_docs_release_info("4.22", 0)).size()); + assertEquals(1, results(server.camel_docs_cve_search(null, null, null, null, -1)).size()); + assertEquals(5, results(server.camel_docs_search("needle", null, null)).size()); + assertEquals(20, results(server.camel_docs_release_info("4.22", null)).size()); + assertEquals(10, results(server.camel_docs_cve_search(null, null, null, null, null)).size()); + } + + @Test + void conventionalSeveritySpellingsReturnTheSameAdvisories() throws Exception { + for (String severity : List.of("hIgH", "HIGH", "high", "High")) { + JsonNode matches = results(server.camel_docs_cve_search(null, null, severity, null, 25)); + assertEquals(25, matches.size(), severity); + for (JsonNode match : matches) { + assertEquals("HIGH", match.path("severity").asText(), match.toString()); + } + } + } + + @Test + void endpointValidationDoesNotContactAnHttpEndpoint() throws Exception { + AtomicInteger requests = new AtomicInteger(); + HttpServer canary = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + canary.createContext("/canary", exchange -> { + requests.incrementAndGet(); + exchange.sendResponseHeaders(200, -1); + exchange.close(); + }); + canary.start(); + try { + String uri = "http://127.0.0.1:" + canary.getAddress().getPort() + "/canary"; + JsonNode validation = mapper.readTree(server.camel_docs_validate_endpoint(uri)); + assertTrue(validation.path("valid").asBoolean(), validation.toString()); + assertEquals(uri, validation.path("uri").asText()); + JsonNode stylesheet = mapper.readTree(server.camel_docs_validate_endpoint("xslt:" + uri)); + assertTrue(stylesheet.path("valid").asBoolean(), stylesheet.toString()); + assertEquals(0, requests.get(), "Catalog validation must not send the endpoint a request"); + } finally { + canary.stop(0); + } + } + + @Test + void endpointValidationDoesNotRunCommandsOrModifyFiles() throws Exception { + Path marker = temp.resolve("executed"); + Path command = temp.resolve("BoundaryCanary.java"); + Files.writeString(command, "class BoundaryCanary { public static void main(String[] args) throws Exception {" + + "java.nio.file.Files.writeString(java.nio.file.Path.of(args[0]), \"executed\"); }}"); + String executable = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + JsonNode exec = mapper.readTree(server.camel_docs_validate_endpoint( + "exec:" + executable + "?args=" + command + " " + marker)); + assertTrue(exec.path("valid").asBoolean(), exec.toString()); + assertFalse(Files.exists(marker), "The exec endpoint is metadata, not a command invocation"); + + Path secret = temp.resolve("private.xml"); + Files.writeString(secret, "<private>content-canary-unique</private>"); + String validation = server.camel_docs_validate_endpoint("xslt:" + secret.toUri()); + assertTrue(mapper.readTree(validation).path("valid").asBoolean(), validation); + assertFalse(validation.contains("content-canary-unique")); + assertEquals("<private>content-canary-unique</private>", Files.readString(secret)); + + Path output = temp.resolve("output"); + JsonNode file = mapper.readTree(server.camel_docs_validate_endpoint("file:" + output + "?fileName=result.txt")); + assertTrue(file.path("valid").asBoolean(), file.toString()); + assertFalse(Files.exists(output), "Catalog validation must not create the endpoint directory"); + } + + private JsonNode results(String response) throws Exception { + JsonNode json = mapper.readTree(response); + assertFalse(json.has("error"), response); + assertTrue(json.path("results").isArray(), response); + return json.path("results"); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/knowledge/mcp/src/test/java/org/apache/camel/kit/knowledge/mcp/LuceneFallbackBoundaryTest.java b/knowledge/mcp/src/test/java/org/apache/camel/kit/knowledge/mcp/LuceneFallbackBoundaryTest.java new file mode 100644 index 00000000..7b1b400e --- /dev/null +++ b/knowledge/mcp/src/test/java/org/apache/camel/kit/knowledge/mcp/LuceneFallbackBoundaryTest.java @@ -0,0 +1,191 @@ +package org.apache.camel.kit.knowledge.mcp; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.camel.kit.knowledge.schema.KnowledgeDocument; + +import org.apache.lucene.analysis.standard.StandardAnalyzer; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.IndexNotFoundException; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.store.FSDirectory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.*; + +class LuceneFallbackBoundaryTest { + + @TempDir + Path temp; + + @Test + void pinnedResolutionFailureNeverUsesAnAvailableClasspathIndex() throws Exception { + AtomicInteger resourceReads = new AtomicInteger(); + ClassLoader original = Thread.currentThread().getContextClassLoader(); + LuceneSearchService service = new LuceneSearchService(); + IOException failure = new IOException("Pinned index is unavailable"); + service.indexDownloader = failingDownloader(true, failure); + Thread.currentThread().setContextClassLoader(classpathIndex(resourceReads)); + try { + RuntimeException thrown = assertThrows(RuntimeException.class, service::init); + assertSame(failure, thrown.getCause()); + assertEquals(0, resourceReads.get(), "A pin must not silently select even a valid bundled index"); + } finally { + Thread.currentThread().setContextClassLoader(original); + service.close(); + } + } + + @Test + void unpinnedFallbackExtractsAReadablePrivateIndexAndRemovesItOnClose() throws Exception { + ClassLoader original = Thread.currentThread().getContextClassLoader(); + LuceneSearchService service = new LuceneSearchService(); + service.indexDownloader = failingDownloader(false, new IOException("Release unavailable")); + Thread.currentThread().setContextClassLoader(classpathIndex(new AtomicInteger())); + Path extracted = null; + try { + IndexDownloader.ResolvedIndex resolved = resolveIndex(service); + extracted = resolved.dir(); + // Keep model initialization out of this fixture; exercise the real resolved-index cleanup policy. + setResolvedIndex(service, resolved); + assertTrue(resolved.temporary()); + assertTrue(extracted.getFileName().toString().startsWith("knowledge-index")); + if (Files.getFileAttributeView(extracted, PosixFileAttributeView.class) != null) { + assertEquals(Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE), Files.getPosixFilePermissions(extracted)); + } + try (FSDirectory directory = FSDirectory.open(extracted); + DirectoryReader reader = DirectoryReader.open(directory)) { + assertEquals(1, reader.numDocs()); + assertEquals("fallback-canary", reader.storedFields().document(0).get("id")); + } + } finally { + Thread.currentThread().setContextClassLoader(original); + service.close(); + } + assertNotNull(extracted); + assertFalse(Files.exists(extracted), "Only the extracted temporary index must be deleted on close"); + } + + @Test + void closingTheServicePreservesAnExplicitPersistentIndex() throws Exception { + Path persistent = Files.createDirectory(temp.resolve("persistent")); + Path sentinel = Files.writeString(persistent.resolve("sentinel"), "keep"); + LuceneSearchService service = new LuceneSearchService(); + service.indexDownloader = new IndexDownloader(null) { + @Override + public ResolvedIndex resolve() { + return new ResolvedIndex(persistent, false); + } + }; + IndexDownloader.ResolvedIndex resolved = resolveIndex(service); + assertEquals(persistent, resolved.dir()); + assertFalse(resolved.temporary()); + setResolvedIndex(service, resolved); + service.close(); + assertEquals("keep", Files.readString(sentinel)); + } + + @Test + void missingBundledIndexCannotTurnFailedAcquisitionIntoSuccessfulStartup() throws Exception { + ClassLoader original = Thread.currentThread().getContextClassLoader(); + LuceneSearchService service = new LuceneSearchService(); + service.indexDownloader = failingDownloader(false, new IOException("Release unavailable")); + Thread.currentThread().setContextClassLoader(new ClassLoader(original) { + @Override + public InputStream getResourceAsStream(String name) { + return name.startsWith("knowledge-index/") ? null : super.getResourceAsStream(name); + } + }); + Path extracted; + try { + RuntimeException thrown = assertThrows(RuntimeException.class, service::init); + assertInstanceOf(IndexNotFoundException.class, thrown.getCause()); + Field field = LuceneSearchService.class.getDeclaredField("resolvedIndex"); + field.setAccessible(true); + extracted = ((IndexDownloader.ResolvedIndex) field.get(service)).dir(); + } finally { + Thread.currentThread().setContextClassLoader(original); + service.close(); + } + assertFalse(Files.exists(extracted)); + } + + private ClassLoader classpathIndex(AtomicInteger resourceReads) throws Exception { + Path source = Files.createTempDirectory(temp, "bundled-"); + try (FSDirectory directory = FSDirectory.open(source); + StandardAnalyzer analyzer = new StandardAnalyzer(); + IndexWriter writer = new IndexWriter(directory, new IndexWriterConfig(analyzer))) { + writer.addDocument(new KnowledgeDocument("fallback-canary", "apache_camel") + .docType("guide").content("bundled fallback").build()); + } + Map<String, byte[]> resources = new HashMap<>(); + StringBuilder manifest = new StringBuilder(); + try (var files = Files.list(source)) { + for (Path file : files.filter(path -> !path.getFileName().toString().equals("write.lock")).toList()) { + String name = file.getFileName().toString(); + manifest.append(name).append('\n'); + resources.put("knowledge-index/" + name, Files.readAllBytes(file)); + } + } + resources.put("knowledge-index/INDEX_FILES", manifest.toString().getBytes(StandardCharsets.UTF_8)); + return new ClassLoader(Thread.currentThread().getContextClassLoader()) { + @Override + public InputStream getResourceAsStream(String name) { + if (name.startsWith("knowledge-index/")) { + resourceReads.incrementAndGet(); + byte[] bytes = resources.get(name); + return bytes != null ? new ByteArrayInputStream(bytes) : null; + } + return super.getResourceAsStream(name); + } + }; + } + + private static IndexDownloader failingDownloader(boolean pinned, IOException failure) { + return new IndexDownloader(null) { + @Override + public ResolvedIndex resolve() throws IOException { + throw failure; + } + + @Override + boolean isVersionPinned() { + return pinned; + } + }; + } + + private static IndexDownloader.ResolvedIndex resolveIndex(LuceneSearchService service) throws Exception { + Method method = LuceneSearchService.class.getDeclaredMethod("resolveIndex"); + method.setAccessible(true); + try { + return (IndexDownloader.ResolvedIndex) method.invoke(service); + } catch (InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + private static void setResolvedIndex(LuceneSearchService service, IndexDownloader.ResolvedIndex resolved) + throws Exception { + Field field = LuceneSearchService.class.getDeclaredField("resolvedIndex"); + field.setAccessible(true); + field.set(service, resolved); + } +} diff --git a/knowledge/mcp/src/test/java/org/apache/camel/kit/knowledge/mcp/OfflineModelsTest.java b/knowledge/mcp/src/test/java/org/apache/camel/kit/knowledge/mcp/OfflineModelsTest.java new file mode 100644 index 00000000..594a91f0 --- /dev/null +++ b/knowledge/mcp/src/test/java/org/apache/camel/kit/knowledge/mcp/OfflineModelsTest.java @@ -0,0 +1,222 @@ +package org.apache.camel.kit.knowledge.mcp; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.Proxy; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.apache.camel.kit.knowledge.embedding.OnnxEmbeddingProvider; +import org.apache.camel.kit.knowledge.embedding.OnnxReranker; + +import jdk.jfr.Recording; +import jdk.jfr.consumer.RecordingFile; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + +class OfflineModelsTest { + + @TempDir + Path tempDir; + + @ParameterizedTest + @ValueSource(strings = {"embedding", "reranker"}) + void bundledModelsInitializeOfflineInEitherOrder(String firstModel) throws Exception { + runProbe(firstModel, "", ""); + } + + @ParameterizedTest + @CsvSource({ + "embedding, DJL_OFFLINE, false", + "reranker, DJL_OFFLINE, false", + "embedding, RUST_FLAVOR, cu122", + "reranker, RUST_FLAVOR, cu122" + }) + void conflictingEnvironmentIsRejectedBeforeNativeInitialization(String firstModel, String name, String value) + throws Exception { + runProbe(firstModel, name, value); + } + + private void runProbe(String firstModel, String environmentName, String environmentValue) throws Exception { + Path home = Files.createDirectory(tempDir.resolve("home")); + Path temporary = Files.createDirectory(tempDir.resolve("tmp")); + Path cache = tempDir.resolve("cache"); + Path output = tempDir.resolve("probe.log"); + ProcessBuilder builder = new ProcessBuilder( + Path.of(System.getProperty("java.home"), "bin", + System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java").toString(), + "-Duser.home=" + home, + "-Djava.io.tmpdir=" + temporary, + "-Donnx.threads=1", + "-Dai.djl.offline=false", + "-DRUST_FLAVOR=cu122", + "-Dai.djl.util.cuda.fork=true", + "-XX:ActiveProcessorCount=2", + "-cp", System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")), + Probe.class.getName(), firstModel, environmentName, tempDir.toString()); + var environment = builder.environment(); + for (String name : List.of("DJL_OFFLINE", "RUST_FLAVOR", "RUST_LIBRARY_PATH", "DJL_CACHE_DIR", + "ENGINE_CACHE_DIR", "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "_JAVA_OPTIONS")) { + environment.remove(name); + } + environment.put("DJL_CACHE_DIR", cache.toString()); + environment.put("ENGINE_CACHE_DIR", cache.toString()); + environment.put("TMPDIR", temporary.toString()); + environment.put("TMP", temporary.toString()); + environment.put("TEMP", temporary.toString()); + if (!environmentName.isEmpty()) { + environment.put(environmentName, environmentValue); + } + Process child = builder.redirectErrorStream(true).redirectOutput(output.toFile()).start(); + try { + assertTrue(child.waitFor(120, TimeUnit.SECONDS), () -> "Model probe timed out: " + readOutput(output)); + assertEquals(0, child.exitValue(), () -> readOutput(output)); + assertTrue(readOutput(output).contains("OFFLINE_MODELS_PASS"), () -> readOutput(output)); + } finally { + if (child.isAlive()) { + child.destroyForcibly(); + child.waitFor(10, TimeUnit.SECONDS); + } + } + } + + private static String readOutput(Path output) { + try { + return Files.readString(output); + } catch (IOException e) { + return e.toString(); + } + } + + public static class Probe { + public static void main(String[] args) throws Exception { + List<String> networkOpens = new ArrayList<>(); + installBlockingNetworkObserver(networkOpens); + Path directory = Path.of(args[2]); + Path recordingPath = directory.resolve("models.jfr"); + Throwable failure = null; + try (Recording recording = new Recording()) { + recording.enable("jdk.ProcessStart").withThreshold(Duration.ZERO).withStackTrace(); + recording.enable("jdk.SocketRead").withThreshold(Duration.ZERO); + recording.enable("jdk.SocketWrite").withThreshold(Duration.ZERO); + recording.start(); + try { + if (args[1].isEmpty()) { + exercise(args[0]); + exercise(args[0].equals("embedding") ? "reranker" : "embedding"); + assertEquals("true", System.getProperty("ai.djl.offline")); + assertEquals("cpu", System.getProperty("RUST_FLAVOR")); + assertEquals("false", System.getProperty("ai.djl.util.cuda.fork")); + } else { + IllegalStateException rejection + = assertThrows(IllegalStateException.class, () -> exercise(args[0])); + assertTrue(rejection.getMessage().contains(args[1]), rejection::getMessage); + assertFalse(Files.exists(directory.resolve("cache")), + "Rejection must precede native extraction"); + try (var temporaryFiles = Files.list(directory.resolve("tmp"))) { + assertTrue( + temporaryFiles.noneMatch( + path -> path.getFileName().toString().startsWith("camel-kit-onnx-")), + "Rejection must precede model extraction"); + } + } + } catch (Throwable e) { + failure = e; + } finally { + recording.stop(); + recording.dump(recordingPath); + } + } + System.out.println("HTTP opens: " + networkOpens.size() + " " + networkOpens); + assertTrue(networkOpens.isEmpty(), + () -> "Tokenizer initialization attempted network access: " + networkOpens); + var events = RecordingFile.readAllEvents(recordingPath); + var processes = events.stream().filter(event -> event.getEventType().getName().equals("jdk.ProcessStart")) + .toList(); + assertTrue(processes.isEmpty(), () -> "Model initialization started child processes: " + processes); + assertFalse(events.stream().anyMatch(event -> event.getEventType().getName().startsWith("jdk.Socket")), + "Model initialization must not read or write sockets"); + if (failure != null) { + throw new AssertionError("Model initialization contract failed", failure); + } + System.out.println("OFFLINE_MODELS_PASS"); + } + + private static void exercise(String model) { + if (model.equals("embedding")) { + float[] vector = new OnnxEmbeddingProvider().embed("Apache Camel routes messages between endpoints"); + assertEquals(OnnxEmbeddingProvider.DIMENSIONS, vector.length); + double norm = 0; + for (float value : vector) { + assertTrue(Float.isFinite(value)); + norm += value * value; + } + assertEquals(1.0, norm, 0.0001, "Embedding must be a real normalized vector"); + } else { + float score = new OnnxReranker().score("Apache Camel routes", + "Apache Camel routes messages between endpoints"); + assertTrue(Float.isFinite(score) && score > 0 && score < 1, + "Reranker must return a real relevance score"); + } + } + + private static void installBlockingNetworkObserver(List<String> networkOpens) { + URL.setURLStreamHandlerFactory(protocol -> { + if (!protocol.equals("http") && !protocol.equals("https")) { + return null; + } + return new URLStreamHandler() { + @Override + protected URLConnection openConnection(URL url) { + return blocked(url); + } + + @Override + protected URLConnection openConnection(URL url, Proxy proxy) { + return blocked(url); + } + + private URLConnection blocked(URL url) { + networkOpens.add(url.toString()); + return new HttpURLConnection(url) { + @Override + public void disconnect() { + } + + @Override + public boolean usingProxy() { + return false; + } + + @Override + public void connect() throws IOException { + throw new IOException("Network disabled by offline-model test"); + } + + @Override + public int getResponseCode() { + return 404; + } + + @Override + public String getResponseMessage() { + return "Network disabled by offline-model test"; + } + }; + } + }; + }); + } + } +} diff --git a/tools/FixtureIndex.java b/tools/FixtureIndex.java new file mode 100644 index 00000000..1add05d6 --- /dev/null +++ b/tools/FixtureIndex.java @@ -0,0 +1,21 @@ +import java.nio.file.Path; +import org.apache.camel.kit.knowledge.schema.KnowledgeDocument; +import org.apache.lucene.analysis.standard.StandardAnalyzer; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.store.FSDirectory; + +public class FixtureIndex { + public static void main(String[] args) throws Exception { + try (var directory = FSDirectory.open(Path.of(args[0])); + var writer = new IndexWriter(directory, new IndexWriterConfig(new StandardAnalyzer()))) { + writer.addDocument(new KnowledgeDocument("runtime-canary", "apache_camel") + .source("local-canary").docType("guide").sourceVersion("4.22") + .sectionTitle("Apache Camel timer component") + .content("The Apache Camel timer component generates periodic messages. " + + "Use a timer endpoint to trigger a route at regular intervals. " + + "The period option configures the interval between generated messages.") + .build()); + } + } +} diff --git a/tools/verify_knowledge_runtime.py b/tools/verify_knowledge_runtime.py new file mode 100644 index 00000000..36b6b57c --- /dev/null +++ b/tools/verify_knowledge_runtime.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +"""Linux packaged Knowledge stdio/SSE boundary check with a synthetic local index.""" +import argparse +import hashlib +import http.client +import json +import os +from pathlib import Path +import queue +import re +import shutil +import signal +import socket +import subprocess +import threading +import time + + +def sockets(pid): + inodes = set() + for fd in Path(f"/proc/{pid}/fd").iterdir(): + try: + target = fd.readlink().as_posix() + except FileNotFoundError: + continue + if target.startswith("socket:["): + inodes.add(target[8:-1]) + found = [] + for protocol in ["tcp", "tcp6", "udp", "udp6"]: + for line in Path(f"/proc/{pid}/net/{protocol}").read_text().splitlines()[1:]: + fields = line.split() + if fields[9] in inodes: + found.append({"protocol": protocol, "local": fields[1], "remote": fields[2], + "state": fields[3], "inode": fields[9]}) + return found + + +def syscall_evidence(case, mode, baseline_offline): + """Inspect the whole child lifetime, including transient startup activity.""" + lines = (case / "syscalls.log").read_text().splitlines() + execs = [line for line in lines if " execve(" in line or " execveat(" in line] + connects = [line for line in lines if " connect(" in line] + inet_connects = [line for line in connects if "sa_family=AF_INET" in line] + service_connects = [line for line in inet_connects if not re.search(r"sin6?_port=htons\(0\)", line)] + inet_sends = [line for line in lines if re.search(r" (sendto|sendmsg|sendmmsg)\(", line) + and ("<UDP" in line or "<TCP" in line)] + inet_binds = [line for line in lines if " bind(" in line and "sa_family=AF_INET" in line] + listeners = [line for line in lines if " listen(" in line] + evidence = {"exec_calls": execs, "inet_connect_calls": inet_connects, + "inet_service_connect_calls": service_connects, + "inet_send_calls": inet_sends, "inet_bind_calls": inet_binds, + "listen_calls": listeners, + "note": "UDP port-zero connects inspect local interface routing; they do not send payloads. Netlink interface enumeration is recorded in the full trace."} + (case / "syscall-summary.json").write_text(json.dumps(evidence, indent=2) + "\n") + assert not service_connects and not inet_sends, evidence + if mode == "stdio": + assert not inet_binds and not listeners, evidence + else: + assert len(inet_binds) == 1 and len(listeners) == 1, evidence + if not baseline_offline: + assert len(execs) == 1, evidence # The initial JVM, without any child executable. + return evidence + + +def wait_item(items, description, timeout=60): + try: + result = items.get(timeout=timeout) + except queue.Empty: + raise AssertionError(f"Timed out waiting for {description}") from None + if isinstance(result, Exception): + raise result + return result + + +def request(method, id=None, params=None): + value = {"jsonrpc": "2.0", "method": method} + if id is not None: + value["id"] = id + if params is not None: + value["params"] = params + return value + + +def init(): + return request("initialize", 1, {"protocolVersion": "2024-11-05", "capabilities": {}, + "clientInfo": {"name": "local-boundary-canary", "version": "1"}}) + + +class Runner: + def __init__(self, args, mode, port): + self.case = args.output / mode + self.case.mkdir() + for name in ["tmp", "cache", "djl", "engine", "home"]: + (self.case / name).mkdir() + self.events, self.snapshots = [], [] + self.stdout = queue.Queue() + env = {key: value for key, value in os.environ.items() if key in ["PATH", "LANG", "LC_ALL", "TZ"]} + env.update({"DJL_CACHE_DIR": str(self.case / "djl"), "ENGINE_CACHE_DIR": str(self.case / "engine"), + "OMP_NUM_THREADS": "1"}) + java_cmd = [str(args.java), "-XX:ActiveProcessorCount=2", "-Donnx.threads=1", + "-Duser.home=" + str(self.case / "home"), + "-Djava.io.tmpdir=" + str(self.case / "tmp"), + "-Dknowledge.index.path=" + str(args.output / "index"), + "-Dknowledge.index.cache-dir=" + str(self.case / "cache")] + if args.baseline_offline: + java_cmd.append("-Dai.djl.offline=true") + if mode == "sse": + java_cmd += ["-Dquarkus.http.host-enabled=true", "-Dquarkus.http.host=127.0.0.1", + f"-Dquarkus.http.port={port}"] + java_cmd += ["-jar", str(args.jar)] + self.cmd = ["strace", "-f", "-s", "256", "-yy", "-e", "trace=%network,%process", + "-o", str(self.case / "syscalls.log"), *java_cmd] + self.stderr = (self.case / "stderr.log").open("w") + self.proc = subprocess.Popen(self.cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=self.stderr, text=True, env=env, cwd=self.case, + start_new_session=True) + def read(): + for line in self.proc.stdout: + try: + value = json.loads(line) + except json.JSONDecodeError: + value = {"stdout_text": line.rstrip()} + self.stdout.put(value) + threading.Thread(target=read, daemon=True).start() + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + children = Path(f"/proc/{self.proc.pid}/task/{self.proc.pid}/children").read_text().split() + if children: + self.pid = int(children[0]) + break + time.sleep(0.05) + else: + raise AssertionError("No traced JVM child") + + def capture(self, phase): + self.snapshots.append({"phase": phase, "sockets": sockets(self.pid)}) + + def send(self, message): + self.proc.stdin.write(json.dumps(message) + "\n") + self.proc.stdin.flush() + + def stdio_result(self, id): + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + item = wait_item(self.stdout, f"stdio response {id}", deadline - time.monotonic()) + self.events.append(item) + if item.get("id") == id: + assert "error" not in item, item + return item["result"] + raise AssertionError(f"Missing stdio result {id}") + + def close(self): + if self.proc.poll() is None: + self.proc.stdin.close() + try: + self.proc.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(self.proc.pid, signal.SIGTERM) + try: + self.proc.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(self.proc.pid, signal.SIGKILL) + self.proc.wait() + self.stderr.close() + (self.case / "events.json").write_text(json.dumps(self.events, indent=2) + "\n") + (self.case / "sockets.json").write_text(json.dumps(self.snapshots, indent=2) + "\n") + (self.case / "command.json").write_text(json.dumps(self.cmd, indent=2) + "\n") + + +def tool_content(result): + assert not result.get("isError"), result + content = result["content"] + assert len(content) == 1 and content[0]["type"] == "text", result + value = json.loads(content[0]["text"]) + assert "error" not in value, value + return value + + +def stdio(args): + runner = Runner(args, "stdio", None) + try: + runner.send(init()) + initialized = runner.stdio_result(1) + runner.capture("initialized") + runner.send(request("notifications/initialized")) + runner.send(request("tools/list", 2)) + tools = runner.stdio_result(2) + assert "camel_docs_search" in [tool["name"] for tool in tools["tools"]] + runner.capture("tools-listed") + runner.send(request("tools/call", 3, {"name": "camel_docs_search", "arguments": { + "query": "Apache Camel timer component generates periodic messages", "max_results": 1}})) + query = tool_content(runner.stdio_result(3)) + assert "runtime-canary" in json.dumps(query), query + runner.capture("first-query-completed") + assert not any(snapshot["sockets"] for snapshot in runner.snapshots), runner.snapshots + return {"pid": runner.pid, "initialization": initialized, "tool_count": len(tools["tools"]), + "query": query, "snapshots": runner.snapshots} + finally: + runner.close() + + +def sse(args): + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + runner = Runner(args, "sse", port) + conn = None + try: + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + break + except OSError: + time.sleep(0.1) + else: + raise AssertionError("HTTP listener did not start") + runner.capture("http-started") + listeners = [s for s in runner.snapshots[-1]["sockets"] if s["state"] == "0A"] + expected_port = f"{port:04X}" + assert len(listeners) == 1 and listeners[0]["local"].endswith(":" + expected_port), listeners + assert listeners[0]["local"].split(":")[0] in ["0100007F", "0000000000000000FFFF00000100007F"], listeners + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=60) + conn.request("GET", "/mcp/sse", headers={"Accept": "text/event-stream"}) + response = conn.getresponse() + assert response.status == 200, (response.status, response.read()) + events = queue.Queue() + def read_sse(): + event, data = None, [] + try: + while line := response.readline(): + text = line.decode().rstrip("\r\n") + if not text: + if data: + events.put({"event": event, "data": "\n".join(data)}) + event, data = None, [] + elif text.startswith("event:"): + event = text[6:].strip() + elif text.startswith("data:"): + data.append(text[5:].strip()) + except Exception as e: + events.put(e) + threading.Thread(target=read_sse, daemon=True).start() + endpoint_event = wait_item(events, "SSE endpoint") + assert endpoint_event["event"] == "endpoint", endpoint_event + endpoint = endpoint_event["data"] + assert endpoint.startswith("/") and not endpoint.startswith("//"), endpoint + runner.events.append(endpoint_event) + def post(message): + client = http.client.HTTPConnection("127.0.0.1", port, timeout=60) + client.request("POST", endpoint, body=json.dumps(message), headers={"Content-Type": "application/json"}) + reply = client.getresponse() + status = reply.status + body = reply.read().decode() + client.close() + assert status in [200, 202, 204], (status, body) + if "id" not in message: + return + while True: + event = wait_item(events, "SSE response") + runner.events.append(event) + value = json.loads(event["data"]) + if value.get("id") == message["id"]: + assert "error" not in value, value + return value["result"] + initialized = post(init()) + post(request("notifications/initialized")) + tools = post(request("tools/list", 2)) + query = tool_content(post(request("tools/call", 3, {"name": "camel_docs_search", "arguments": { + "query": "Apache Camel timer component generates periodic messages", "max_results": 1}}))) + assert "runtime-canary" in json.dumps(query), query + runner.capture("unauthenticated-query-completed") + return {"pid": runner.pid, "port": port, "initialization": initialized, "tool_count": len(tools["tools"]), + "query": query, "snapshots": runner.snapshots, "authorization_header_sent": False} + finally: + runner.close() + if conn: + conn.close() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--jar", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--java", type=Path, default=Path(shutil.which("java") or "java")) + parser.add_argument("--baseline-offline", action="store_true") + args = parser.parse_args() + args.jar = args.jar.resolve() + args.java = args.java.resolve() + args.output = args.output.resolve() + args.output.mkdir(parents=True, exist_ok=False) + source = Path(__file__).with_name("FixtureIndex.java") + subprocess.run([str(args.java.with_name("javac")), "-cp", str(args.jar), "-d", str(args.output), str(source)], check=True) + subprocess.run([str(args.java), "-cp", str(args.output) + os.pathsep + str(args.jar), "FixtureIndex", + str(args.output / "index")], check=True) + with args.jar.open("rb") as jar: + jar_hash = hashlib.file_digest(jar, "sha256").hexdigest() + result = {"jar": str(args.jar), "jar_sha256": jar_hash, + "java": subprocess.run([str(args.java), "-version"], capture_output=True, text=True).stderr, + "baseline_offline": args.baseline_offline} + result["stdio"] = stdio(args) + result["stdio"]["syscalls"] = syscall_evidence(args.output / "stdio", "stdio", args.baseline_offline) + print("stdio PASS", flush=True) + result["sse"] = sse(args) + result["sse"]["syscalls"] = syscall_evidence(args.output / "sse", "sse", args.baseline_offline) + print("sse PASS", flush=True) + (args.output / "result.json").write_text(json.dumps(result, indent=2) + "\n") + + +if __name__ == "__main__": + main()
