AcadApplication has no GetVariable/SetVariable members; they live on
AcadDocument. Measured on the running AutoCAD 2026 (25.1s, 2026-09-22,
scratch Documents.Add(), Close(False)):
hasattr(app, 'GetVariable') -> False
hasattr(app, 'SetVariable') -> False
app.GetVariable('INSUNITS') -> AttributeError: ... IAcadApplication
instance ... has no attribute 'GetVariable'
doc.GetVariable('INSUNITS') -> 4
doc.GetVariable('MEASUREMENT') -> 1
doc.GetVariable('FILEDIA') -> 1; doc.SetVariable('FILEDIA', 0) -> None,
reads back 0, restored to 1
doc.GetVariable('AUDITCTL') -> 0, 'CMDACTIVE' -> 0, 'USERS1' -> ''
Fifteen call sites in backends/com_backend.py still went through the
Application inside `try/except ... log.debug`, so on a live seat they
silently degraded: MEASUREMENT defaulted every seat to metric,
drawing_info reported units from a failed INSUNITS read, drawing_audit
never set AUDITCTL, the CMDACTIVE guards of system_run_command /
system_run_lisp never fired and run_lisp never read USERS1 back. All are
rerouted through `_acad_doc()`; the try/except stays only where an
optional read is defaulted (INSUNITS -> "Unknown", AUDITCTL absent on a
host that lacks it, USERS1 after the expression ran) and the log line
says so.
Linetype loading, measured the same way: `SendCommand("_-LINETYPE _LOAD
CENTER acadiso.lin\n\n")` loads nothing with FILEDIA=0 and leaves the
command at `Enter an option [?/Create/Load/Set]:` (LASTPROMPT), which
makes the whole seat answer RPC_E_CALL_REJECTED to every client until
ESC is pressed; with FILEDIA=1 it opens the modal "Select Linetype File"
dialog. `doc.Linetypes.Load("CENTER", "acadiso.lin")` returns None and
the table grows 3 -> 4; a repeat raises 'Duplicate record name', an
unknown name 'Undefined linetype', a missing file 'File system error'.
`_ensure_linetype_loaded` and `linetype_load` now call `Linetypes.Load`
(MEASUREMENT still read on the document to pick the .lin), touch no
FILEDIA, and report a failure with the English excepinfo description.
`apply_standard` reads every variable it will write before the first
style is created, so a read the engine refuses is a refusal with nothing
written (it used to fail after ISO-25 / ISOCP were created and current).
Fakes: every application fake refuses GetVariable/SetVariable
(tests/test_styles.py's COM fixture carried them on the Application,
enshrining the wrong shape); the members exist on document fakes only.
Regression tests per rerouted site: linetype load via Linetypes.Load with
MEASUREMENT on the document, drawing_info units from the document,
AUDITCTL set/restored on the document, CMDACTIVE/USERS1 on the document,
apply_standard refusing before any write.
Live end-to-end with the real ComBackend on a scratch document:
apply_standard('iso') -> INSUNITS 4, ISO-25 (already in acadiso.dwt,
created: false) and ISOCP (created: true) current, second call created:
false / changed: {}; linetype_load('CENTER') -> Linetypes 3 -> 4, FILEDIA
stays 1, repeat already_loaded, 'NOSUCHLT' refused by name;
_ensure_linetype_loaded('HIDDEN') -> 3 -> 4; drawing_info units 'mm';
drawing_audit AUDITCTL 0 -> 0; system_run_command ok;
system_run_lisp('(+ 40 2)') -> '42'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
91 lines
3.0 KiB
Python
91 lines
3.0 KiB
Python
"""SendCommand takes a whole macro, so a name interpolated into one is code.
|
|
|
|
`security.py` recorded the policy as "`sanitize_command` and `sanitize_lisp`
|
|
each have exactly ONE caller, both of them the COM-only free-text escape hatch
|
|
into SendCommand. The policy is scoped to the CHANNEL." That premise was false.
|
|
|
|
`_ensure_linetype_loaded` built ``_-LINETYPE _LOAD {name} {lin_file}`` and
|
|
handed it to `doc.SendCommand`, and it is reached from the ``linetype=``
|
|
argument of every COM entity-creation and layer tool — plus
|
|
`ComBackend.linetype_load`, which interpolated a caller-supplied *file path*
|
|
as well. SendCommand treats a newline and ``;`` as segment separators, so a
|
|
linetype name carrying either injects further AutoCAD commands, and the 36-verb
|
|
denylist and `DANGEROUS_COMMANDS_ENABLED` never see that channel at all. (Both
|
|
loaders now call the ActiveX ``Linetypes.Load`` member, which takes the name
|
|
as data; the guard stays in front of it, since a name the DXF rule refuses was
|
|
never a loadable linetype.)
|
|
|
|
The guard here is the DXF symbol-name rule rather than a bespoke blocklist:
|
|
AutoCAD already forbids these characters in a table name, so anything this
|
|
rejects was never a loadable linetype, and the injection characters fall out of
|
|
the rule rather than having to be enumerated.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastmcp.exceptions import ToolError
|
|
|
|
from security import sanitize_symbol_name
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
[
|
|
"CENTER",
|
|
"HIDDEN2",
|
|
"DASHDOT",
|
|
"ACAD_ISO02W100",
|
|
"My-Linetype_01",
|
|
],
|
|
)
|
|
def test_ordinary_linetype_names_pass(name):
|
|
assert sanitize_symbol_name(name, kind="linetype") == name
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
"CENTER\n_ERASE ALL\n", # newline ends the macro segment
|
|
"CENTER;_ERASE ALL", # so does a semicolon
|
|
"CENTER\r_QUIT",
|
|
'CENTER"',
|
|
"CENTER|X",
|
|
"CENTER<X",
|
|
"CENTER>X",
|
|
"CENTER?X",
|
|
"CENTER*X",
|
|
"CENTER,X",
|
|
"CENTER=X",
|
|
"CENTER:X",
|
|
"CENTER/X",
|
|
"CENTER" + chr(92) + "X",
|
|
],
|
|
)
|
|
def test_a_name_that_could_end_the_macro_segment_is_refused(payload):
|
|
with pytest.raises(ToolError) as excinfo:
|
|
sanitize_symbol_name(payload, kind="linetype")
|
|
|
|
assert "linetype" in str(excinfo.value).lower()
|
|
|
|
|
|
def test_an_empty_name_is_refused():
|
|
with pytest.raises(ToolError):
|
|
sanitize_symbol_name(" ", kind="linetype")
|
|
|
|
|
|
def test_the_refusal_does_not_echo_the_payload_back_verbatim():
|
|
"""An error message is a channel too."""
|
|
with pytest.raises(ToolError) as excinfo:
|
|
sanitize_symbol_name("CENTER\n_ERASE ALL\n", kind="linetype")
|
|
|
|
assert "\n_ERASE" not in str(excinfo.value)
|
|
|
|
|
|
def test_the_com_linetype_loader_refuses_before_it_reaches_sendcommand():
|
|
"""The guard has to sit in front of the macro, not inside AutoCAD."""
|
|
import backends.com_backend as cb
|
|
|
|
with pytest.raises(ToolError):
|
|
cb._ensure_linetype_loaded("CENTER\n_ERASE ALL\n")
|