Skip to content

Commit 8884a1c

Browse files
TMHSDigitalclaude
andcommitted
ci: clear the HOL plugin scanner's findings
The scanner's first run (score 90, grade A) failed on one high finding and reported two low ones: - tests/check_engine_id.py ran eval/exec on expressions lifted from the repo's own files. It now interprets the resolver AST directly (version comparisons, IS_5X flags, conditionals, if/return), with identical output: 142 mappings across 141 files, 3 exempt. Inverting a real mapping still fails the check with exit 1. - live-session/ is scanned as its own plugin and had no LICENSE or SECURITY.md. It gets a copy of the repo license and a policy covering the pinned MCP server and where to report server bugs upstream. Refs #510 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a663e28 commit 8884a1c

3 files changed

Lines changed: 144 additions & 29 deletions

File tree

‎live-session/LICENSE‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International
2+
3+
Copyright (c) 2026 TM Hospitality Strategies
4+
5+
This work is licensed under the Creative Commons
6+
Attribution-NonCommercial-NoDerivatives 4.0 International License.
7+
8+
You are free to:
9+
10+
Share - copy and redistribute the material in any medium or format.
11+
12+
The licensor cannot revoke these freedoms as long as you follow the
13+
license terms.
14+
15+
Under the following terms:
16+
17+
Attribution - You must give appropriate credit, provide a link to the
18+
license, and indicate if changes were made. You may do so in any
19+
reasonable manner, but not in any way that suggests the licensor
20+
endorses you or your use.
21+
22+
NonCommercial - You may not use the material for commercial purposes.
23+
24+
NoDerivatives - If you remix, transform, or build upon the material,
25+
you may not distribute the modified material.
26+
27+
No additional restrictions - You may not apply legal terms or
28+
technological measures that legally restrict others from doing
29+
anything the license permits.
30+
31+
Full license text:
32+
https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode
33+
34+
SPDX-License-Identifier: CC-BY-NC-ND-4.0

