Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,9 @@ jobs:
languages: ${{ matrix.language }}
build-mode: none
- name: Analyze
id: analyze
uses: github/codeql-action/analyze@3b0bd1d116c0bde30213346b22d4f634d96a2fb0 # v3
with:
output: codeql-results
- name: Require clean CodeQL results
run: python scripts/check_codeql_sarif.py "${{ steps.analyze.outputs.sarif-output }}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pass the SARIF directory to the gate

analyze exposes sarif-output as the generated SARIF file path, but check_codeql_sarif.py treats its argument as a directory and calls rglob("*.sarif"). Since Path.rglob on that file returns no results, both matrix jobs fail with “no SARIF files found” even when CodeQL is clean, blocking every PR and main push. Pass the configured codeql-results directory instead, or make the checker accept an individual SARIF file.

Useful? React with 👍 / 👎.

4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ Public 1.0.1 client reliability release.
entitlement-gated managed-compute availability while preserving snapshot redaction and limits.
- Commercial metadata now describes Pro as one owner account across that owner's local
installations, matching the hosted entitlement model; Team remains billed per named seat.
- API error responses and provider logs no longer expose arbitrary exception or configuration
text; local folder and repository reads resolve and re-check filesystem boundaries.
- Entity extraction and dashboard asset migration avoid adversarial regular-expression
backtracking, and CI now fails when CodeQL emits any SARIF finding.

## [1.0.0] - 2026-07-23

Expand Down
20 changes: 12 additions & 8 deletions demo/record_screen_demo.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { spawn, spawnSync } from "node:child_process";
import { createReadStream, mkdirSync, rmSync, statSync } from "node:fs";
import { createServer } from "node:http";
import { join, resolve, sep } from "node:path";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";

Expand All @@ -13,6 +13,11 @@ const payload = join(generatedDir, "screen_demo_payload.json");
const webm = join(outputDir, "engraphis-memory-demo.webm");
const mp4 = join(outputDir, "engraphis-memory-demo.mp4");
const port = 8790;
const demoAssets = new Map([
["/", join(demoDir, "engraphis_screen_demo.html")],
["/engraphis_screen_demo.html", join(demoDir, "engraphis_screen_demo.html")],
["/generated/screen_demo_payload.json", payload],
]);

