Skip to content

Commit 92b70ce

Browse files
committed
fix: sanitize_label double-encoding and --wiki missing from skill (#66, #55)
1 parent 3d53287 commit 92b70ce

7 files changed

Lines changed: 49 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
44

5+
## 0.3.12 (2026-04-07)
6+
7+
- Fix: `sanitize_label` was double-encoding HTML entities in the interactive graph (`<` instead of `<`) — removed `html.escape()` from `sanitize_label`; callers that inject directly into HTML now call `html.escape()` themselves (#66)
8+
- Fix: `--wiki` flag missing from `skill.md` usage table (#55)
9+
510
## 0.3.11 (2026-04-07)
611

712
- Fix: Louvain fallback hangs indefinitely on large sparse graphs — added `max_level=10, threshold=1e-4` to prevent infinite loops while preserving community quality (#48)

graphify/__main__.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,9 @@ def main() -> None:
382382
if len(sys.argv) < 3:
383383
print("Usage: graphify query \"<question>\" [--dfs] [--budget N] [--graph path]", file=sys.stderr)
384384
sys.exit(1)
385-
from graphify.serve import _load_graph, _score_nodes, _bfs, _dfs, _subgraph_to_text
385+
from graphify.serve import _score_nodes, _bfs, _dfs, _subgraph_to_text
386+
from graphify.security import sanitize_label
387+
from networkx.readwrite import json_graph
386388
question = sys.argv[2]
387389
use_dfs = "--dfs" in sys.argv
388390
budget = 2000
@@ -391,14 +393,39 @@ def main() -> None:
391393
i = 0
392394
while i < len(args):
393395
if args[i] == "--budget" and i + 1 < len(args):
394-
budget = int(args[i + 1]); i += 2
396+
try:
397+
budget = int(args[i + 1])
398+
except ValueError:
399+
print(f"error: --budget must be an integer", file=sys.stderr)
400+
sys.exit(1)
401+
i += 2
395402
elif args[i].startswith("--budget="):
396-
budget = int(args[i].split("=", 1)[1]); i += 1
403+
try:
404+
budget = int(args[i].split("=", 1)[1])
405+
except ValueError:
406+
print(f"error: --budget must be an integer", file=sys.stderr)
407+
sys.exit(1)
408+
i += 1
397409
elif args[i] == "--graph" and i + 1 < len(args):
398410
graph_path = args[i + 1]; i += 2
399411
else:
400412
i += 1
401-
G = _load_graph(graph_path)
413+
# Load graph directly — validate_graph_path restricts to graphify-out/
414+
# so for custom --graph paths we resolve and load directly after existence check
415+
gp = Path(graph_path).resolve()
416+
if not gp.exists():
417+
print(f"error: graph file not found: {gp}", file=sys.stderr)
418+
sys.exit(1)
419+
if not gp.suffix == ".json":
420+
print(f"error: graph file must be a .json file", file=sys.stderr)
421+
sys.exit(1)
422+
try:
423+
import json as _json
424+
import networkx as _nx
425+
G = json_graph.node_link_graph(_json.loads(gp.read_text(encoding="utf-8")), edges="links")
426+
except Exception as exc:
427+
print(f"error: could not load graph: {exc}", file=sys.stderr)
428+
sys.exit(1)
402429
terms = [t.lower() for t in question.split() if len(t) > 2]
403430
scored = _score_nodes(G, terms)
404431
if not scored:

graphify/export.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# write graph to HTML, JSON, SVG, GraphML, Obsidian vault, and Neo4j Cypher
22
from __future__ import annotations
3+
import html as _html
34
import json
45
import math
56
import re
@@ -371,7 +372,7 @@ def to_html(
371372
edges_json = json.dumps(vis_edges)
372373
legend_json = json.dumps(legend_data)
373374
hyperedges_json = json.dumps(getattr(G, "graph", {}).get("hyperedges", []))
374-
title = sanitize_label(str(output_path))
375+
title = _html.escape(sanitize_label(str(output_path)))
375376
stats = f"{G.number_of_nodes()} nodes &middot; {G.number_of_edges()} edges &middot; {len(communities)} communities"
376377

377378
html = f"""<!DOCTYPE html>

graphify/security.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,12 @@ def validate_graph_path(path: str | Path, base: Path | None = None) -> Path:
186186

187187

188188
def sanitize_label(text: str) -> str:
189-
"""Strip control characters, cap length, then HTML-escape.
189+
"""Strip control characters and cap length.
190190
191-
Applied to all node labels and edge titles before they are embedded
192-
in pyvis HTML output or returned via the MCP server, preventing both
193-
XSS and broken visualisations from malformed source identifiers.
191+
Safe for embedding in JSON data (inside <script> tags) and plain text.
192+
For direct HTML injection, wrap the result with html.escape().
194193
"""
195194
text = _CONTROL_CHAR_RE.sub("", text)
196195
if len(text) > _MAX_LABEL_LEN:
197196
text = text[:_MAX_LABEL_LEN]
198-
return html.escape(text)
197+
return text

graphify/skill.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
2424
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
2525
/graphify <path> --mcp # start MCP stdio server for agent access
2626
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
27+
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
2728
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
2829
/graphify add <url> # fetch URL, save to ./raw, update graph
2930
/graphify add <url> --author "Name" # tag who wrote it

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "graphifyy"
7-
version = "0.3.11"
7+
version = "0.3.12"
88
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, OpenClaw) - turn any folder of code, docs, papers, or images into a queryable knowledge graph"
99
readme = "README.md"
1010
license = { file = "LICENSE" }

tests/test_security.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,11 @@ def test_validate_graph_path_raises_if_file_missing(tmp_path):
168168
# sanitize_label
169169
# ---------------------------------------------------------------------------
170170

171-
def test_sanitize_label_escapes_html():
172-
assert "&lt;script&gt;" in sanitize_label("<script>")
173-
assert "&amp;" in sanitize_label("foo & bar")
171+
def test_sanitize_label_passthrough_html_chars():
172+
# sanitize_label does NOT HTML-escape — callers that inject into HTML must
173+
# wrap with html.escape() themselves (e.g. the title in to_html())
174+
assert sanitize_label("<script>") == "<script>"
175+
assert sanitize_label("foo & bar") == "foo & bar"
174176

175177
def test_sanitize_label_strips_control_chars():
176178
result = sanitize_label("hello\x00\x1fworld")

0 commit comments

Comments
 (0)