‎live-session/SECURITY.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Security Policy: blender-live-session
2+
3+
`blender-live-session` is the opt-in second plugin in the Blender Developer
4+
Tools marketplace. It registers the official Blender Lab MCP server
5+
([lab/blender_mcp](https://projects.blender.org/lab/blender_mcp)) and ships one
6+
skill for using it. The repository-wide policy in the root
7+
[`SECURITY.md`](../SECURITY.md) applies; this file covers what is specific to
8+
this plugin.
9+
10+
## What this plugin runs
11+
12+
- `mcp.json` starts the server with `uvx` from a full 40-hex commit pin on
13+
projects.blender.org, with `--exclude-newer` so its dependencies resolve to
14+
what was reviewed with that pin. `tests/check_manifest_meta.py` enforces both,
15+
and `mcp-server.yml` installs and handshakes with the pinned server weekly.
16+
- The server connects to a running Blender over `localhost`, through the MCP
17+
add-on, and executes Python the agent generates **in that Blender, without
18+
guards**. Attach it only to scratch scenes, never to files you cannot afford
19+
to lose. The skill in `skills/live-blender-session/` has the agent read the
20+
scene before touching it and save (or save a copy) before any mutating call.
21+
22+
## Reporting
23+
24+
- **This plugin** (the pin, `mcp.json`, the manifests, or the skill recommending
25+
something unsafe): open a
26+
[private security advisory](https://github.com/TMHSDigital/Blender-Developer-Tools/security/advisories/new).
27+
- **The MCP server or its Blender add-on:** report upstream at
28+
[projects.blender.org/lab/blender_mcp](https://projects.blender.org/lab/blender_mcp).
29+
We move the pin once a fix lands upstream.
30+
31+
Supported versions and response times are the same as the root policy: the
32+
latest release only.

‎tests/check_engine_id.py‎

Lines changed: 78 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@
2929
Method
3030
------
3131
32-
AST, not regex. Every function that returns an EEVEE id is compiled and
33-
*called* twice, against a stubbed ``bpy.app.version`` of (4, 5, 11) and
34-
(5, 2, 1); every conditional expression that yields one is evaluated the
35-
same way. Both forms — ternary and ``if``/``return`` — are handled by the
32+
AST, not regex. Every function that returns an EEVEE id is interpreted
33+
twice, against a stubbed ``bpy.app.version`` of (4, 5, 11) and (5, 2, 1);
34+
every conditional expression that yields one is evaluated the same way. The
35+
interpreter walks the AST and never runs the repo's code (no eval or exec). Both forms — ternary and ``if``/``return`` — are handled by the
3636
same code path, because a regex over either one misreads the other.
3737
3838
A deliberately inverted mapping (``examples/swatch-grid`` witnesses the
@@ -63,16 +63,6 @@ def canonical(version):
6363
return "BLENDER_EEVEE" if version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
6464

6565

66-
class _App:
67-
def __init__(self, version):
68-
self.version = version
69-
70-
71-
class _Bpy:
72-
def __init__(self, version):
73-
self.app = _App(version)
74-
75-
7666
def _mentions_id(node):
7767
for sub in ast.walk(node):
7868
if isinstance(sub, ast.Constant) and sub.value in EEVEE_IDS:
@@ -119,26 +109,85 @@ def walk(stmts):
119109
return walk(fn.body) and bool(returns) and not fn.args.args
120110

121111

122-
def _namespace(version):
123-
return {
124-
"bpy": _Bpy(version),
125-
"IS_5X": version >= (5, 0, 0),
126-
"IS_5_X": version >= (5, 0, 0),
127-
"__builtins__": __builtins__,
128-
}
112+
# Names a resolver may branch on, as functions of the stubbed version.
113+
_NAMES = {
114+
"IS_5X": lambda version: version >= (5, 0, 0),
115+
"IS_5_X": lambda version: version >= (5, 0, 0),
116+
}
117+
_COMPARE = {
118+
ast.Lt: lambda a, b: a < b, ast.LtE: lambda a, b: a <= b,
119+
ast.Gt: lambda a, b: a > b, ast.GtE: lambda a, b: a >= b,
120+
ast.Eq: lambda a, b: a == b, ast.NotEq: lambda a, b: a != b,
121+
}
122+
123+
124+
def _value(node, version):
125+
"""Interpret the small expression language resolvers use.
126+
127+
Constants, tuples, ``bpy.app.version`` (indexed or sliced), the IS_5X
128+
flags, comparisons, and/or/not, and conditional expressions. Anything
129+
else raises ValueError, which the caller reports as "not resolvable".
130+
The repo's own code is walked, never executed, so the check needs no
131+
eval/exec (HOL's plugin scanner rejects dynamic execution, #510).
132+
"""
133+
if isinstance(node, ast.Constant):
134+
return node.value
135+
if isinstance(node, ast.Tuple):
136+
return tuple(_value(e, version) for e in node.elts)
137+
if isinstance(node, ast.Name) and node.id in _NAMES:
138+
return _NAMES[node.id](version)
139+
if (isinstance(node, ast.Attribute) and node.attr == "version"
140+
and isinstance(node.value, ast.Attribute) and node.value.attr == "app"
141+
and isinstance(node.value.value, ast.Name) and node.value.value.id == "bpy"):
142+
return version
143+
if isinstance(node, ast.Subscript):
144+
seq = _value(node.value, version)
145+
if isinstance(node.slice, ast.Slice):
146+
lo, hi, step = (None if p is None else _value(p, version)
147+
for p in (node.slice.lower, node.slice.upper, node.slice.step))
148+
return seq[lo:hi:step]
149+
return seq[_value(node.slice, version)]
150+
if isinstance(node, ast.Compare):
151+
left = _value(node.left, version)
152+
for op, comp in zip(node.ops, node.comparators):
153+
right = _value(comp, version)
154+
if type(op) not in _COMPARE:
155+
raise ValueError(f"unsupported comparison {type(op).__name__}")
156+
if not _COMPARE[type(op)](left, right):
157+
return False
158+
left = right
159+
return True
160+
if isinstance(node, ast.BoolOp):
161+
vals = (_value(v, version) for v in node.values)
162+
return all(vals) if isinstance(node.op, ast.And) else any(vals)
163+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
164+
return not _value(node.operand, version)
165+
if isinstance(node, ast.IfExp):
166+
branch = node.body if _value(node.test, version) else node.orelse
167+
return _value(branch, version)
168+
raise ValueError(f"unsupported expression {ast.unparse(node)!r}")
169+
170+
171+
def _run(stmts, version):
172+
"""Interpret a resolver body (returns, version branches, docstrings)."""
173+
for st in stmts:
174+
if isinstance(st, ast.Return):
175+
return True, _value(st.value, version)
176+
if isinstance(st, ast.If):
177+
done, val = _run(st.body if _value(st.test, version) else st.orelse, version)
178+
if done:
179+
return True, val
180+
return False, None
129181

130182

131183
def _evaluate(node, version):
132184
"""Return the id this node yields on *version*."""
133-
ns = _namespace(version)
134185
if isinstance(node, ast.FunctionDef):
135-
mod = ast.Module(body=[node], type_ignores=[])
136-
ast.fix_missing_locations(mod)
137-
exec(compile(mod, "<engine-id>", "exec"), ns)
138-
return ns[node.name]()
139-
expr = ast.Expression(body=node)
140-
ast.fix_missing_locations(expr)
141-
return eval(compile(expr, "<engine-id>", "eval"), ns)
186+
done, val = _run(node.body, version)
187+
if not done:
188+
raise ValueError(f"{node.name}() falls off the end without returning")
189+
return val
190+
return _value(node, version)
142191

143192

144193
def _exempt_reason(lines, lineno):

0 commit comments

Comments
 (0)