release: v1.5.0 — discovery layer, honesty debt closed, Wave A

Tool count 131 -> 154; 474 -> 1242 collected tests.

The release exists for two measured reasons. A 149-tool catalog costs a client
~40,300 tokens before it has asked for anything, and a drafter searching the way
drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock
index, because those words were simply not in the data.

Discovery and batching
- DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool,
  ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on
  top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised.
- cad_batch runs a step list in one round trip; fields= projects result-heavy
  responses. The `core` profile is gone -- discovery replaced it.

Honesty debt (Tier 0)
- OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and
  rotated geometry reported coordinates that were quietly wrong. backends/ocs.py
  translates at the backend boundary; every tool coordinate is WCS on both
  engines, and a tilted plane refuses rather than answering with a number that
  looks fine.
- drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never
  repaired. analysis_measure_area shoelaced points the caller typed in.
- ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a
  server whose document lock is a single asyncio.Lock.

Architecture
- The 1854-line backend ABC is 19 contract modules; @capability(key, reason=)
  makes optional methods declarative and a gate fails on undeclared holes.
- 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch.
- Wave A: selection filters, boundary tracing, hatch depth, annotation objects,
  measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was
  cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann
  substitute loses 28.2% of the area on a square with one semicircular edge.

Verified against a live AutoCAD 2026, which found four COM defects no amount of
reading would have caught -- AcadPViewport has no ViewCenter, and the line that
set it had been swallowed by a bare except since v1.4.

Benchmark v3 (15 tasks) and what it caught
- v2 scored this server 10/10, which carried no information: every task in it
  exercised something the server was built around. Five tasks were added because
  they can fail, and three did while being written -- a hatch could not be
  measured though the refusal listed HATCH as measurable, the boundary tools and
  the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a
  circle stored as two bulged vertices measured 0.0.
- Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and
  were never asked these five questions; the chart shows those rows `not run`.
- Correctness A/B: 21/24 -> 24/24, zero regressions.

Performance, stated plainly
The published v1.4 perf report was recorded on CPython 3.14 and the new one on
3.11.15, so the apparent 2-6x speedup is the interpreter, not this release.
Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22%
slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores
v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is
documented, and winning it back is on the 1.6 roadmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Umut CAN
2026-08-06 09:45:20 +03:00
co-authored by Claude Opus 5
parent d5a0ff5109
commit b03a7615b1
107 changed files with 32640 additions and 4810 deletions
+117 -1
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
import os
import pytest
from fastmcp.exceptions import ToolError
from security import sanitize_command, sanitize_lisp, validate_path
from security import command_verbs, sanitize_command, sanitize_lisp, validate_path
# ---------------------------------------------------------------------------
# Path validation tests
@@ -276,3 +278,117 @@ class TestSanitizerBypassVectors:
def test_lisp_legitimate_not_overblocked(self):
assert sanitize_lisp("(setq x (* 2 3))") == "(setq x (* 2 3))"
# ── T0.8-A — what the guards actually guard ────────────────────────────────
class TestNonLocalPathAnchors:
r"""Windows accepts several spellings of the same file that compare unequal.
``_BLOCKED_PATH_PATTERNS`` rejects the backslash forms, but ``Path.resolve()``
normalises the *forward-slash* spellings into them afterwards — so
``//?/C:/Windows/win.ini`` slipped past the pattern check and then compared
unequal to ``C:/Windows`` in the system-directory loop. Proven: it returned a
handle that ``os.path.samefile``'d ``C:\Windows\win.ini``.
"""
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
def test_extended_length_prefix_is_rejected(self):
with pytest.raises(ToolError, match="not a plain local drive path"):
validate_path("//?/C:/Windows/win.ini")
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
def test_unc_forward_slash_form_is_rejected(self):
with pytest.raises(ToolError, match="not a plain local drive path"):
validate_path("//localhost/C$/Windows/win.ini")
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
def test_device_form_still_reaches_the_system_directory_check(self):
"""``//./C:/...`` normalises to a plain drive path — it must be caught
by the system-directory rule, not by the anchor rule, or the anchor
check would be masking rather than adding a guard."""
with pytest.raises(ToolError, match="system directory"):
validate_path("//./C:/Windows/win.ini")
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
def test_an_ordinary_path_is_unaffected(self, tmp_path):
assert validate_path(str(tmp_path / "part.dxf"))
class TestLispStringLiteralsAreData:
"""A quoted literal is drawing text, not code.
Reading inside quotes made the server refuse its own domain vocabulary: an
engineering CAD server that rejects ``"Max load 500 kg"`` because the word
"load" appears in a note is broken for the job it exists to do.
"""
@pytest.mark.parametrize(
"expression",
[
'(setq desc "Max load 500 kg")',
'(entmakex (list (cons 0 "TEXT") (cons 1 "LOAD CASE 1")))',
'(setq layer "C:CENTER")',
'(setq note "Design load = 12 kN")',
],
)
def test_engineering_text_is_not_mistaken_for_code(self, expression):
assert sanitize_lisp(expression) == expression
@pytest.mark.parametrize(
"expression",
[
'(load "malicious.lsp")',
'(vl-catch-all-apply (quote command) (list "ERASE"))',
'(startapp "cmd")',
"(eval (read user-input))",
],
)
def test_the_dangerous_vectors_still_block(self, expression):
with pytest.raises(ToolError):
sanitize_lisp(expression)
class TestCommandVerbScoping:
"""Match the command, not the characters.
``\b(ERASE|...)\b`` over the whole string refused ``MTEXT 0,0 10,10 Do not
erase this part`` — annotation text, not a command — while missing spellings
AutoCAD accepts. SendCommand takes a macro: segments split on newline or
``;``, and only a segment's FIRST token is a verb.
"""
def test_the_repos_own_safe_fixture_still_passes(self):
assert sanitize_command("_ZOOM E") == "_ZOOM E"
def test_zoom_extents_as_a_macro_passes(self):
"""``E`` here is the Extents *option*, not the ERASE alias. Blocking the
canonical zoom-extents macro is why single-letter aliases stay out of
the verb set."""
assert sanitize_command("_ZOOM\nE\n") == "_ZOOM\nE\n"
def test_annotation_text_mentioning_a_verb_passes(self):
text = "MTEXT 0,0 10,10 Do not erase this part"
assert sanitize_command(text) == text
def test_a_linetype_load_macro_passes(self):
macro = "_-LINETYPE _LOAD CENTER acad.lin\n\n"
assert sanitize_command(macro) == macro
@pytest.mark.parametrize(
"command",
["ERASE ALL", "_ZOOM E\n_ERASE", "SAVE", "VBASTMT", "ARX", ".SHELL", "'SCRIPT x.scr"],
)
def test_dangerous_verbs_are_blocked_in_every_decoration(self, command):
with pytest.raises(ToolError):
sanitize_command(command)
def test_the_refusal_names_the_offending_verb(self):
"""Without this a user whose annotation was refused cannot tell why."""
with pytest.raises(ToolError, match="ERASE"):
sanitize_command("_ZOOM E\n_ERASE")
def test_command_verbs_strips_every_decorator(self):
assert command_verbs("_-PURGE") == ["PURGE"]
assert command_verbs("'_ZOOM E;.REGEN") == ["ZOOM", "REGEN"]