webveil 0.0.0 → 0.1.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.
Files changed (73) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +101 -0
  3. package/dist/cli.d.ts +58 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +91 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/core/backends/custom.d.ts +15 -0
  8. package/dist/core/backends/custom.d.ts.map +1 -0
  9. package/dist/core/backends/custom.js +106 -0
  10. package/dist/core/backends/custom.js.map +1 -0
  11. package/dist/core/backends/registry.d.ts +13 -0
  12. package/dist/core/backends/registry.d.ts.map +1 -0
  13. package/dist/core/backends/registry.js +31 -0
  14. package/dist/core/backends/registry.js.map +1 -0
  15. package/dist/core/backends/searxng.d.ts +8 -0
  16. package/dist/core/backends/searxng.d.ts.map +1 -0
  17. package/dist/core/backends/searxng.js +43 -0
  18. package/dist/core/backends/searxng.js.map +1 -0
  19. package/dist/core/backends/tavily-compat.d.ts +10 -0
  20. package/dist/core/backends/tavily-compat.d.ts.map +1 -0
  21. package/dist/core/backends/tavily-compat.js +85 -0
  22. package/dist/core/backends/tavily-compat.js.map +1 -0
  23. package/dist/core/backends/types.d.ts +48 -0
  24. package/dist/core/backends/types.d.ts.map +1 -0
  25. package/dist/core/backends/types.js +5 -0
  26. package/dist/core/backends/types.js.map +1 -0
  27. package/dist/core/config.d.ts +39 -0
  28. package/dist/core/config.d.ts.map +1 -0
  29. package/dist/core/config.js +72 -0
  30. package/dist/core/config.js.map +1 -0
  31. package/dist/core/egress.d.ts +30 -0
  32. package/dist/core/egress.d.ts.map +1 -0
  33. package/dist/core/egress.js +87 -0
  34. package/dist/core/egress.js.map +1 -0
  35. package/dist/core/extract.d.ts +45 -0
  36. package/dist/core/extract.d.ts.map +1 -0
  37. package/dist/core/extract.js +36 -0
  38. package/dist/core/extract.js.map +1 -0
  39. package/dist/core/fetch.d.ts +42 -0
  40. package/dist/core/fetch.d.ts.map +1 -0
  41. package/dist/core/fetch.js +76 -0
  42. package/dist/core/fetch.js.map +1 -0
  43. package/dist/core/http.d.ts +8 -0
  44. package/dist/core/http.d.ts.map +1 -0
  45. package/dist/core/http.js +49 -0
  46. package/dist/core/http.js.map +1 -0
  47. package/dist/core/search.d.ts +31 -0
  48. package/dist/core/search.d.ts.map +1 -0
  49. package/dist/core/search.js +65 -0
  50. package/dist/core/search.js.map +1 -0
  51. package/dist/core/security.d.ts +35 -0
  52. package/dist/core/security.d.ts.map +1 -0
  53. package/dist/core/security.js +141 -0
  54. package/dist/core/security.js.map +1 -0
  55. package/dist/index.d.ts +22 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +40 -0
  58. package/dist/index.js.map +1 -0
  59. package/package.json +62 -2
  60. package/src/cli.ts +106 -0
  61. package/src/core/backends/custom.ts +159 -0
  62. package/src/core/backends/registry.ts +41 -0
  63. package/src/core/backends/searxng.ts +70 -0
  64. package/src/core/backends/tavily-compat.ts +156 -0
  65. package/src/core/backends/types.ts +61 -0
  66. package/src/core/config.ts +106 -0
  67. package/src/core/egress.ts +106 -0
  68. package/src/core/extract.ts +82 -0
  69. package/src/core/fetch.ts +132 -0
  70. package/src/core/http.ts +62 -0
  71. package/src/core/search.ts +104 -0
  72. package/src/core/security.ts +141 -0
  73. package/src/index.ts +82 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/core/search.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAC,MAAM,EAAE,cAAc,EAAC,MAAM,aAAa,CAAC;AAExD,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,aAAa,CAAC;AAG5C,OAAO,KAAK,EAAC,IAAI,EAAE,aAAa,EAAE,YAAY,EAAC,MAAM,qBAAqB,CAAC;AAU3E;;;;;;GAMG;AACH,MAAM,WAAW,UAAU;IAC1B,aAAa,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,cAAc,KAAK,MAAM,CAAC;IACrD,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,UAAU,GAAG,SAAS,CAAC;IAC7D,UAAU,CAAC,EAAE,CAAC,UAAU,EAAE,UAAU,GAAG,SAAS,KAAK,IAAI,CAAC;IAC1D,UAAU,CAAC,EAAE,CACZ,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,KACV;QACJ,MAAM,EAAE,CACP,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,IAAI,EACV,OAAO,CAAC,EAAE,aAAa,KACnB,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;KAC7B,CAAC;CACF;AAED,iFAAiF;AACjF,MAAM,WAAW,iBAAkB,SAAQ,aAAa,EAAE,cAAc;CAAG;AAc3E;;;;;;;GAOG;AACH,wBAAsB,MAAM,CAC3B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,iBAAsB,EAC/B,IAAI,GAAE,UAAe,GACnB,OAAO,CAAC,YAAY,EAAE,CAAC,CAwBzB"}
