@dreamtree-org/graphify 1.0.0 → 1.1.2

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.
package/README.md CHANGED
@@ -1,19 +1,27 @@
1
1
  # @dreamtree-org/graphify
2
2
 
3
- Turn a folder of code/docs/papers into a queryable, persistent knowledge graph for AI coding
4
- assistants. Ships as a CLI, a library, an installable agent skill, and an MCP server.
5
-
6
- > **Status: v1.** The full pipeline (`detect -> extract -> build -> cluster -> analyze -> report
7
- > -> export`), the CLI, the MCP server, and the `graphify install` skill are implemented and
8
- > tested. Structural (tree-sitter) extraction covers TypeScript/JavaScript (incl. JSX, CommonJS
9
- > `require()`/dynamic `import()`, decorators, generics), Python, Go, Rust, Java, C#, and Ruby
10
- > TypeScript/JavaScript is the most deeply covered of the seven. A few pieces are deliberately
11
- > deferred rather than faked — see [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the exact list
12
- > (Obsidian/GraphML/Neo4j export, `--update`/`--mode deep`, real cross-file resolution beyond an
13
- > import-aware heuristic, non-Claude-Code skill hosts). See
14
- > [`docs/MASTER-PROMPT.md`](./docs/MASTER-PROMPT.md) for the full spec this package was built
15
- > from, and [`ARCHITECTURE.md`](./ARCHITECTURE.md) / [`SECURITY.md`](./SECURITY.md) for the
16
- > architecture and threat model.
3
+ Turn a folder of code into a queryable, persistent knowledge graph and then use it the way
4
+ a coding agent actually needs to: get the working set for a task, the blast radius of a
5
+ change, the minimal tests to run, a structural review of a diff, and an architecture check —
6
+ each in one command, inside a token budget.
7
+
8
+ Works with any coding agent that can shell out or speak MCP (Claude Code, Cursor, Windsurf,
9
+ Cline, Codex, opencode, Amp, Gemini CLI, ...). No LLM API key required extraction is
10
+ tree-sitter AST parsing, deterministic and offline.
11
+
12
+ ## Why
13
+
14
+ Agents re-read codebases from scratch every session and burn most of their tokens finding
15
+ the right files. A graph fixes retrieval, but node lists alone still leave the agent to
16
+ chase files. graphify closes the loop: its answers are **task-shaped** — actual code
17
+ snippets, test lists, impact checklists, diffs — not just structure.
18
+
19
+ Token savings depend on the shape of your repo: graph answers have a roughly fixed cost
20
+ (they fill toward their token budget), so the win grows with how much code a naive file
21
+ read would have pulled in. Measured with `graphify benchmark`: **up to ~8x** on this
22
+ repository (~100 files, deep cross-file structure), closer to **~1.5x** on small,
23
+ tightly-organized libraries — and on a trivial question in a tiny repo, reading the file
24
+ directly can be cheaper. Run `graphify benchmark` on your own repo before quoting a number.
17
25
 
18
26
  ## Install
19
27
 
@@ -23,44 +31,111 @@ npm install -g @dreamtree-org/graphify
23
31
  npx @dreamtree-org/graphify .
24
32
  ```
25
33
 
26
- ## Usage
34
+ ## Build the graph
27
35
 
28
36
  ```bash
29
- graphify . # full pipeline on the current directory
30
- graphify <path> # full pipeline on a path
31
- graphify <github-url> # git clone --depth 1, then run the pipeline
32
- graphify <path> --no-viz # skip graph.html
33
- graphify <path> --cluster-only # rerun clustering on an existing graph.json
34
- graphify <path> --watch # rebuild on file change (chokidar, no LLM involved)
35
- graphify <path> --leiden # cluster with Leiden instead of Louvain (v2 — better community quality)
36
- graphify <path> --mcp # start the MCP stdio server instead of the pipeline
37
-
38
- graphify query "<question>" # BFS (or --dfs) traversal — broad context for a question
37
+ graphify . # full pipeline: detect -> extract -> build -> cluster -> analyze -> report -> export
38
+ graphify <path> # same, on a path
39
+ graphify <github-url> # git clone --depth 1, then the pipeline
40
+ graphify . --update # incremental — cached extractions for unchanged files (warm rebuilds ~3x faster)
41
+ graphify . --no-viz # skip graph.html
42
+ graphify . --leiden # Leiden instead of Louvain community detection
43
+ graphify . --watch # rebuild on file change
44
+ graphify . --mysql <dsn> # also extract a MySQL schema (mysql://user:pass@host:port/db) into the same graph
45
+ graphify . --mcp # start the MCP stdio server instead of running the pipeline
46
+ ```
47
+
48
+ Outputs land in `graphify-out/`: `graph.json` (GraphRAG-ready), `graph.html` (interactive,
49
+ fully offline), `GRAPH_REPORT.md` (plain-language audit: god nodes, surprises, open questions).
50
+
51
+ Languages: TypeScript/JavaScript (JSX, CommonJS, decorators, re-exports — deepest coverage),
52
+ Python, Go, Rust, Java, C#, Ruby, plus MySQL schemas. Cross-file references are resolved in a
53
+ whole-corpus pass, so imports land on real definitions, not guesses.
54
+
55
+ ## Ask the graph
56
+
57
+ ```bash
58
+ graphify context "<task>" # THE first call for any task: a token-budgeted pack of the actual
59
+ # code (real snippets at real line ranges), graph-ranked (--budget)
60
+ graphify query "<question>" # BFS/DFS traversal — fuzzy and identifier-aware: natural-language
61
+ # words match camelCase symbols, typos still resolve (--budget tokens)
62
+ graphify affected "<node>" # reverse impact analysis — everything that transitively depends on
63
+ # a node, grouped by depth, blast radius split by confidence
64
+ graphify tests "<node>" # minimal test files worth running for a change (static, no coverage
65
+ graphify tests --changed [rev] # instrumentation) — or for the whole working-tree diff
66
+ graphify review <base> [head] # structural review between two git revs: added/removed/rewired
67
+ # symbols, each with blast radius + suggested tests
68
+ graphify check # architecture guardrails from graphify.rules.json — exits 1 on
69
+ # violations, so agents and CI can gate on it
39
70
  graphify path "<A>" "<B>" # shortest path between two named nodes
40
- graphify explain "<node>" # plain-language explanation of a node
41
- graphify install # install the skill files into your local AI assistant(s)
71
+ graphify explain "<node>" # a node's location, community, and every edge in/out
72
+ graphify benchmark [q...] # measure the token savings on your own repo
42
73
  ```
43
74
 
44
- `--svg`/`--graphml`/`--neo4j`/`--obsidian`, `--update`, and `--mode deep` are accepted but not
45
- yet implemented in v1 (`--svg`/`--graphml`/`--neo4j`/`--obsidian` print a clear warning and are
46
- skipped; `--update` runs the full pipeline; `--mode deep` is a no-op) — see
47
- [`ARCHITECTURE.md`](./ARCHITECTURE.md#status--whats-real-vs-deliberately-deferred).
75
+ ## Close the loop
76
+
77
+ ```bash
78
+ graphify save-result --question Q --answer A --nodes id... --outcome useful|dead_end|corrected
79
+ graphify reflect # aggregate results into graphify-out/reflections/LESSONS.md
80
+ # (recency-weighted; agents read it at session start)
81
+ graphify hook install # auto-rebuild (incremental) after every commit and pull
82
+ graphify install --platform all # install the agent skill/rules: claude, cursor, windsurf, cline,
83
+ # agents (AGENTS.md: Codex/opencode/Amp), gemini
84
+ ```
85
+
86
+ With hooks installed the MCP server always serves a fresh graph — it re-reads `graph.json`
87
+ on every tool call.
88
+
89
+ ## Cross-project graphs
90
+
91
+ ```bash
92
+ graphify global add [path] # register a built project (name from package.json)
93
+ graphify global build # merge all registered projects into one global graph
94
+ graphify merge <dirA> <dirB> # one-shot merge (--out <dir>)
95
+ ```
96
+
97
+ Per-project node ids are namespaced; shared dependencies (`module:lodash`) stay unprefixed,
98
+ so repos that use the same packages are automatically bridged in the merged graph.
99
+
100
+ ## MCP server
101
+
102
+ `graphify --mcp` (or the `graphify-mcp` binary) exposes `context`, `query`, `path`,
103
+ `explain`, `affected`, and `tests` as MCP tools over stdio. No network listener.
104
+
105
+ ## Guardrails file
106
+
107
+ `graphify.rules.json` at the repo root (this repo dogfoods its own):
108
+
109
+ ```json
110
+ {
111
+ "rules": [
112
+ {
113
+ "name": "extractors-stay-cli-agnostic",
114
+ "from": "src/extractors/**",
115
+ "disallow": ["src/cli/**", "src/mcp/**"],
116
+ "reason": "language extractors are pure library code"
117
+ }
118
+ ]
119
+ }
120
+ ```
48
121
 
49
122
  ## What this does NOT do
50
123
 
51
124
  - No network listener — the MCP server is stdio-only.
52
- - No code execution from source content — extraction is AST parsing only, never `eval`/`exec`.
125
+ - No code execution from source content — AST parsing only, never `eval`/`exec`.
53
126
  - No `shell: true` / string-built shell commands.
54
- - No credential persistenceAPI keys are read from env vars per call.
127
+ - No credential leakageMySQL DSN credentials never appear in graph output, reports, or
128
+ logs (only the credential-free `mysql://host:port/db` form does).
55
129
 
56
- See [`SECURITY.md`](./SECURITY.md) for the full threat model.
130
+ Full threat model: [`docs/SECURITY.md`](./docs/SECURITY.md). Architecture and design notes:
131
+ [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md).
57
132
 
58
133
  ## Development
59
134
 
60
135
  ```bash
61
136
  npm install
62
137
  npm run build
63
- npm test
138
+ npm test # ~300 unit tests, no network, no side effects outside temp dirs
64
139
  npm run lint
65
140
  ```
66
141
 
@@ -0,0 +1,207 @@
1
+ // src/security.ts
2
+ import { createHash } from "crypto";
3
+ import * as dns from "dns";
4
+ import * as http from "http";
5
+ import * as https from "https";
6
+ import * as fs from "fs";
7
+ import * as net from "net";
8
+ import * as nodePath from "path";
9
+ var ALLOWED_SCHEMES = /* @__PURE__ */ new Set(["http:", "https:"]);
10
+ var MAX_FETCH_BYTES = 50 * 1024 * 1024;
11
+ var MAX_TEXT_BYTES = 10 * 1024 * 1024;
12
+ var MAX_LABEL_LEN = 256;
13
+ var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
14
+ "metadata.google.internal",
15
+ "metadata.internal",
16
+ "metadata",
17
+ "metadata.azure.com",
18
+ "instance-data",
19
+ "instance-data.ec2.internal"
20
+ ]);
21
+ function ipv4ToInt(ip) {
22
+ const parts = ip.split(".");
23
+ if (parts.length !== 4) return null;
24
+ let result = 0;
25
+ for (const part of parts) {
26
+ if (!/^\d{1,3}$/.test(part)) return null;
27
+ const n = Number(part);
28
+ if (n > 255) return null;
29
+ result = result << 8 | n;
30
+ }
31
+ return result >>> 0;
32
+ }
33
+ function ipv4InCidr(ipInt, cidr) {
34
+ const [base, prefixStr] = cidr.split("/");
35
+ const prefix = Number(prefixStr);
36
+ const baseInt = ipv4ToInt(base ?? "");
37
+ if (baseInt === null) return false;
38
+ const mask = prefix === 0 ? 0 : ~0 << 32 - prefix >>> 0;
39
+ return (ipInt & mask) >>> 0 === (baseInt & mask) >>> 0;
40
+ }
41
+ var BLOCKED_IPV4_CIDRS = [
42
+ "0.0.0.0/8",
43
+ // "this" network
44
+ "10.0.0.0/8",
45
+ // private
46
+ "100.64.0.0/10",
47
+ // carrier-grade NAT (CGN)
48
+ "127.0.0.0/8",
49
+ // loopback
50
+ "169.254.0.0/16",
51
+ // link-local (covers 169.254.169.254 cloud metadata)
52
+ "172.16.0.0/12",
53
+ // private
54
+ "192.0.0.0/24",
55
+ // IETF protocol assignments
56
+ "192.0.2.0/24",
57
+ // TEST-NET-1
58
+ "192.168.0.0/16",
59
+ // private
60
+ "198.18.0.0/15",
61
+ // benchmarking
62
+ "198.51.100.0/24",
63
+ // TEST-NET-2
64
+ "203.0.113.0/24",
65
+ // TEST-NET-3
66
+ "224.0.0.0/4",
67
+ // multicast
68
+ "240.0.0.0/4",
69
+ // reserved
70
+ "255.255.255.255/32"
71
+ // broadcast
72
+ ];
73
+ function isForbiddenIpv4(ip) {
74
+ const ipInt = ipv4ToInt(ip);
75
+ if (ipInt === null) return true;
76
+ return BLOCKED_IPV4_CIDRS.some((cidr) => ipv4InCidr(ipInt, cidr));
77
+ }
78
+ function expandIpv6(ip) {
79
+ const withoutZone = ip.split("%")[0] ?? "";
80
+ const parts = withoutZone.split("::");
81
+ if (parts.length > 2) return null;
82
+ const head = parts[0] ? parts[0].split(":").filter((s) => s.length > 0) : [];
83
+ const tail = parts.length === 2 && parts[1] ? parts[1].split(":").filter((s) => s.length > 0) : [];
84
+ let missing = 0;
85
+ if (parts.length === 2) {
86
+ missing = 8 - head.length - tail.length;
87
+ if (missing < 0) return null;
88
+ } else if (head.length + tail.length !== 8) {
89
+ return null;
90
+ }
91
+ const groupsHex = [...head, ...Array(missing).fill("0"), ...tail];
92
+ if (groupsHex.length !== 8) return null;
93
+ const groups = [];
94
+ for (const g of groupsHex) {
95
+ if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null;
96
+ groups.push(parseInt(g, 16));
97
+ }
98
+ return groups;
99
+ }
100
+ function isForbiddenIpv6(ip) {
101
+ const lower = ip.toLowerCase();
102
+ if (lower === "::1" || lower === "::") return true;
103
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
104
+ if (mapped) return isForbiddenIpv4(mapped[1]);
105
+ const groups = expandIpv6(lower);
106
+ if (groups === null) return true;
107
+ const first = groups[0];
108
+ if ((first & 65472) === 65152) return true;
109
+ if ((first & 65024) === 64512) return true;
110
+ if ((first & 65280) === 65280) return true;
111
+ if (groups.every((g) => g === 0)) return true;
112
+ return false;
113
+ }
114
+ function isForbiddenIp(ip) {
115
+ return net.isIPv6(ip) ? isForbiddenIpv6(ip) : isForbiddenIpv4(ip);
116
+ }
117
+ function validateUrl(url) {
118
+ let parsed;
119
+ try {
120
+ parsed = new URL(url);
121
+ } catch {
122
+ throw new Error(`Invalid URL: ${url}`);
123
+ }
124
+ if (!ALLOWED_SCHEMES.has(parsed.protocol)) {
125
+ throw new Error(
126
+ `URL scheme not allowed: "${parsed.protocol}" (allowed: ${[...ALLOWED_SCHEMES].join(", ")})`
127
+ );
128
+ }
129
+ const hostname = parsed.hostname.toLowerCase();
130
+ if (BLOCKED_HOSTNAMES.has(hostname)) {
131
+ throw new Error(`URL targets a blocked host: ${hostname}`);
132
+ }
133
+ const bareHost = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
134
+ if (net.isIP(bareHost) && isForbiddenIp(bareHost)) {
135
+ throw new Error(`URL targets a forbidden IP address: ${bareHost}`);
136
+ }
137
+ return parsed.toString();
138
+ }
139
+ var DEFAULT_MYSQL_PORT = 3306;
140
+ function validateDsn(dsn) {
141
+ let parsed;
142
+ try {
143
+ parsed = new URL(dsn);
144
+ } catch {
145
+ throw new Error("Invalid DSN \u2014 expected mysql://user:pass@host:port/database");
146
+ }
147
+ if (parsed.protocol !== "mysql:") {
148
+ throw new Error(`DSN scheme not supported: "${parsed.protocol}" (only mysql: is supported)`);
149
+ }
150
+ const database = decodeURIComponent(parsed.pathname.replace(/^\//, ""));
151
+ if (!database || database.includes("/")) {
152
+ throw new Error("DSN must name exactly one database, e.g. mysql://localhost:3306/mydb");
153
+ }
154
+ if (!parsed.hostname) {
155
+ throw new Error("DSN must include a host, e.g. mysql://localhost:3306/mydb");
156
+ }
157
+ const port = parsed.port ? Number(parsed.port) : DEFAULT_MYSQL_PORT;
158
+ return {
159
+ safeDisplay: `mysql://${parsed.hostname}:${port}/${database}`,
160
+ connection: {
161
+ host: parsed.hostname,
162
+ port,
163
+ user: decodeURIComponent(parsed.username) || "root",
164
+ password: decodeURIComponent(parsed.password),
165
+ database
166
+ }
167
+ };
168
+ }
169
+ function validateGraphPath(path, base) {
170
+ const baseDir = base ?? nodePath.join(process.cwd(), "graphify-out");
171
+ let resolvedBase;
172
+ try {
173
+ resolvedBase = fs.realpathSync(baseDir);
174
+ } catch {
175
+ throw new Error(`Base directory does not exist: ${baseDir}`);
176
+ }
177
+ const candidate = nodePath.isAbsolute(path) ? path : nodePath.join(resolvedBase, path);
178
+ const resolvedCandidate = nodePath.resolve(candidate);
179
+ let realCandidate = resolvedCandidate;
180
+ try {
181
+ realCandidate = fs.realpathSync(resolvedCandidate);
182
+ } catch {
183
+ }
184
+ const relative2 = nodePath.relative(resolvedBase, realCandidate);
185
+ const escapes = relative2 === ".." || relative2.startsWith(`..${nodePath.sep}`) || nodePath.isAbsolute(relative2);
186
+ if (escapes) {
187
+ throw new Error(`Path escapes graphify-out/: ${path}`);
188
+ }
189
+ return realCandidate;
190
+ }
191
+ function sanitizeLabel(text) {
192
+ if (text == null) return "";
193
+ const stripped = String(text).replace(/[\x00-\x1f\x7f]/g, "");
194
+ return stripped.slice(0, MAX_LABEL_LEN);
195
+ }
196
+ function escapeHtml(text) {
197
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
198
+ }
199
+
200
+ export {
201
+ validateUrl,
202
+ validateDsn,
203
+ validateGraphPath,
204
+ sanitizeLabel,
205
+ escapeHtml
206
+ };
207
+ //# sourceMappingURL=chunk-6JLEILYF.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/security.ts"],"sourcesContent":["/**\n * Security helpers — URL validation, SSRF-guarded fetch, path guards, label\n * sanitization, prompt-injection defenses. Every control listed in\n * SECURITY.md must have a corresponding function here and a unit test in\n * tests/security.test.ts. See the master prompt §5 for the full checklist.\n */\n\nimport { createHash } from 'node:crypto';\nimport * as dns from 'node:dns';\nimport * as http from 'node:http';\nimport * as https from 'node:https';\nimport * as fs from 'node:fs';\nimport * as net from 'node:net';\nimport * as nodePath from 'node:path';\n\nconst ALLOWED_SCHEMES = new Set(['http:', 'https:']);\nconst MAX_FETCH_BYTES = 50 * 1024 * 1024;\nconst MAX_TEXT_BYTES = 10 * 1024 * 1024;\nconst MAX_LABEL_LEN = 256;\nconst MAX_REDIRECTS = 5;\nconst REQUEST_TIMEOUT_MS = 15_000;\n\n/** Hostnames that must never be reachable, regardless of what they resolve to. */\nconst BLOCKED_HOSTNAMES = new Set([\n 'metadata.google.internal',\n 'metadata.internal',\n 'metadata',\n 'metadata.azure.com',\n 'instance-data',\n 'instance-data.ec2.internal',\n]);\n\n// ---------------------------------------------------------------------------\n// IP range checks (SSRF guard core). Implemented without extra dependencies.\n// ---------------------------------------------------------------------------\n\nfunction ipv4ToInt(ip: string): number | null {\n const parts = ip.split('.');\n if (parts.length !== 4) return null;\n let result = 0;\n for (const part of parts) {\n if (!/^\\d{1,3}$/.test(part)) return null;\n const n = Number(part);\n if (n > 255) return null;\n result = (result << 8) | n;\n }\n return result >>> 0;\n}\n\nfunction ipv4InCidr(ipInt: number, cidr: string): boolean {\n const [base, prefixStr] = cidr.split('/');\n const prefix = Number(prefixStr);\n const baseInt = ipv4ToInt(base ?? '');\n if (baseInt === null) return false;\n const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;\n return (ipInt & mask) >>> 0 === (baseInt & mask) >>> 0;\n}\n\n/** IPv4 ranges that must never be connected to from a server-side fetch. */\nconst BLOCKED_IPV4_CIDRS = [\n '0.0.0.0/8', // \"this\" network\n '10.0.0.0/8', // private\n '100.64.0.0/10', // carrier-grade NAT (CGN)\n '127.0.0.0/8', // loopback\n '169.254.0.0/16', // link-local (covers 169.254.169.254 cloud metadata)\n '172.16.0.0/12', // private\n '192.0.0.0/24', // IETF protocol assignments\n '192.0.2.0/24', // TEST-NET-1\n '192.168.0.0/16', // private\n '198.18.0.0/15', // benchmarking\n '198.51.100.0/24', // TEST-NET-2\n '203.0.113.0/24', // TEST-NET-3\n '224.0.0.0/4', // multicast\n '240.0.0.0/4', // reserved\n '255.255.255.255/32', // broadcast\n];\n\nexport function isForbiddenIpv4(ip: string): boolean {\n const ipInt = ipv4ToInt(ip);\n if (ipInt === null) return true; // malformed -> treat as forbidden, fail closed\n return BLOCKED_IPV4_CIDRS.some((cidr) => ipv4InCidr(ipInt, cidr));\n}\n\n/** Expand a (possibly `::`-compressed) IPv6 address into 8 16-bit groups. */\nfunction expandIpv6(ip: string): number[] | null {\n const withoutZone = ip.split('%')[0] ?? '';\n const parts = withoutZone.split('::');\n if (parts.length > 2) return null;\n\n const head = parts[0] ? parts[0].split(':').filter((s) => s.length > 0) : [];\n const tail = parts.length === 2 && parts[1] ? parts[1].split(':').filter((s) => s.length > 0) : [];\n\n let missing = 0;\n if (parts.length === 2) {\n missing = 8 - head.length - tail.length;\n if (missing < 0) return null;\n } else if (head.length + tail.length !== 8) {\n return null;\n }\n\n const groupsHex = [...head, ...Array(missing).fill('0'), ...tail];\n if (groupsHex.length !== 8) return null;\n\n const groups: number[] = [];\n for (const g of groupsHex) {\n if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null;\n groups.push(parseInt(g, 16));\n }\n return groups;\n}\n\nexport function isForbiddenIpv6(ip: string): boolean {\n const lower = ip.toLowerCase();\n if (lower === '::1' || lower === '::') return true;\n\n const mapped = /^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/.exec(lower);\n if (mapped) return isForbiddenIpv4(mapped[1] as string);\n\n const groups = expandIpv6(lower);\n if (groups === null) return true; // unparsable -> fail closed\n const first = groups[0] as number;\n if ((first & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local\n if ((first & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local\n if ((first & 0xff00) === 0xff00) return true; // ff00::/8 multicast\n if (groups.every((g) => g === 0)) return true; // unspecified/all-zero\n return false;\n}\n\nexport function isForbiddenIp(ip: string): boolean {\n return net.isIPv6(ip) ? isForbiddenIpv6(ip) : isForbiddenIpv4(ip);\n}\n\n// ---------------------------------------------------------------------------\n// URL validation\n// ---------------------------------------------------------------------------\n\n/**\n * Validate a URL is http/https and, when the hostname is itself a literal\n * IP or a known cloud-metadata hostname, reject it synchronously. DNS-bound\n * hostnames are re-validated at connect time by safeFetch() (see below) —\n * this function alone cannot rule out DNS rebinding for a hostname that\n * currently resolves to a public IP.\n */\nexport function validateUrl(url: string): string {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(`Invalid URL: ${url}`);\n }\n\n if (!ALLOWED_SCHEMES.has(parsed.protocol)) {\n throw new Error(\n `URL scheme not allowed: \"${parsed.protocol}\" (allowed: ${[...ALLOWED_SCHEMES].join(', ')})`,\n );\n }\n\n const hostname = parsed.hostname.toLowerCase();\n if (BLOCKED_HOSTNAMES.has(hostname)) {\n throw new Error(`URL targets a blocked host: ${hostname}`);\n }\n\n // WHATWG URL keeps the brackets on an IPv6 literal host (e.g. \"[::1]\") —\n // strip them before checking net.isIP()/isForbiddenIp().\n const bareHost =\n hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;\n if (net.isIP(bareHost) && isForbiddenIp(bareHost)) {\n throw new Error(`URL targets a forbidden IP address: ${bareHost}`);\n }\n\n return parsed.toString();\n}\n\n// ---------------------------------------------------------------------------\n// safeFetch — SSRF-guarded, size-capped, redirect-revalidating fetch\n// ---------------------------------------------------------------------------\n\nexport type LookupFn = (hostname: string) => Promise<{ address: string; family: number }>;\n\n/**\n * Resolve a hostname (or pass through a literal IP) and validate the\n * result. `lookup` defaults to the real `dns.promises.lookup` — tests\n * substitute a fake to exercise the rebind guard deterministically without\n * making a real DNS query.\n */\nexport async function resolveAndValidate(\n hostname: string,\n lookup: LookupFn = dns.promises.lookup,\n): Promise<{ address: string; family: number }> {\n if (net.isIP(hostname)) {\n if (isForbiddenIp(hostname)) {\n throw new Error(`Refusing to connect to forbidden IP: ${hostname}`);\n }\n return { address: hostname, family: net.isIPv6(hostname) ? 6 : 4 };\n }\n const result = await lookup(hostname);\n if (isForbiddenIp(result.address)) {\n throw new Error(`Refusing to connect to forbidden IP: ${hostname} -> ${result.address}`);\n }\n return result;\n}\n\nexport interface RawResponse {\n statusCode: number;\n headers: http.IncomingHttpHeaders;\n body: Buffer;\n}\n\n/** Minimal shape of the `http`/`https` modules that requestOnce() needs — narrow on purpose so tests can substitute a fake transport instead of mocking network I/O. */\nexport type RequestTransport = Pick<typeof http, 'request'>;\n\n/**\n * Issue a single HTTP(S) request, connecting to `resolvedAddress` directly\n * (via a `lookup` override) rather than re-resolving DNS at connect time —\n * this is what prevents a DNS-rebind attacker from swapping the target\n * between validation and connection (TOCTOU). Streams the body and aborts\n * once it exceeds the byte cap for its declared content type.\n */\nexport function requestOnce(\n target: URL,\n resolvedAddress: string,\n resolvedFamily: number,\n transport: RequestTransport = target.protocol === 'https:' ? https : http,\n): Promise<RawResponse> {\n return new Promise((resolve, reject) => {\n const req = transport.request(\n {\n protocol: target.protocol,\n hostname: target.hostname,\n host: target.hostname,\n port: target.port || (target.protocol === 'https:' ? 443 : 80),\n path: `${target.pathname}${target.search}`,\n method: 'GET',\n timeout: REQUEST_TIMEOUT_MS,\n headers: { 'user-agent': 'graphify/0.1', accept: '*/*' },\n // Force the connection to the address we already validated — do not\n // let Node re-resolve `target.hostname` at connect time.\n lookup: (\n _hostname: string,\n options: unknown,\n callback: (err: NodeJS.ErrnoException | null, address: string, family: number) => void,\n ) => {\n callback(null, resolvedAddress, resolvedFamily);\n },\n } as http.RequestOptions,\n (res) => {\n const statusCode = res.statusCode ?? 0;\n const contentType = String(res.headers['content-type'] ?? '');\n const isTextual = /^text\\/|json|xml|html|charset=/i.test(contentType);\n const cap = isTextual ? MAX_TEXT_BYTES : MAX_FETCH_BYTES;\n\n const chunks: Buffer[] = [];\n let total = 0;\n let aborted = false;\n\n res.on('data', (chunk: Buffer) => {\n total += chunk.length;\n if (total > cap) {\n aborted = true;\n res.destroy();\n req.destroy();\n reject(new Error(`Response exceeded ${cap} byte cap (content-type: ${contentType})`));\n return;\n }\n chunks.push(chunk);\n });\n res.on('end', () => {\n if (aborted) return;\n resolve({ statusCode, headers: res.headers, body: Buffer.concat(chunks) });\n });\n res.on('error', (err) => {\n if (!aborted) reject(err);\n });\n },\n );\n\n req.on('timeout', () => {\n req.destroy(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`));\n });\n req.on('error', reject);\n req.end();\n });\n}\n\nexport interface SafeFetchDeps {\n /** Override DNS resolution + IP validation (default: resolveAndValidate). */\n resolve?: (hostname: string) => Promise<{ address: string; family: number }>;\n /** Override the actual request (default: requestOnce against real http/https). */\n request?: (target: URL, address: string, family: number) => Promise<RawResponse>;\n}\n\n/**\n * Fetch a URL with the SSRF guards from validateUrl() plus streaming size\n * caps (MAX_FETCH_BYTES / MAX_TEXT_BYTES) and a hard error on non-2xx\n * status. Every redirect hop is independently validated and re-resolved —\n * a redirect to a private/loopback/metadata address is rejected exactly\n * like a direct request would be.\n *\n * `deps` exists so tests can substitute the DNS/network collaborators with\n * deterministic fakes (see tests/security.test.ts) instead of mocking\n * Node's core modules or hitting real sockets — production callers should\n * never need to pass it.\n */\nexport async function safeFetch(url: string, deps: SafeFetchDeps = {}): Promise<Buffer> {\n const resolve = deps.resolve ?? resolveAndValidate;\n const doRequest = deps.request ?? requestOnce;\n\n let current = validateUrl(url);\n for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {\n const target = new URL(current);\n const { address, family } = await resolve(target.hostname);\n const response = await doRequest(target, address, family);\n\n if (response.statusCode >= 300 && response.statusCode < 400) {\n const location = response.headers.location;\n if (!location) {\n throw new Error(`Redirect response (${response.statusCode}) missing Location header`);\n }\n const next = new URL(location, target).toString();\n current = validateUrl(next);\n continue;\n }\n\n if (response.statusCode < 200 || response.statusCode >= 300) {\n throw new Error(`Non-2xx response: ${response.statusCode}`);\n }\n\n return response.body;\n }\n throw new Error(`Too many redirects (> ${MAX_REDIRECTS}) while fetching ${url}`);\n}\n\n// ---------------------------------------------------------------------------\n// Database DSN validation (MySQL schema extraction)\n// ---------------------------------------------------------------------------\n\nexport interface ValidatedDsn {\n /**\n * Credential-free rendering (`mysql://host:port/db`). This is the ONLY\n * form that may ever appear in graph nodes, reports, logs, or errors —\n * the password exists solely inside `connection`.\n */\n safeDisplay: string;\n connection: {\n host: string;\n port: number;\n user: string;\n password: string;\n database: string;\n };\n}\n\nconst DEFAULT_MYSQL_PORT = 3306;\n\n/**\n * Parse and validate a `mysql://user:pass@host:port/database` DSN.\n *\n * Deliberately does NOT apply the SSRF IP blocklist that safeFetch()\n * enforces: connecting to a localhost/private-network database is the\n * primary legitimate use of an explicitly user-supplied DSN, unlike a URL\n * scraped out of corpus content. The security property that matters here\n * is credential containment — see ValidatedDsn.safeDisplay.\n */\nexport function validateDsn(dsn: string): ValidatedDsn {\n let parsed: URL;\n try {\n parsed = new URL(dsn);\n } catch {\n throw new Error('Invalid DSN — expected mysql://user:pass@host:port/database');\n }\n\n if (parsed.protocol !== 'mysql:') {\n throw new Error(`DSN scheme not supported: \"${parsed.protocol}\" (only mysql: is supported)`);\n }\n\n const database = decodeURIComponent(parsed.pathname.replace(/^\\//, ''));\n if (!database || database.includes('/')) {\n throw new Error('DSN must name exactly one database, e.g. mysql://localhost:3306/mydb');\n }\n if (!parsed.hostname) {\n throw new Error('DSN must include a host, e.g. mysql://localhost:3306/mydb');\n }\n\n const port = parsed.port ? Number(parsed.port) : DEFAULT_MYSQL_PORT;\n return {\n safeDisplay: `mysql://${parsed.hostname}:${port}/${database}`,\n connection: {\n host: parsed.hostname,\n port,\n user: decodeURIComponent(parsed.username) || 'root',\n password: decodeURIComponent(parsed.password),\n database,\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// Path traversal guard\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve `path` and require it stays inside `base` (defaults to\n * `<cwd>/graphify-out`). `base` must already exist. Throws on traversal\n * attempts (`../`, absolute escapes, symlink-resolved escapes).\n */\nexport function validateGraphPath(path: string, base?: string): string {\n const baseDir = base ?? nodePath.join(process.cwd(), 'graphify-out');\n\n let resolvedBase: string;\n try {\n resolvedBase = fs.realpathSync(baseDir);\n } catch {\n throw new Error(`Base directory does not exist: ${baseDir}`);\n }\n\n const candidate = nodePath.isAbsolute(path) ? path : nodePath.join(resolvedBase, path);\n const resolvedCandidate = nodePath.resolve(candidate);\n\n // Resolve symlinks for whichever is the deepest existing ancestor, so a\n // symlink inside an otherwise-valid path can't escape the base dir either.\n let realCandidate = resolvedCandidate;\n try {\n realCandidate = fs.realpathSync(resolvedCandidate);\n } catch {\n // Path (or part of it) may not exist yet (e.g. a file we're about to\n // write) — fall back to the lexically-resolved path for the check.\n }\n\n const relative = nodePath.relative(resolvedBase, realCandidate);\n const escapes = relative === '..' || relative.startsWith(`..${nodePath.sep}`) || nodePath.isAbsolute(relative);\n if (escapes) {\n throw new Error(`Path escapes graphify-out/: ${path}`);\n }\n\n return realCandidate;\n}\n\n// ---------------------------------------------------------------------------\n// Label sanitization (unchanged reference implementation) + HTML escaping\n// ---------------------------------------------------------------------------\n\n/** Strip control characters, cap length. Apply to every node/edge label. */\nexport function sanitizeLabel(text: string | null | undefined): string {\n if (text == null) return '';\n // eslint-disable-next-line no-control-regex -- intentional: stripping raw control chars\n const stripped = String(text).replace(/[\\x00-\\x1f\\x7f]/g, '');\n return stripped.slice(0, MAX_LABEL_LEN);\n}\n\n/**\n * HTML-escape a string. Callers must run sanitizeLabel() first for\n * length/control-char stripping, then escapeHtml() immediately before\n * embedding into graph.html (XSS control, see SECURITY.md).\n */\nexport function escapeHtml(text: string): string {\n return text\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\n}\n\n// ---------------------------------------------------------------------------\n// Prompt-injection defenses for LLM-bound file content\n// ---------------------------------------------------------------------------\n\n/**\n * Known jailbreak / chat-template sentinels that must never reach a prompt\n * unescaped, whether they occur in source files or are forged to spoof our\n * own <untrusted_source> delimiter.\n */\nconst SENTINEL_PATTERNS: RegExp[] = [\n /<\\|im_start\\|>/gi,\n /<\\|im_end\\|>/gi,\n /<\\|system\\|>/gi,\n /\\[INST\\]/gi,\n /\\[\\/INST\\]/gi,\n /<<SYS>>/gi,\n /<<\\/SYS>>/gi,\n /<\\/untrusted_source>/gi,\n /<untrusted_source/gi,\n];\n\n/**\n * Render brackets as fullwidth lookalikes (< > [ ]) so the defanged\n * echo is human-visible but no longer byte-identical to the original\n * sentinel — a naive downstream scan for the literal delimiter/sentinel\n * text will not re-match our own \"we found one\" marker.\n */\nfunction toFullwidthBrackets(text: string): string {\n return text\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\\[/g, '[')\n .replace(/\\]/g, ']');\n}\n\n/**\n * Replace known jailbreak/chat-template sentinels with a visibly defanged\n * form — never silently stripped (silent stripping can itself be an\n * injection vector if it changes meaning unexpectedly).\n */\nfunction defangSentinels(content: string): string {\n let result = content;\n for (const pattern of SENTINEL_PATTERNS) {\n result = result.replace(pattern, (match) => `[DEFANGED:${toFullwidthBrackets(match)}]`);\n }\n return result;\n}\n\n/**\n * Wrap file content for LLM prompts in a hash-stamped untrusted-source\n * delimiter and neutralize known jailbreak/chat-template sentinels. This\n * raises the bar against prompt injection; it does not eliminate it —\n * document that plainly wherever this is referenced.\n */\nexport function wrapUntrustedSource(path: string, content: string): string {\n const sha256 = createHash('sha256').update(content, 'utf8').digest('hex');\n const safePath = escapeHtml(sanitizeLabel(path));\n const defanged = defangSentinels(content);\n return `<untrusted_source path=\"${safePath}\" sha256=\"${sha256}\">\\n${defanged}\\n</untrusted_source>`;\n}\n"],"mappings":"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","names":["relative"]}