@siduri-x/knowledge 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ import { KnowledgeItem, KnowledgeOrgan } from '@siduri-x/core';
2
+ export interface EKnowledgeConfig {
3
+ provider?: 'e-knowledge' | 'e-remote' | 'e-hub';
4
+ packPath?: string;
5
+ baseUrl?: string;
6
+ registryUrl?: string;
7
+ packId?: string;
8
+ timeoutMs?: number;
9
+ maxResponseBytes?: number;
10
+ preferredMode?: 'lexical' | 'semantic' | 'hybrid';
11
+ }
12
+ /**
13
+ * Validates whether an IP address belongs to loopback, private, link-local, or cloud metadata ranges.
14
+ */
15
+ export declare function isBlockedIp(ip: string): boolean;
16
+ export interface SafeUrlValidationOptions {
17
+ dnsLookup?: (hostname: string) => Promise<string[]>;
18
+ }
19
+ /**
20
+ * Validates a destination URL against SSRF rules:
21
+ * - Scheme must be http: or https:
22
+ * - Host must not resolve to blocked IP addresses
23
+ */
24
+ export declare function validateSafeUrl(urlStr: string, options?: SafeUrlValidationOptions): Promise<URL>;
25
+ /**
26
+ * Safe fetch wrapper that enforces:
27
+ * 1. Target URL validation (SSRF defense)
28
+ * 2. Manual redirect following with re-validation of each redirect target
29
+ * 3. Timeout via AbortController
30
+ * 4. Maximum response size limit
31
+ */
32
+ export declare function safeFetch(urlStr: string, options?: {
33
+ method?: string;
34
+ headers?: Record<string, string>;
35
+ body?: string;
36
+ timeoutMs?: number;
37
+ maxBytes?: number;
38
+ maxRedirects?: number;
39
+ }): Promise<Response>;
40
+ export declare class EKnowledgeAdapter implements KnowledgeOrgan {
41
+ private loaded;
42
+ private readonly preferredMode;
43
+ constructor(config: EKnowledgeConfig);
44
+ get currentRevision(): Promise<string>;
45
+ search(query: string): Promise<KnowledgeItem[]>;
46
+ }
package/dist/index.js ADDED
@@ -0,0 +1,299 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.EKnowledgeAdapter = void 0;
7
+ exports.isBlockedIp = isBlockedIp;
8
+ exports.validateSafeUrl = validateSafeUrl;
9
+ exports.safeFetch = safeFetch;
10
+ const node_net_1 = __importDefault(require("node:net"));
11
+ const promises_1 = __importDefault(require("node:dns/promises"));
12
+ const loadEKnowledgeModule = () => new Function('specifier', 'return import(specifier)')('@vxnus/e-knowledge');
13
+ const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024; // 1MB
14
+ /**
15
+ * Validates whether an IP address belongs to loopback, private, link-local, or cloud metadata ranges.
16
+ */
17
+ function isBlockedIp(ip) {
18
+ // IPv4 checks
19
+ if (node_net_1.default.isIPv4(ip)) {
20
+ const parts = ip.split('.').map(Number);
21
+ if (parts.length !== 4 || parts.some(p => isNaN(p) || p < 0 || p > 255))
22
+ return true;
23
+ // 0.0.0.0/8 (Current network)
24
+ if (parts[0] === 0)
25
+ return true;
26
+ // 127.0.0.0/8 (Loopback)
27
+ if (parts[0] === 127)
28
+ return true;
29
+ // 10.0.0.0/8 (Private RFC1918)
30
+ if (parts[0] === 10)
31
+ return true;
32
+ // 172.16.0.0/12 (Private RFC1918)
33
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
34
+ return true;
35
+ // 192.168.0.0/16 (Private RFC1918)
36
+ if (parts[0] === 192 && parts[1] === 168)
37
+ return true;
38
+ // 169.254.0.0/16 (Link-local / Cloud metadata)
39
+ if (parts[0] === 169 && parts[1] === 254)
40
+ return true;
41
+ // 100.64.0.0/10 (Carrier-grade NAT)
42
+ if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127)
43
+ return true;
44
+ // 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 (TEST-NET)
45
+ if (parts[0] === 192 && parts[1] === 0 && parts[2] === 2)
46
+ return true;
47
+ if (parts[0] === 198 && parts[1] === 51 && parts[2] === 100)
48
+ return true;
49
+ if (parts[0] === 203 && parts[1] === 0 && parts[2] === 113)
50
+ return true;
51
+ // 224.0.0.0/4 (Multicast) & 240.0.0.0/4 (Reserved)
52
+ if (parts[0] >= 224)
53
+ return true;
54
+ return false;
55
+ }
56
+ // IPv6 checks
57
+ if (node_net_1.default.isIPv6(ip)) {
58
+ const normalized = ip.toLowerCase();
59
+ // ::1 (Loopback)
60
+ if (normalized === '::1' || normalized === '0:0:0:0:0:0:0:1')
61
+ return true;
62
+ // :: (Unspecified)
63
+ if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0')
64
+ return true;
65
+ // IPv4-mapped IPv6 (::ffff:127.0.0.1)
66
+ if (normalized.startsWith('::ffff:')) {
67
+ const v4Part = normalized.slice(7);
68
+ if (node_net_1.default.isIPv4(v4Part))
69
+ return isBlockedIp(v4Part);
70
+ }
71
+ // fe80::/10 (Link-local)
72
+ if (normalized.startsWith('fe80:') || normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb'))
73
+ return true;
74
+ // fc00::/7 (Unique local / ULA)
75
+ if (normalized.startsWith('fc') || normalized.startsWith('fd'))
76
+ return true;
77
+ return false;
78
+ }
79
+ // Non-IP string is invalid
80
+ return true;
81
+ }
82
+ /**
83
+ * Validates a destination URL against SSRF rules:
84
+ * - Scheme must be http: or https:
85
+ * - Host must not resolve to blocked IP addresses
86
+ */
87
+ async function validateSafeUrl(urlStr, options = {}) {
88
+ let parsed;
89
+ try {
90
+ parsed = new URL(urlStr);
91
+ }
92
+ catch {
93
+ throw new Error(`Invalid URL provided: ${urlStr}`);
94
+ }
95
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
96
+ throw new Error(`Forbidden protocol: ${parsed.protocol}. Only http: and https: are allowed.`);
97
+ }
98
+ let hostname = parsed.hostname;
99
+ if (!hostname) {
100
+ throw new Error('URL hostname is required');
101
+ }
102
+ // Strip IPv6 brackets if present in hostname (e.g. "[::1]" -> "::1")
103
+ if (hostname.startsWith('[') && hostname.endsWith(']')) {
104
+ hostname = hostname.slice(1, -1);
105
+ }
106
+ // If hostname is already an IP address
107
+ if (node_net_1.default.isIP(hostname)) {
108
+ if (isBlockedIp(hostname)) {
109
+ throw new Error(`Blocked destination IP address: ${hostname}`);
110
+ }
111
+ return parsed;
112
+ }
113
+ // Resolve hostname via DNS
114
+ let addresses;
115
+ try {
116
+ if (options.dnsLookup) {
117
+ addresses = await options.dnsLookup(hostname);
118
+ }
119
+ else {
120
+ const res = await promises_1.default.lookup(hostname, { all: true });
121
+ addresses = res.map(r => r.address);
122
+ }
123
+ }
124
+ catch (err) {
125
+ throw new Error(`DNS resolution failed for hostname "${hostname}": ${err.message}`);
126
+ }
127
+ if (!addresses || addresses.length === 0) {
128
+ throw new Error(`No DNS records found for hostname: ${hostname}`);
129
+ }
130
+ for (const addr of addresses) {
131
+ if (isBlockedIp(addr)) {
132
+ throw new Error(`Hostname "${hostname}" resolved to blocked IP address: ${addr}`);
133
+ }
134
+ }
135
+ return parsed;
136
+ }
137
+ /**
138
+ * Safe fetch wrapper that enforces:
139
+ * 1. Target URL validation (SSRF defense)
140
+ * 2. Manual redirect following with re-validation of each redirect target
141
+ * 3. Timeout via AbortController
142
+ * 4. Maximum response size limit
143
+ */
144
+ async function safeFetch(urlStr, options = {}) {
145
+ let currentUrl = urlStr;
146
+ const maxRedirects = options.maxRedirects ?? 3;
147
+ const timeoutMs = options.timeoutMs ?? 5000;
148
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
149
+ for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
150
+ const validated = await validateSafeUrl(currentUrl);
151
+ const controller = new AbortController();
152
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
153
+ try {
154
+ const response = await fetch(validated.toString(), {
155
+ method: options.method || 'GET',
156
+ headers: options.headers,
157
+ body: options.body,
158
+ signal: controller.signal,
159
+ redirect: 'manual',
160
+ });
161
+ // Handle Redirects safely
162
+ if (response.status >= 300 && response.status < 400) {
163
+ const location = response.headers.get('location');
164
+ if (!location) {
165
+ throw new Error(`HTTP ${response.status} redirect missing Location header`);
166
+ }
167
+ if (redirectCount >= maxRedirects) {
168
+ throw new Error(`Exceeded maximum redirect limit of ${maxRedirects}`);
169
+ }
170
+ currentUrl = new URL(location, validated).toString();
171
+ continue;
172
+ }
173
+ // Check Content-Length header if present
174
+ const contentLengthHeader = response.headers.get('content-length');
175
+ if (contentLengthHeader) {
176
+ const contentLength = parseInt(contentLengthHeader, 10);
177
+ if (!isNaN(contentLength) && contentLength > maxBytes) {
178
+ throw new Error(`Response size (${contentLength} bytes) exceeds limit of ${maxBytes} bytes`);
179
+ }
180
+ }
181
+ return response;
182
+ }
183
+ finally {
184
+ clearTimeout(timer);
185
+ }
186
+ }
187
+ throw new Error('Too many redirects');
188
+ }
189
+ async function safeFetchJson(urlStr, options = {}) {
190
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
191
+ const response = await safeFetch(urlStr, options);
192
+ if (!response.ok) {
193
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
194
+ }
195
+ // Read response as text with size limit to prevent memory exhaustion
196
+ const text = await response.text();
197
+ if (text.length > maxBytes) {
198
+ throw new Error(`Response body length (${text.length} chars) exceeds maximum allowed ${maxBytes} bytes`);
199
+ }
200
+ try {
201
+ return JSON.parse(text);
202
+ }
203
+ catch (err) {
204
+ throw new Error(`Failed to parse JSON response: ${err.message}`);
205
+ }
206
+ }
207
+ async function resolveManifest(provider, baseUrl, timeoutMs, maxBytes) {
208
+ if (typeof provider.manifest === 'function') {
209
+ return await provider.manifest();
210
+ }
211
+ const cleanUrl = baseUrl.replace(/\/+$/, '');
212
+ return await safeFetchJson(`${cleanUrl}/manifest`, {
213
+ headers: { accept: 'application/json' },
214
+ timeoutMs: timeoutMs || 5000,
215
+ maxBytes,
216
+ });
217
+ }
218
+ async function resolveHubProvider(config, module) {
219
+ if (!config.registryUrl || !config.packId)
220
+ throw new Error('E Hub provider requires registryUrl and packId');
221
+ const match = config.packId.match(/^@([^/]+)\/([^/]+)$/);
222
+ if (!match)
223
+ throw new Error('E Hub packId must use the @publisher/name format');
224
+ const registryUrl = config.registryUrl.replace(/\/+$/, '');
225
+ const hubPackUrl = `${registryUrl}/${encodeURIComponent(match[1])}/${encodeURIComponent(match[2])}`;
226
+ const pack = await safeFetchJson(hubPackUrl, {
227
+ timeoutMs: config.timeoutMs,
228
+ maxBytes: config.maxResponseBytes,
229
+ });
230
+ if (pack.distribution?.kind !== 'provider' || !pack.distribution.url) {
231
+ throw new Error(`E Hub pack ${config.packId} is not a remote provider`);
232
+ }
233
+ const baseUrl = pack.distribution.url;
234
+ // Validate distribution URL against SSRF
235
+ await validateSafeUrl(baseUrl);
236
+ return {
237
+ provider: module.createRemoteProvider({ baseUrl, timeoutMs: config.timeoutMs, manifest: pack }),
238
+ baseUrl,
239
+ manifest: pack,
240
+ };
241
+ }
242
+ class EKnowledgeAdapter {
243
+ loaded;
244
+ preferredMode;
245
+ constructor(config) {
246
+ this.preferredMode = config.preferredMode ?? 'lexical';
247
+ this.loaded = loadEKnowledgeModule().then(async (module) => {
248
+ if (config.provider === 'e-hub') {
249
+ const { provider, baseUrl, manifest: hubManifest } = await resolveHubProvider(config, module);
250
+ let manifest;
251
+ try {
252
+ manifest = await resolveManifest(provider, baseUrl, config.timeoutMs);
253
+ }
254
+ catch {
255
+ if (!hubManifest)
256
+ throw new Error('Could not resolve manifest for E Hub provider');
257
+ manifest = hubManifest;
258
+ }
259
+ return { provider: provider, manifest };
260
+ }
261
+ if (config.provider === 'e-remote' || config.baseUrl) {
262
+ const baseUrl = config.baseUrl || '';
263
+ const provider = module.createRemoteProvider({ baseUrl, timeoutMs: config.timeoutMs });
264
+ const manifest = await resolveManifest(provider, baseUrl, config.timeoutMs);
265
+ return { provider: provider, manifest };
266
+ }
267
+ if (!config.packPath)
268
+ throw new Error('EKnowledgeAdapter requires packPath, baseUrl, or E Hub configuration');
269
+ return module.loadPack(config.packPath);
270
+ });
271
+ }
272
+ get currentRevision() { return this.loaded.then(pack => 'revision' in pack ? pack.revision.id : 'remote'); }
273
+ async search(query) {
274
+ const pack = await this.loaded;
275
+ if (!query.trim())
276
+ return [];
277
+ const requestedMode = this.preferredMode;
278
+ const manifest = pack.manifest;
279
+ const modeSupported = requestedMode === 'lexical' || manifest.capabilities.semanticSearch;
280
+ let response;
281
+ try {
282
+ response = await pack.provider.retrieve({ query, mode: modeSupported ? requestedMode : 'lexical', limit: 8 });
283
+ }
284
+ catch (error) {
285
+ if (requestedMode === 'lexical')
286
+ throw error;
287
+ // Semantic infrastructure is optional: an outage must not remove the
288
+ // provider's cited lexical path.
289
+ response = await pack.provider.retrieve({ query, mode: 'lexical', limit: 8 });
290
+ }
291
+ return response.results.map((result) => ({
292
+ content: result.content,
293
+ revision: result.revision,
294
+ citations: result.citations,
295
+ provenance: result.citations[0]?.sourceId || pack.manifest.publisher
296
+ }));
297
+ }
298
+ }
299
+ exports.EKnowledgeAdapter = EKnowledgeAdapter;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const index_1 = require("./index");
7
+ const node_path_1 = __importDefault(require("node:path"));
8
+ const promises_1 = __importDefault(require("node:dns/promises"));
9
+ const testPackPath = process.env.E_TEST_PACK_PATH || node_path_1.default.resolve(__dirname, '../../../../../e/packages/knowledge/fixtures/sample');
10
+ describe('EKnowledgeAdapter', () => {
11
+ beforeEach(() => {
12
+ jest.spyOn(promises_1.default, 'lookup').mockImplementation(async (hostname) => {
13
+ if (hostname.includes('blocked') || hostname.includes('127.0.0.1')) {
14
+ return [{ address: '127.0.0.1', family: 4 }];
15
+ }
16
+ return [{ address: '93.184.216.34', family: 4 }]; // public IP
17
+ });
18
+ });
19
+ afterEach(() => {
20
+ jest.restoreAllMocks();
21
+ });
22
+ test('loads an E pack and preserves citations and revision', async () => {
23
+ const adapter = new index_1.EKnowledgeAdapter({ packPath: testPackPath });
24
+ const results = await adapter.search('grounded facts');
25
+ expect(results).toHaveLength(1);
26
+ expect(results[0]).toMatchObject({ content: 'Siduri knowledge packs provide grounded facts.', revision: 'r1' });
27
+ expect(results[0].citations[0]).toMatchObject({ sourceId: 'handbook', documentId: 'intro', chunkId: 'intro-1' });
28
+ });
29
+ test('does not retrieve for an empty query', async () => {
30
+ const adapter = new index_1.EKnowledgeAdapter({ packPath: testPackPath });
31
+ expect(await adapter.search(' ')).toEqual([]);
32
+ });
33
+ test('falls back to lexical when semantic capability is unavailable', async () => {
34
+ const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
35
+ const url = String(input);
36
+ const data = url.endsWith('/manifest')
37
+ ? { id: '@publisher/installed-pack', name: 'installed-pack', publisher: 'publisher', version: '1.0.0', schemaVersion: '1.0', sources: [{ id: 'source', title: 'Source', license: 'CC-BY-4.0' }], capabilities: { lexicalSearch: true, semanticSearch: false, structuredEntities: true, relations: true, revisions: true } }
38
+ : { revision: 'r1', results: [{ id: 'c1', content: 'lexical fallback', revision: 'r1', citations: [{ sourceId: 'gi-data', chunkId: 'c1' }] }] };
39
+ if (!url.endsWith('/manifest')) {
40
+ expect(JSON.parse(String(init?.body)).mode).toBe('lexical');
41
+ }
42
+ return {
43
+ ok: true,
44
+ status: 200,
45
+ text: async () => JSON.stringify(data),
46
+ json: async () => data,
47
+ headers: new Headers(),
48
+ };
49
+ });
50
+ const adapter = new index_1.EKnowledgeAdapter({ provider: 'e-remote', baseUrl: 'https://provider.example/api/e', preferredMode: 'semantic' });
51
+ await expect(adapter.search('fallback')).resolves.toMatchObject([{ content: 'lexical fallback' }]);
52
+ fetchMock.mockRestore();
53
+ });
54
+ test('loads an E remote provider and preserves its citation contract', async () => {
55
+ const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
56
+ const url = String(input);
57
+ const body = url.endsWith('/manifest')
58
+ ? { id: '@publisher/installed-pack', name: 'installed-pack', publisher: 'publisher', version: '1.0.0', schemaVersion: '1.0', sources: [{ id: 'source', title: 'Source', license: 'CC-BY-4.0' }], capabilities: { lexicalSearch: true, semanticSearch: false, structuredEntities: true, relations: true, revisions: true } }
59
+ : { revision: 'teyvat-r1', results: [{ id: 'chunk-1', content: 'Furina uses materials.', revision: 'teyvat-r1', citations: [{ sourceId: 'gi-data', documentId: 'doc-1', chunkId: 'chunk-1' }] }] };
60
+ return {
61
+ ok: true,
62
+ status: 200,
63
+ text: async () => JSON.stringify(body),
64
+ json: async () => body,
65
+ headers: new Headers(),
66
+ };
67
+ });
68
+ const adapter = new index_1.EKnowledgeAdapter({ provider: 'e-remote', baseUrl: 'https://provider.example/api/e' });
69
+ await expect(adapter.search('Furina')).resolves.toMatchObject([{ revision: 'teyvat-r1', provenance: 'gi-data' }]);
70
+ expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/manifest'), expect.anything());
71
+ fetchMock.mockRestore();
72
+ });
73
+ test('resolves a provider distribution through the E Hub', async () => {
74
+ const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
75
+ const url = String(input);
76
+ const body = url.includes('/api/packs/publisher/installed-pack')
77
+ ? { distribution: { kind: 'provider', url: 'https://provider.example/api/e' } }
78
+ : url.endsWith('/manifest')
79
+ ? { id: '@publisher/installed-pack', name: 'installed-pack', publisher: 'publisher', version: '1.0.0', schemaVersion: '1.0', sources: [{ id: 'source', title: 'Source', license: 'CC-BY-4.0' }], capabilities: { lexicalSearch: true, semanticSearch: false, structuredEntities: true, relations: true, revisions: true } }
80
+ : { revision: 'teyvat-r1', results: [{ id: 'chunk-1', content: 'hub fact', revision: 'teyvat-r1', citations: [{ sourceId: 'gi-data', documentId: 'doc-1', chunkId: 'chunk-1' }] }] };
81
+ return {
82
+ ok: true,
83
+ status: 200,
84
+ text: async () => JSON.stringify(body),
85
+ json: async () => body,
86
+ headers: new Headers(),
87
+ };
88
+ });
89
+ const adapter = new index_1.EKnowledgeAdapter({ provider: 'e-hub', registryUrl: 'https://hub.example/api/packs', packId: '@publisher/installed-pack' });
90
+ await expect(adapter.search('hub')).resolves.toMatchObject([{ content: 'hub fact', revision: 'teyvat-r1' }]);
91
+ expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/packs/publisher/installed-pack'), expect.anything());
92
+ fetchMock.mockRestore();
93
+ });
94
+ });
95
+ describe('SSRF Hardening Suite', () => {
96
+ beforeEach(() => {
97
+ jest.spyOn(promises_1.default, 'lookup').mockImplementation(async (hostname) => {
98
+ if (hostname.includes('blocked') || hostname.includes('127.0.0.1') || hostname.includes('internal.corp')) {
99
+ return [{ address: '10.0.0.5', family: 4 }];
100
+ }
101
+ return [{ address: '93.184.216.34', family: 4 }]; // public IP
102
+ });
103
+ });
104
+ afterEach(() => {
105
+ jest.restoreAllMocks();
106
+ });
107
+ test('isBlockedIp identifies private, loopback, link-local, and cloud metadata IPs', () => {
108
+ expect((0, index_1.isBlockedIp)('127.0.0.1')).toBe(true);
109
+ expect((0, index_1.isBlockedIp)('127.255.255.255')).toBe(true);
110
+ expect((0, index_1.isBlockedIp)('10.0.0.1')).toBe(true);
111
+ expect((0, index_1.isBlockedIp)('172.16.0.1')).toBe(true);
112
+ expect((0, index_1.isBlockedIp)('192.168.1.1')).toBe(true);
113
+ expect((0, index_1.isBlockedIp)('169.254.169.254')).toBe(true); // AWS/GCP Metadata
114
+ expect((0, index_1.isBlockedIp)('0.0.0.0')).toBe(true);
115
+ expect((0, index_1.isBlockedIp)('::1')).toBe(true);
116
+ expect((0, index_1.isBlockedIp)('::ffff:127.0.0.1')).toBe(true);
117
+ expect((0, index_1.isBlockedIp)('fe80::1')).toBe(true);
118
+ expect((0, index_1.isBlockedIp)('fc00::1')).toBe(true);
119
+ // Public IPs should not be blocked
120
+ expect((0, index_1.isBlockedIp)('8.8.8.8')).toBe(false);
121
+ expect((0, index_1.isBlockedIp)('93.184.216.34')).toBe(false);
122
+ expect((0, index_1.isBlockedIp)('2606:2800:220:1:248:1893:25c8:1946')).toBe(false);
123
+ });
124
+ test('validateSafeUrl rejects non-http schemes and blocked IPs', async () => {
125
+ await expect((0, index_1.validateSafeUrl)('file:///etc/passwd')).rejects.toThrow(/Forbidden protocol/);
126
+ await expect((0, index_1.validateSafeUrl)('ftp://example.com')).rejects.toThrow(/Forbidden protocol/);
127
+ await expect((0, index_1.validateSafeUrl)('http://127.0.0.1:8080/manifest')).rejects.toThrow(/Blocked destination IP address/);
128
+ await expect((0, index_1.validateSafeUrl)('http://169.254.169.254/latest/meta-data')).rejects.toThrow(/Blocked destination IP address/);
129
+ await expect((0, index_1.validateSafeUrl)('http://0.0.0.0/')).rejects.toThrow(/Blocked destination IP address/);
130
+ await expect((0, index_1.validateSafeUrl)('http://100.64.0.1/')).rejects.toThrow(/Blocked destination IP address/); // CGNAT
131
+ await expect((0, index_1.validateSafeUrl)('http://[::1]/')).rejects.toThrow(/Blocked destination IP address/); // IPv6 loopback
132
+ await expect((0, index_1.validateSafeUrl)('http://[fc00::1]/')).rejects.toThrow(/Blocked destination IP address/); // IPv6 ULA
133
+ });
134
+ test('validateSafeUrl rejects hostnames resolving to private IPs', async () => {
135
+ jest.spyOn(promises_1.default, 'lookup').mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }]);
136
+ await expect((0, index_1.validateSafeUrl)('https://internal.corp/manifest')).rejects.toThrow(/resolved to blocked IP address/);
137
+ });
138
+ test('safeFetch rejects open redirects pointing to internal IP addresses', async () => {
139
+ const fetchMock = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
140
+ status: 302,
141
+ ok: false,
142
+ headers: new Headers({ location: 'http://169.254.169.254/latest/meta-data' }),
143
+ text: async () => '',
144
+ });
145
+ await expect((0, index_1.safeFetch)('https://public.example/redirect')).rejects.toThrow(/Blocked destination IP address/);
146
+ fetchMock.mockRestore();
147
+ });
148
+ });
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@siduri-x/knowledge",
3
+ "organType": "knowledge",
4
+ "version": "1.0.0",
5
+ "displayName": "Knowledge (E-Compatible Cited Facts)",
6
+ "description": "Factual context retrieval from local or hosted E Knowledge packs",
7
+ "entrypoint": "./dist/index.js",
8
+ "factory": "EKnowledgeAdapter",
9
+ "configKey": "knowledge",
10
+ "configSchema": {
11
+ "type": "object",
12
+ "required": [
13
+ "provider"
14
+ ],
15
+ "properties": {
16
+ "provider": {
17
+ "type": "string",
18
+ "enum": [
19
+ "e-knowledge",
20
+ "e-remote",
21
+ "e-hub",
22
+ "none"
23
+ ]
24
+ },
25
+ "packPath": {
26
+ "type": "string"
27
+ },
28
+ "baseUrl": {
29
+ "type": "string"
30
+ },
31
+ "registryUrl": {
32
+ "type": "string"
33
+ },
34
+ "packId": {
35
+ "type": "string"
36
+ },
37
+ "timeoutMs": {
38
+ "type": "number",
39
+ "default": 5000
40
+ },
41
+ "preferredMode": {
42
+ "type": "string",
43
+ "enum": [
44
+ "lexical",
45
+ "semantic",
46
+ "hybrid"
47
+ ],
48
+ "default": "lexical"
49
+ }
50
+ }
51
+ },
52
+ "environment": [
53
+ {
54
+ "name": "SIDURI_KNOWLEDGE_PACK",
55
+ "required": false,
56
+ "secret": false,
57
+ "description": "Path to local E knowledge pack"
58
+ },
59
+ {
60
+ "name": "SIDURI_KNOWLEDGE_REGISTRY_URL",
61
+ "required": false,
62
+ "secret": false,
63
+ "default": "https://e.vxnus.xyz/api/v1/knowledge",
64
+ "description": "URL of the E Knowledge Hub registry"
65
+ }
66
+ ],
67
+ "services": [
68
+ {
69
+ "name": "E Knowledge Hub",
70
+ "kind": "http_service",
71
+ "optional": true
72
+ }
73
+ ],
74
+ "database": null,
75
+ "healthCheck": null
76
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@siduri-x/knowledge",
3
+ "version": "1.0.0",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "scripts": {
7
+ "build": "tsc",
8
+ "dev": "tsc -w",
9
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest --config jest.config.json"
10
+ },
11
+ "dependencies": {
12
+ "@vxnus/e": "^0.1.4",
13
+ "@vxnus/e-knowledge": "^0.1.4",
14
+ "@siduri-x/core": "workspace:*"
15
+ },
16
+ "devDependencies": {
17
+ "@types/jest": "^29.5.14",
18
+ "@types/node": "^26.2.0",
19
+ "jest": "^29.7.0",
20
+ "ts-jest": "^29.4.12",
21
+ "typescript": "^5.3.3"
22
+ },
23
+ "description": "Knowledge organ for semantic search and retrieval from external sources",
24
+ "license": "UNLICENSED",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/vxnuslabs/siduri-y",
28
+ "directory": "packages/organs/knowledge"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "organ-manifest.json",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "exports": {
43
+ ".": {
44
+ "types": "./dist/index.d.ts",
45
+ "import": "./dist/index.js",
46
+ "default": "./dist/index.js"
47
+ },
48
+ "./organ-manifest.json": "./organ-manifest.json"
49
+ }
50
+ }