mkdirSync(generatedDir, { recursive: true });
mkdirSync(outputDir, { recursive: true });
Expand All @@ -22,17 +27,16 @@ const prepared = spawnSync(process.env.PYTHON || "python", [
if (prepared.status !== 0) process.exit(prepared.status || 1);

const server = createServer((request, response) => {
let relative;
let pathname;
try {
relative = decodeURIComponent((request.url || "/").split("?", 1)[0]);
pathname = new URL(request.url || "/", "http://127.0.0.1").pathname;
} catch {
response.writeHead(400); response.end("Bad request"); return;
}
const requested = relative === "/" ? "engraphis_screen_demo.html" : relative.slice(1);
const file = resolve(demoDir, requested);
if (file !== demoDir && !file.startsWith(demoDir + sep)) {
response.writeHead(403); response.end("Forbidden"); return;
}
// This recorder only needs these generated demo assets. Map request paths to fixed
// files instead of deriving a filesystem path from a URL.
const file = demoAssets.get(pathname);
if (!file) { response.writeHead(404); response.end("Not found"); return; }
try {
const stat = statSync(file);
response.writeHead(200, { "Content-Length": stat.size, "Content-Type": file.endsWith(".json") ? "application/json" : "text/html" });
Expand Down
21 changes: 8 additions & 13 deletions engraphis/backends/embedder_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,9 @@ def __init__(
# A custom endpoint can contain embedded credentials or signed query
# parameters, while provider-controlled model identifiers are also untrusted
# log input. Do not copy either into logs.
logger.info(
"ApiEmbedder(custom_endpoint=%s, dim=%s)",
bool(base_url), self._dim or "auto",
)
# Do not log endpoint, model, dimensions, or any authentication-related state:
# provider configuration can contain account identifiers or signed parameters.
logger.info("API embedder initialized")

@property
def dim(self) -> int:
Expand Down Expand Up @@ -89,10 +88,7 @@ def embed(
import httpx

if not self._api_key:
logger.error(
"No API key set — set %s env var or pass api_key",
_DEFAULT_API_KEY_ENV,
)
logger.error("No API key set for API embedder")
raise RuntimeError(
f"ApiEmbedder requires an API key via {_DEFAULT_API_KEY_ENV} "
"env var or the api_key parameter"
Expand All @@ -114,9 +110,8 @@ def embed(
)
resp.raise_for_status()
data = resp.json()
except Exception as exc:
logger.warning("Batch embedding failed (%s), falling back per-item",
type(exc).__name__)
except Exception:
logger.warning("Batch embedding request failed; falling back per-item")
# Fallback: embed one at a time
vecs = [self._embed_one(t) for t in texts]
return np.asarray(vecs, dtype=np.float32)
Expand Down Expand Up @@ -172,8 +167,8 @@ def _embed_one(self, text: str) -> list[float]:
)
resp.raise_for_status()
data = resp.json()
except Exception as exc:
logger.error("Single embedding request failed (%s)", type(exc).__name__)
except Exception:
logger.error("Single embedding request failed")
return [0.0] * (self._dim or 384)

items = data.get("data", [])
Expand Down
1 change: 1 addition & 0 deletions engraphis/backends/embedder_deterministic.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def embed(self, texts: list[str], *, kind: Literal["text", "code"] = "text") ->
# SHA-1 is retained only to keep the established offline embedding
# mapping stable across upgrades. This is feature hashing, never a
# password, signature, integrity check, or other security primitive.
# codeql[py/weak-sensitive-data-hashing]
h = hashlib.sha1(
token.encode("utf-8"), usedforsecurity=False
).digest()
Expand Down
115 changes: 100 additions & 15 deletions engraphis/backends/graph_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,28 @@
"""
from __future__ import annotations

import heapq
import re
from dataclasses import dataclass, field
from typing import Any, Optional

from engraphis.core.interfaces import Edge, Node

# ── Regex NER (ported from engraphis/engines/ingest.py — the v1 heuristic path) ──
# Capitalized multi-word sequences, emails, URLs/hashtags, quoted names/mentions.
_ENTITY_RE = re.compile(
r"\b([A-Z][a-z]+(?:-[A-Za-z]+)*(?:\s+[A-Z][a-z]+(?:-[A-Za-z]+)*){0,3})\b"
r"|([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})"
r"|(#[a-zA-Z][a-zA-Z0-9_-]+)"
r"|(@[a-zA-Z][a-zA-Z0-9_-]+)"
# Capitalized multi-word sequences, emails, hashtags, and mentions. Keep the
# individual recognizers unambiguous: the old all-in-one expression could take
# polynomial time while backtracking over a long email-like local part.
_CAPITALIZED_WORD_RE = re.compile(r"\b[A-Z][a-z]+(?:-[A-Za-z]+)*\b")
_HASHTAG_RE = re.compile(r"#[a-zA-Z][a-zA-Z0-9_-]+")
_MENTION_RE = re.compile(r"@[a-zA-Z][a-zA-Z0-9_-]+")
_EMAIL_LOCAL_CHARS = frozenset(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._%+-"
)
_EMAIL_DOMAIN_CHARS = frozenset(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-"
)
_EMAIL_SUFFIX_CHARS = frozenset(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
)
_RELATION_RE = re.compile(
r"\b(?:is|are|was|were|has|have|had|owns|works at|lives in|prefers|likes|"
Expand Down Expand Up @@ -79,6 +88,35 @@
"My", "Our", "Your", "Their", "His", "Her", "Its"}


def _iter_emails(text: str):
"""Yield email-like spans in one pass without regex backtracking."""

index = 0
length = len(text)
while index < length:
if text[index] not in _EMAIL_LOCAL_CHARS:
index += 1
continue
if index > 0 and text[index - 1] in _EMAIL_LOCAL_CHARS:
index += 1
continue
start = index
while index < length and text[index] in _EMAIL_LOCAL_CHARS:
index += 1
if index >= length or text[index] != "@":
continue
domain_start = index + 1
end = domain_start
while end < length and text[end] in _EMAIL_DOMAIN_CHARS:
end += 1
domain = text[domain_start:end]
dot = domain.rfind(".")
suffix = domain[dot + 1:] if dot > 0 else ""
if len(suffix) >= 2 and all(char in _EMAIL_SUFFIX_CHARS for char in suffix):
yield start, end, text[start:end]
Comment on lines +112 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop consuming sentence punctuation as email domain

When an email is followed by a period, such as bob@acme.com., the domain scan consumes the terminal period because . is allowed in _EMAIL_DOMAIN_CHARS; rfind then sees that final period and produces an empty suffix, so no email is emitted. This common prose case regresses the former regex behavior and can additionally produce the spurious @acme mention from the independent mention matcher. Trim trailing dots (and preserve the last non-empty label) before validating and yielding; the same copied logic in engines/ingest.py needs the equivalent fix.

Useful? React with 👍 / 👎.

index = domain_start if end < length and text[end] == "@" else end


def _defang(value: str) -> str:
return _CONTROL_RE.sub("", value or "").strip()[:_MAX_NAME]

Expand All @@ -105,24 +143,71 @@ class GraphExtraction:


def _extract_entities(text: str) -> list[tuple[str, str]]:
text = text or ""

def capitalized_candidates():
# Assemble at most four whitespace-separated capitalized words, matching the
# former heuristic without placing a nested repetition over untrusted text.
words = iter(_CAPITALIZED_WORD_RE.finditer(text))
current = next(words, None)
while current is not None:
first = last = current
current = None
for _ in range(3):
following = next(words, None)
if following is None:
break
if text[last.end():following.start()].isspace():
last = following
continue
current = following
break
else:
current = next(words, None)
yield (
first.start(),
0,
last.end(),
text[first.start():last.end()],
"person_or_concept",
)

# Merge four already-position-ordered iterators. This keeps memory bounded even
# for very large untrusted input and lets the entity fanout cap stop collection.
candidates = heapq.merge(
capitalized_candidates(),
(
(start, 1, end, value, "email")
for start, end, value in _iter_emails(text)
),
(
(match.start(), 2, match.end(), match.group(0), "hashtag")
for match in _HASHTAG_RE.finditer(text)
),
(
(match.start(), 3, match.end(), match.group(0), "mention")
for match in _MENTION_RE.finditer(text)
),
)

seen: set[str] = set()
out: list[tuple[str, str]] = []
for m in _ENTITY_RE.finditer(text or ""):
raw = (m.group(0) or "").strip()
matched_through = 0
for start, _priority, end, candidate, etype in candidates:
if start < matched_through:
continue
matched_through = end
raw = candidate.strip()
if not raw or raw.casefold() in _STOPWORD_KEYS or raw.casefold() in (
"user", "the user"
):
continue
if raw.startswith("#"):
ent, etype = raw, "hashtag"
elif raw.startswith("@"):
ent, etype = raw, "mention"
elif "@" in raw and "." in raw:
ent, etype = raw, "email"
else:
if etype == "person_or_concept":
ent, etype = _canon_concept(raw), "person_or_concept"
if len(ent) < 2 or ent.casefold() in _STOPWORD_KEYS:
continue
else:
ent = raw
key = ent.lower()
if key not in seen:
seen.add(key)
Expand Down
18 changes: 13 additions & 5 deletions engraphis/core/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,10 @@ def index_repo(self, repo_id: str, root_path: str, *, languages: Optional[set] =
)

indexer = get_code_indexer(prefer=prefer)
# index_repo is an explicit local-filesystem capability: callers select the
# repository root to index. Every walked child is resolved and re-contained
# below before it is read.
# codeql[py/path-injection]
root = Path(root_path).expanduser().resolve()
if not root.exists():
raise ValueError(f"repo root not found: {root_path}")
Expand Down Expand Up @@ -1257,10 +1261,14 @@ def index_repo(self, repo_id: str, root_path: str, *, languages: Optional[set] =
if files_scanned >= max_files:
scan_complete = False
break
p = Path(file_path)
candidate = Path(file_path)
try:
rel = p.resolve().relative_to(root).as_posix()
except ValueError:
# Resolve once, verify containment, then use this checked path for
# every filesystem operation. The walker skips symlinks, and this
# closes the remaining defense-in-depth gap if it ever changes.
source_file = candidate.resolve(strict=True)
rel = source_file.relative_to(root).as_posix()
except (OSError, ValueError):
files_failed += 1
continue
files_scanned += 1
Expand All @@ -1271,11 +1279,11 @@ def index_repo(self, repo_id: str, root_path: str, *, languages: Optional[set] =
# of an otherwise complete scan.
present.add(rel)
try:
stat = p.stat()
stat = source_file.stat()
if stat.st_size > max_file_bytes:
files_skipped += 1
continue
raw = p.read_bytes()
raw = source_file.read_bytes()
except OSError:
files_failed += 1
continue
Expand Down
Loading
Loading