@@ -0,0 +1,65 @@
1
+ // core search — the plain, framework-agnostic `search()` BOTH frontends (the
2
+ // incur CLI/MCP and the pi extension) call. It owns the wiring and the
3
+ // caller-facing post-processing; the per-source parsing lives in the backend.
4
+ //
5
+ // Flow: resolve config → build the egress dispatcher → bind the proxied `http`
6
+ // helper to it → select the backend from the registry → call the backend with
7
+ // ONLY that proxied helper → normalize (dedup + clamp) the SearchResult[].
8
+ //
9
+ // The egress invariant (docs/adr/0001): the backend is handed only the
10
+ // dispatcher-bound `http` helper, so it physically cannot reach a global fetch
11
+ // and bypass the configured egress. A configured-but-unbuildable proxy throws at
12
+ // buildDispatcher (fail-loud), never silently un-proxied.
13
+ import { resolveConfig as defaultResolveConfig } from './config.js';
14
+ import { buildDispatcher as defaultBuildDispatcher } from './egress.js';
15
+ import { createHttp as defaultCreateHttp } from './http.js';
16
+ import { getBackend as defaultGetBackend } from './backends/registry.js';
17
+ /**
18
+ * Default cap on returned results when the caller does not pass `maxResults`.
19
+ * Keeps an agent's context small by default; a caller can raise/lower it per
20
+ * call. (Recorded decision: there is no configured default, so the core sets
21
+ * one; see the task's Decisions block.)
22
+ */
23
+ const DEFAULT_MAX_RESULTS = 10;
24
+ /** Dedup by url (the hit's identity), preserving first-seen order. */
25
+ function dedup(results) {
26
+ const seen = new Set();
27
+ const out = [];
28
+ for (const r of results) {
29
+ if (seen.has(r.url))
30
+ continue;
31
+ seen.add(r.url);
32
+ out.push(r);
33
+ }
34
+ return out;
35
+ }
36
+ /**
37
+ * Search the configured backend over the configured egress and return
38
+ * normalized `SearchResult[]` (deduped by url, then clamped to `maxResults`).
39
+ *
40
+ * Dedup runs BEFORE the clamp so the caller gets up to `maxResults` UNIQUE hits,
41
+ * not a window that duplicates eat into; for the same reason the backend is NOT
42
+ * asked to pre-clamp (only the abort signal is forwarded).
43
+ */
44
+ export async function search(query, options = {}, deps = {}) {
45
+ const resolveConfig = deps.resolveConfig ?? defaultResolveConfig;
46
+ const buildDispatcher = deps.buildDispatcher ?? defaultBuildDispatcher;
47
+ const createHttp = deps.createHttp ?? defaultCreateHttp;
48
+ const getBackend = deps.getBackend ?? defaultGetBackend;
49
+ const config = resolveConfig({
50
+ cwd: options.cwd,
51
+ env: options.env,
52
+ globalPath: options.globalPath,
53
+ });
54
+ // Build the dispatcher FIRST: a configured-but-unbuildable proxy throws here,
55
+ // before any network access (never an un-proxied request).
56
+ const dispatcher = buildDispatcher(config);
57
+ const http = createHttp(dispatcher);
58
+ const backend = getBackend(config.backend, config);
59
+ // Hand the backend ONLY the proxied helper (no maxResults: dedup happens
60
+ // here, over the full set, so the clamp below is over UNIQUE results).
61
+ const raw = await backend.search(query, http, { signal: options.signal });
62
+ const maxResults = options.maxResults ?? DEFAULT_MAX_RESULTS;
63
+ return dedup(raw).slice(0, maxResults);
64
+ }
65
+ //# sourceMappingURL=search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search.js","sourceRoot":"","sources":["../../src/core/search.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,uEAAuE;AACvE,8EAA8E;AAC9E,EAAE;AACF,+EAA+E;AAC/E,8EAA8E;AAC9E,2EAA2E;AAC3E,EAAE;AACF,uEAAuE;AACvE,+EAA+E;AAC/E,iFAAiF;AACjF,0DAA0D;AAE1D,OAAO,EAAC,aAAa,IAAI,oBAAoB,EAAC,MAAM,aAAa,CAAC;AAElE,OAAO,EAAC,eAAe,IAAI,sBAAsB,EAAC,MAAM,aAAa,CAAC;AAEtE,OAAO,EAAC,UAAU,IAAI,iBAAiB,EAAC,MAAM,WAAW,CAAC;AAC1D,OAAO,EAAC,UAAU,IAAI,iBAAiB,EAAC,MAAM,wBAAwB,CAAC;AAGvE;;;;;GAKG;AACH,MAAM,mBAAmB,GAAG,EAAE,CAAC;AA4B/B,sEAAsE;AACtE,SAAS,KAAK,CAAC,OAAuB;IACrC,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,MAAM,GAAG,GAAmB,EAAE,CAAC;IAC/B,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;QACzB,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;YAAE,SAAS;QAC9B,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;QAChB,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACb,CAAC;IACD,OAAO,GAAG,CAAC;AACZ,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,MAAM,CAC3B,KAAa,EACb,UAA6B,EAAE,EAC/B,OAAmB,EAAE;IAErB,MAAM,aAAa,GAAG,IAAI,CAAC,aAAa,IAAI,oBAAoB,CAAC;IACjE,MAAM,eAAe,GAAG,IAAI,CAAC,eAAe,IAAI,sBAAsB,CAAC;IACvE,MAAM,UAAU,GAAG,IAAI,CAAC,UAAU,IAAI,iBAAiB,CAAC;IACxD,MAAM,UAAU,GAAG,IAAI,CAAC,UAAU,IAAI,iBAAiB,CAAC;IAExD,MAAM,MAAM,GAAG,aAAa,CAAC;QAC5B,GAAG,EAAE,OAAO,CAAC,GAAG;QAChB,GAAG,EAAE,OAAO,CAAC,GAAG;QAChB,UAAU,EAAE,OAAO,CAAC,UAAU;KAC9B,CAAC,CAAC;IAEH,8EAA8E;IAC9E,2DAA2D;IAC3D,MAAM,UAAU,GAAG,eAAe,CAAC,MAAM,CAAC,CAAC;IAC3C,MAAM,IAAI,GAAG,UAAU,CAAC,UAAU,CAAC,CAAC;IAEpC,MAAM,OAAO,GAAG,UAAU,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IACnD,yEAAyE;IACzE,uEAAuE;IACvE,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,IAAI,EAAE,EAAC,MAAM,EAAE,OAAO,CAAC,MAAM,EAAC,CAAC,CAAC;IAExE,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,mBAAmB,CAAC;IAC7D,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC;AACxC,CAAC"}
@@ -0,0 +1,35 @@
1
+ import type { Config } from './config.js';
2
+ import type { EgressFetch } from './egress.js';
3
+ /** Thrown when the SSRF guard refuses a request to a private/blocked address. */
4
+ export declare class SsrfError extends Error {
5
+ constructor(message: string);
6
+ }
7
+ /**
8
+ * Is this LITERAL IP private / non-public? Covers the ranges that must never be
9
+ * reachable from a direct-egress web fetch:
10
+ * IPv4: 0.0.0.0/8, 10/8 (RFC1918), 127/8 (loopback), 169.254/16 (link-local,
11
+ * incl. the 169.254.169.254 cloud metadata endpoint), 172.16/12 (RFC1918),
12
+ * 192.168/16 (RFC1918), 100.64/10 (CGNAT), 192.0.0/24, 192.0.2/24,
13
+ * 198.18/15, 198.51.100/24, 203.0.113/24, 224/4 (multicast), 240/4
14
+ * (reserved).
15
+ * IPv6: ::1 (loopback), :: (unspecified), fc00::/7 (ULA), fe80::/10
16
+ * (link-local), ff00::/8 (multicast), plus IPv4-mapped (::ffff:a.b.c.d,
17
+ * re-checked as IPv4). Default-deny: anything outside global unicast
18
+ * (2000::/3) is treated as non-public.
19
+ */
20
+ export declare function isPrivateIp(ip: string): boolean;
21
+ /**
22
+ * Assert a URL is safe to fetch under THIS config's egress. Under a proxy egress
23
+ * it always passes (the proxy owns egress + DNS). Under direct egress it rejects
24
+ * a literal private IP and, for a hostname, resolves it locally and rejects if it
25
+ * maps to a private IP (so a name pointing at 127.0.0.1 / metadata is caught).
26
+ */
27
+ export declare function assertPublicUrl(url: string, config: Config): Promise<void>;
28
+ /**
29
+ * Wrap an egress-bound `fetch` with the SSRF guard. The returned fetch checks
30
+ * EVERY request URL (so it covers distilly's rule-rewritten requests too, not
31
+ * only webveil's own GET) before delegating to the underlying egress fetch.
32
+ * This is what `core.fetch()` injects into distilly. See docs/adr/0001.
33
+ */
34
+ export declare function guardEgressFetch(fetch: EgressFetch, config: Config): EgressFetch;
35
+ //# sourceMappingURL=security.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"security.d.ts","sourceRoot":"","sources":["../../src/core/security.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,aAAa,CAAC;AACxC,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,aAAa,CAAC;AAE7C,iFAAiF;AACjF,qBAAa,SAAU,SAAQ,KAAK;gBACvB,OAAO,EAAE,MAAM;CAI3B;AAOD;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAK/C;AAuCD;;;;;GAKG;AACH,wBAAsB,eAAe,CACpC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,GACZ,OAAO,CAAC,IAAI,CAAC,CAsBf;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC/B,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,MAAM,GACZ,WAAW,CAWb"}
@@ -0,0 +1,141 @@
1
+ // SSRF guard: the security seam wrapped AROUND the egress-bound `fetch`, so it
2
+ // covers BOTH webveil's own GETs AND distilly's rule-rewritten requests (see
3
+ // docs/adr/0001: the guard lives inside the egress fetch). Adapts the range
4
+ // classification + DNS-resolve approach of leing2021/pi-search's `security.ts`.
5
+ //
6
+ // THE RELAXATION RULE (load-bearing, recorded in the task's Decisions): the
7
+ // guard BLOCKS private/loopback/link-local/etc. addresses on DIRECT egress, and
8
+ // RELAXES ENTIRELY under a proxy egress (`http` | `socks5`). Tor/Mullvad
9
+ // legitimately reach private-looking addresses (e.g. `10.64.0.1`), AND a local
10
+ // DNS lookup for a proxied request would itself be a deanonymizing leak, so
11
+ // under a proxy we neither block nor resolve locally; the proxy owns egress.
12
+ import { lookup } from 'node:dns/promises';
13
+ import { isIP } from 'node:net';
14
+ /** Thrown when the SSRF guard refuses a request to a private/blocked address. */
15
+ export class SsrfError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = 'SsrfError';
19
+ }
20
+ }
21
+ /** A proxy egress owns egress + DNS, so the local SSRF guard relaxes for it. */
22
+ function egressIsProxy(config) {
23
+ return config.egress.mode === 'http' || config.egress.mode === 'socks5';
24
+ }
25
+ /**
26
+ * Is this LITERAL IP private / non-public? Covers the ranges that must never be
27
+ * reachable from a direct-egress web fetch:
28
+ * IPv4: 0.0.0.0/8, 10/8 (RFC1918), 127/8 (loopback), 169.254/16 (link-local,
29
+ * incl. the 169.254.169.254 cloud metadata endpoint), 172.16/12 (RFC1918),
30
+ * 192.168/16 (RFC1918), 100.64/10 (CGNAT), 192.0.0/24, 192.0.2/24,
31
+ * 198.18/15, 198.51.100/24, 203.0.113/24, 224/4 (multicast), 240/4
32
+ * (reserved).
33
+ * IPv6: ::1 (loopback), :: (unspecified), fc00::/7 (ULA), fe80::/10
34
+ * (link-local), ff00::/8 (multicast), plus IPv4-mapped (::ffff:a.b.c.d,
35
+ * re-checked as IPv4). Default-deny: anything outside global unicast
36
+ * (2000::/3) is treated as non-public.
37
+ */
38
+ export function isPrivateIp(ip) {
39
+ const kind = isIP(ip);
40
+ if (kind === 4)
41
+ return isPrivateIpv4(ip);
42
+ if (kind === 6)
43
+ return isPrivateIpv6(ip);
44
+ return false; // not a literal IP; hostname handling resolves it first
45
+ }
46
+ function isPrivateIpv4(ip) {
47
+ const parts = ip.split('.').map((p) => Number(p));
48
+ if (parts.length !== 4 ||
49
+ parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
50
+ return true; // malformed → treat as non-public (fail closed)
51
+ const [a, b] = parts;
52
+ if (a === 0 || a === 10 || a === 127)
53
+ return true;
54
+ if (a === 169 && b === 254)
55
+ return true; // link-local incl. cloud metadata
56
+ if (a === 172 && b >= 16 && b <= 31)
57
+ return true; // 172.16/12
58
+ if (a === 192 && b === 168)
59
+ return true; // 192.168/16
60
+ if (a === 100 && b >= 64 && b <= 127)
61
+ return true; // 100.64/10 CGNAT
62
+ if (a === 192 && b === 0)
63
+ return true; // 192.0.0/24 + 192.0.2/24
64
+ if (a === 198 && (b === 18 || b === 19))
65
+ return true; // 198.18/15 benchmark
66
+ if (a === 198 && b === 51)
67
+ return true; // 198.51.100/24 TEST-NET-2
68
+ if (a === 203 && b === 0)
69
+ return true; // 203.0.113/24 TEST-NET-3
70
+ if (a >= 224)
71
+ return true; // 224/4 multicast + 240/4 reserved
72
+ return false;
73
+ }
74
+ function isPrivateIpv6(ip) {
75
+ const lower = ip.toLowerCase();
76
+ if (lower === '::1' || lower === '::')
77
+ return true; // loopback / unspecified
78
+ // IPv4-mapped (::ffff:a.b.c.d): re-check the embedded IPv4.
79
+ const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
80
+ if (mapped)
81
+ return isPrivateIpv4(mapped[1]);
82
+ const head = lower.split(':')[0] ?? '';
83
+ const first = parseInt(head || '0', 16);
84
+ if (Number.isNaN(first))
85
+ return true; // fail closed on anything unparseable
86
+ if ((first & 0xfe00) === 0xfc00)
87
+ return true; // fc00::/7 ULA
88
+ if ((first & 0xffc0) === 0xfe80)
89
+ return true; // fe80::/10 link-local
90
+ if ((first & 0xff00) === 0xff00)
91
+ return true; // ff00::/8 multicast
92
+ // Default-deny: only global unicast 2000::/3 is public.
93
+ return (first & 0xe000) !== 0x2000;
94
+ }
95
+ /**
96
+ * Assert a URL is safe to fetch under THIS config's egress. Under a proxy egress
97
+ * it always passes (the proxy owns egress + DNS). Under direct egress it rejects
98
+ * a literal private IP and, for a hostname, resolves it locally and rejects if it
99
+ * maps to a private IP (so a name pointing at 127.0.0.1 / metadata is caught).
100
+ */
101
+ export async function assertPublicUrl(url, config) {
102
+ if (egressIsProxy(config))
103
+ return; // proxy owns egress + DNS; relax entirely
104
+ let parsed;
105
+ try {
106
+ parsed = new URL(url);
107
+ }
108
+ catch {
109
+ throw new SsrfError(`webveil SSRF: malformed url ${url}`);
110
+ }
111
+ const host = parsed.hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
112
+ if (isIP(host)) {
113
+ if (isPrivateIp(host))
114
+ throw new SsrfError(`webveil SSRF: blocked private address ${host}`);
115
+ return;
116
+ }
117
+ // A hostname: resolve it locally (safe on direct egress) and check every
118
+ // address it maps to, so a name pointing at a private IP is also blocked.
119
+ const addrs = await lookup(host, { all: true });
120
+ for (const { address } of addrs)
121
+ if (isPrivateIp(address))
122
+ throw new SsrfError(`webveil SSRF: ${host} resolves to private address ${address}`);
123
+ }
124
+ /**
125
+ * Wrap an egress-bound `fetch` with the SSRF guard. The returned fetch checks
126
+ * EVERY request URL (so it covers distilly's rule-rewritten requests too, not
127
+ * only webveil's own GET) before delegating to the underlying egress fetch.
128
+ * This is what `core.fetch()` injects into distilly. See docs/adr/0001.
129
+ */
130
+ export function guardEgressFetch(fetch, config) {
131
+ return (async (input, init) => {
132
+ const url = typeof input === 'string'
133
+ ? input
134
+ : input instanceof URL
135
+ ? input.href
136
+ : input.url;
137
+ await assertPublicUrl(url, config);
138
+ return fetch(input, init);
139
+ });
140
+ }
141
+ //# sourceMappingURL=security.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"security.js","sourceRoot":"","sources":["../../src/core/security.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ export { resolveConfig } from './core/config.js';
2
+ export type { Config, Egress, FetchSize, PartialConfig, ResolveOptions, } from './core/config.js';
3
+ export { buildDispatcher, createEgressFetch, EgressError, } from './core/egress.js';
4
+ export type { Dispatcher, EgressFetch } from './core/egress.js';
5
+ export { createHttp } from './core/http.js';
6
+ export { extract } from './core/extract.js';
7
+ export type { ExtractOptions, ExtractDeps } from './core/extract.js';
8
+ export { assertPublicUrl, guardEgressFetch, isPrivateIp, SsrfError, } from './core/security.js';
9
+ export type { Backend, Http, HttpRequestOptions, SearchResult, FetchResult, SearchOptions, FetchOptions, } from './core/backends/types.js';
10
+ export { backendNames, getBackend } from './core/backends/registry.js';
11
+ export type { BackendFactory } from './core/backends/registry.js';
12
+ export { createSearxngBackend } from './core/backends/searxng.js';
13
+ export { createTavilyCompatBackend } from './core/backends/tavily-compat.js';
14
+ export { createCustomBackend } from './core/backends/custom.js';
15
+ export type { SpawnFn } from './core/backends/custom.js';
16
+ export { search } from './core/search.js';
17
+ export type { SearchCoreOptions, SearchDeps } from './core/search.js';
18
+ export { fetch, fetchAll } from './core/fetch.js';
19
+ export type { FetchCoreOptions, FetchDeps } from './core/fetch.js';
20
+ export { createCli } from './cli.js';
21
+ export type { CliDeps } from './cli.js';
22
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAC,aAAa,EAAC,MAAM,kBAAkB,CAAC;AAC/C,YAAY,EACX,MAAM,EACN,MAAM,EACN,SAAS,EACT,aAAa,EACb,cAAc,GACd,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EACN,eAAe,EACf,iBAAiB,EACjB,WAAW,GACX,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EAAC,UAAU,EAAE,WAAW,EAAC,MAAM,kBAAkB,CAAC;AAG9D,OAAO,EAAC,UAAU,EAAC,MAAM,gBAAgB,CAAC;AAG1C,OAAO,EAAC,OAAO,EAAC,MAAM,mBAAmB,CAAC;AAC1C,YAAY,EAAC,cAAc,EAAE,WAAW,EAAC,MAAM,mBAAmB,CAAC;AAGnE,OAAO,EACN,eAAe,EACf,gBAAgB,EAChB,WAAW,EACX,SAAS,GACT,MAAM,oBAAoB,CAAC;AAG5B,YAAY,EACX,OAAO,EACP,IAAI,EACJ,kBAAkB,EAClB,YAAY,EACZ,WAAW,EACX,aAAa,EACb,YAAY,GACZ,MAAM,0BAA0B,CAAC;AAGlC,OAAO,EAAC,YAAY,EAAE,UAAU,EAAC,MAAM,6BAA6B,CAAC;AACrE,YAAY,EAAC,cAAc,EAAC,MAAM,6BAA6B,CAAC;AAChE,OAAO,EAAC,oBAAoB,EAAC,MAAM,4BAA4B,CAAC;AAChE,OAAO,EAAC,yBAAyB,EAAC,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAC,mBAAmB,EAAC,MAAM,2BAA2B,CAAC;AAC9D,YAAY,EAAC,OAAO,EAAC,MAAM,2BAA2B,CAAC;AAGvD,OAAO,EAAC,MAAM,EAAC,MAAM,kBAAkB,CAAC;AACxC,YAAY,EAAC,iBAAiB,EAAE,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAGpE,OAAO,EAAC,KAAK,EAAE,QAAQ,EAAC,MAAM,iBAAiB,CAAC;AAChD,YAAY,EAAC,gBAAgB,EAAE,SAAS,EAAC,MAAM,iBAAiB,CAAC;AAGjE,OAAO,EAAC,SAAS,EAAC,MAAM,UAAU,CAAC;AACnC,YAAY,EAAC,OAAO,EAAC,MAAM,UAAU,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,40 @@
1
+ // webveil — anonymous-capable, self-hosted, account-free web search + fetch for agents.
2
+ //
3
+ // This is the public surface. The framework-agnostic core lives under src/core:
4
+ // - core/config.ts : config seam (per-folder .pi/webveil.json + global + env)
5
+ // - core/egress.ts : egress seam (direct | http | socks5/Tor) — dispatcher + egress fetch
6
+ // - core/http.ts : the proxied `http` helper handed to backends
7
+ // - core/extract.ts : Extractor seam (distilly/fetch + injected egress fetch)
8
+ // - core/backends/types.ts : backend seam (the Backend interface + result shapes)
9
+ // - core/backends/registry.ts : name -> Backend dispatcher
10
+ // - core/backends/searxng.ts : the keyless self-hosted SearXNG backend
11
+ // - core/backends/tavily-compat.ts : the generic Tavily-shaped backend (/search + /extract)
12
+ // - core/search.ts : the framework-agnostic search() both frontends call
13
+ // - core/security.ts : SSRF guard wrapped around the egress fetch
14
+ // - core/fetch.ts : the framework-agnostic fetch() both frontends call
15
+ // - core/backends/custom.ts : the local-command escape hatch (JSON stdin/stdout)
16
+ // - cli.ts : the incur CLI + MCP frontend (the `webveil` bin)
17
+ // pi-webveil (sibling package) wraps the SAME core functions as registerTool
18
+ // web_search / web_fetch, in-process, as an Ollama drop-in.
19
+ // config seam
20
+ export { resolveConfig } from './core/config.js';
21
+ // egress seam
22
+ export { buildDispatcher, createEgressFetch, EgressError, } from './core/egress.js';
23
+ // http helper
24
+ export { createHttp } from './core/http.js';
25
+ // Extractor seam (distilly/fetch over webveil's egress)
26
+ export { extract } from './core/extract.js';
27
+ // SSRF guard (wrapped around the egress fetch; covers distilly's requests too)
28
+ export { assertPublicUrl, guardEgressFetch, isPrivateIp, SsrfError, } from './core/security.js';
29
+ // backend registry + implementations
30
+ export { backendNames, getBackend } from './core/backends/registry.js';
31
+ export { createSearxngBackend } from './core/backends/searxng.js';
32
+ export { createTavilyCompatBackend } from './core/backends/tavily-compat.js';
33
+ export { createCustomBackend } from './core/backends/custom.js';
34
+ // core search (the framework-agnostic search() both frontends call)
35
+ export { search } from './core/search.js';
36
+ // core fetch (the framework-agnostic fetch() + list-ready fetchAll internal)
37
+ export { fetch, fetchAll } from './core/fetch.js';
38
+ // incur CLI + MCP frontend (the `webveil` bin builds and serves this)
39
+ export { createCli } from './cli.js';
40
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,wFAAwF;AACxF,EAAE;AACF,gFAAgF;AAChF,2FAA2F;AAC3F,uGAAuG;AACvG,+EAA+E;AAC/E,0FAA0F;AAC1F,uFAAuF;AACvF,6DAA6D;AAC7D,0EAA0E;AAC1E,8FAA8F;AAC9F,sFAAsF;AACtF,6EAA6E;AAC7E,qFAAqF;AACrF,oFAAoF;AACpF,mFAAmF;AACnF,6EAA6E;AAC7E,4DAA4D;AAE5D,cAAc;AACd,OAAO,EAAC,aAAa,EAAC,MAAM,kBAAkB,CAAC;AAS/C,cAAc;AACd,OAAO,EACN,eAAe,EACf,iBAAiB,EACjB,WAAW,GACX,MAAM,kBAAkB,CAAC;AAG1B,cAAc;AACd,OAAO,EAAC,UAAU,EAAC,MAAM,gBAAgB,CAAC;AAE1C,wDAAwD;AACxD,OAAO,EAAC,OAAO,EAAC,MAAM,mBAAmB,CAAC;AAG1C,+EAA+E;AAC/E,OAAO,EACN,eAAe,EACf,gBAAgB,EAChB,WAAW,EACX,SAAS,GACT,MAAM,oBAAoB,CAAC;AAa5B,qCAAqC;AACrC,OAAO,EAAC,YAAY,EAAE,UAAU,EAAC,MAAM,6BAA6B,CAAC;AAErE,OAAO,EAAC,oBAAoB,EAAC,MAAM,4BAA4B,CAAC;AAChE,OAAO,EAAC,yBAAyB,EAAC,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAC,mBAAmB,EAAC,MAAM,2BAA2B,CAAC;AAG9D,oEAAoE;AACpE,OAAO,EAAC,MAAM,EAAC,MAAM,kBAAkB,CAAC;AAGxC,6EAA6E;AAC7E,OAAO,EAAC,KAAK,EAAE,QAAQ,EAAC,MAAM,iBAAiB,CAAC;AAGhD,sEAAsE;AACtE,OAAO,EAAC,SAAS,EAAC,MAAM,UAAU,CAAC"}
package/package.json CHANGED
@@ -1,4 +1,64 @@
1
1
  {
2
2
  "name": "webveil",
3
- "version": "0.0.0"
4
- }
3
+ "version": "0.1.0",
4
+ "description": "Anonymous-capable, self-hosted, account-free web search and fetch for agents. CLI + MCP (built on incur), pi-agnostic. Swappable backend and egress (direct, http proxy, socks5/Tor).",
5
+ "license": "AGPL-3.0-or-later",
6
+ "keywords": [
7
+ "web-search",
8
+ "web-fetch",
9
+ "mcp",
10
+ "cli",
11
+ "agents",
12
+ "searxng",
13
+ "tor",
14
+ "anonymous",
15
+ "self-hosted"
16
+ ],
17
+ "author": "wighawag",
18
+ "homepage": "https://github.com/wighawag/webveil/tree/main/packages/webveil#readme",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/wighawag/webveil.git",
22
+ "directory": "packages/webveil"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/wighawag/webveil/issues"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "type": "module",
31
+ "bin": {
32
+ "webveil": "./dist/cli.js"
33
+ },
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "default": "./dist/index.js"
38
+ }
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "src"
43
+ ],
44
+ "dependencies": {
45
+ "@modelcontextprotocol/server": "2.0.0-alpha.2",
46
+ "distilly": "^0.1.0",
47
+ "fetch-socks": "^1.3.3",
48
+ "incur": "0.4.10",
49
+ "undici": "^7.28.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^25.2.0",
53
+ "as-soon": "^0.1.5",
54
+ "tsx": "^4.21.0",
55
+ "typescript": "^5.3.3",
56
+ "vitest": "^4.0.18"
57
+ },
58
+ "scripts": {
59
+ "build": "tsc",
60
+ "dev": "as-soon -w src pnpm build",
61
+ "test": "vitest run",
62
+ "test:watch": "vitest"
63
+ }
64
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ // webveil — the incur-based CLI + MCP frontend. ONE `Cli.create()` definition
3
+ // yields the CLI, an MCP server (`--mcp`), skills (`skills add`), a `--llms`
4
+ // manifest, TOON output, and token pagination for free (incur). Pi-agnostic:
5
+ // any agent (pi via pi-mcp-adapter, Claude Code, Cursor, Codex, bash) consumes
6
+ // it the same way. The `webveil` bin points at the built `dist/cli.js`.
7
+ //
8
+ // This is the THIN frontend: each command only parses argv/options and calls
9
+ // the SAME framework-agnostic core (`search()` / `fetch()`) the pi extension
10
+ // calls. The core owns config/egress/backend/extraction; this file owns no
11
+ // network logic of its own.
12
+ //
13
+ // Testability: `createCli(deps)` takes the core functions as injectable deps so
14
+ // a test wires fakes and asserts the commands call the core (via `cli.serve`
15
+ // with custom argv/stdout) WITHOUT touching the network. The bottom of the file
16
+ // builds the real CLI and serves it when run as the bin.
17
+
18
+ import {argv} from 'node:process';
19
+ import {fileURLToPath} from 'node:url';
20
+ import {Cli, z} from 'incur';
21
+ import {search as coreSearch} from './core/search.js';
22
+ import {fetch as coreFetch} from './core/fetch.js';
23
+
24
+ /**
25
+ * The two core functions the frontend wraps, seamed so tests can inject fakes.
26
+ * Defaults are the real core; a test passes spies to assert the wiring.
27
+ */
28
+ export interface CliDeps {
29
+ search?: typeof coreSearch;
30
+ fetch?: typeof coreFetch;
31
+ }
32
+
33
+ /** The size presets `fetch` accepts, mirroring the core's `FetchSize`. */
34
+ const SIZES = ['s', 'm', 'l', 'f'] as const;
35
+
36
+ /**
37
+ * Build the webveil CLI. Returns the incur `Cli` so a caller (the bin below, or
38
+ * a test) decides how to serve it. The `search`/`fetch` commands forward to the
39
+ * injected core, normalizing nothing themselves — the core already deduped,
40
+ * clamped, and size-bounded.
41
+ */
42
+ export function createCli(deps: CliDeps = {}) {
43
+ const search = deps.search ?? coreSearch;
44
+ const fetch = deps.fetch ?? coreFetch;
45
+
46
+ return Cli.create('webveil', {
47
+ description:
48
+ 'Anonymous-capable, self-hosted, account-free web search + fetch for agents.',
49
+ })
50
+ .command('search', {
51
+ description: 'Search the web via the configured backend and egress.',
52
+ args: z.object({
53
+ query: z.string().describe('The search query'),
54
+ }),
55
+ options: z.object({
56
+ maxResults: z.coerce
57
+ .number()
58
+ .optional()
59
+ .describe('Maximum number of results to return'),
60
+ }),
61
+ alias: {maxResults: 'n'},
62
+ async run(c) {
63
+ const results = await search(c.args.query, {
64
+ maxResults: c.options.maxResults,
65
+ });
66
+ return {results};
67
+ },
68
+ })
69
+ .command('fetch', {
70
+ description:
71
+ 'Fetch a URL as clean, size-bounded markdown via the configured egress.',
72
+ args: z.object({
73
+ url: z.string().describe('The URL to fetch'),
74
+ }),
75
+ options: z.object({
76
+ size: z
77
+ .enum(SIZES)
78
+ .optional()
79
+ .describe('Page-size budget preset: s | m | l | f'),
80
+ }),
81
+ alias: {size: 's'},
82
+ async run(c) {
83
+ return fetch(c.args.url, {size: c.options.size});
84
+ },
85
+ });
86
+ }
87
+
88
+ // The real CLI (also `export default` so `incur gen` can import it for typed
89
+ // CTAs). Serving is GUARDED to the bin entry below, so importing this module in
90
+ // a test never consumes `process.argv` or exits the process.
91
+ const cli = createCli();
92
+
93
+ /** True when this module is the process entry (the `webveil` bin), not imported. */
94
+ function isMain(): boolean {
95
+ const entry = argv[1];
96
+ if (!entry) return false;
97
+ try {
98
+ return fileURLToPath(import.meta.url) === entry;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ if (isMain()) cli.serve();
105
+
106
+ export default cli;