52d94303bc
ci / test (push) Has been cancelled
Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
416 lines
18 KiB
Python
416 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Owner, verbatim: "我要把revit桥给你。你直接来sample_job。铁律是在live里面
|
|
# 只读不写。" -- I'm giving you the Revit bridge. Come straight into
|
|
# sample_job. The iron rule is: read-only inside live, never write.
|
|
"""Whether the bridge may serve THIS document, and how. NO REVIT, NO IO, NO STATE.
|
|
|
|
The rule this enforces is the owner's, and it was a memory until now: the
|
|
LIVE model is his, and nothing here may read it, run in it, or touch it. The
|
|
sandbox -- a timestamped detached copy, opened in a Revit session of its own
|
|
-- is the only session an agent is allowed to drive.
|
|
|
|
WHY IT HAS TO BE MECHANICAL
|
|
----------------------------
|
|
pyRevit's routes server picks its port by walking UP from the configured one
|
|
past every port a running Revit has already taken
|
|
(`serverinfo._get_next_available_port`). So "port 48884" does not mean "the
|
|
session I meant"; it means "whichever Revit started first". Restart the
|
|
sandbox while the live session is up and the live session is now on 48884 and
|
|
answering. Every request an agent then sends -- `status`, `exec`, a whole
|
|
sweep -- lands in the model it must never touch, and every one of them looks
|
|
completely normal.
|
|
|
|
Nothing about that is detectable from the client side: a `status` that says
|
|
`ok` and names the right title is exactly what a correct sandbox says (the
|
|
snapshot deliberately KEEPS the live title -- see `snapshotmeta`'s layout
|
|
note). So the refusal has to be made where the document actually is.
|
|
|
|
WHY A TITLE ALONE CANNOT DECIDE IT
|
|
-----------------------------------
|
|
`snapshotmeta.plan()`'s default layout puts the timestamp in the FOLDER and
|
|
leaves the model's own name -- and therefore `doc.Title` -- constant, because
|
|
the job folder is keyed on the title and a title that moves daily moves the
|
|
job folder with it. The consequence lands here: the sandbox copy's title is
|
|
IDENTICAL to the live model's. A denylist of titles would refuse both, which
|
|
is a bridge that refuses everything.
|
|
|
|
So the title is the ACCUSATION and the path is the ALIBI. A document whose
|
|
title is on the denylist is refused unless it can prove it is a copy:
|
|
|
|
1. its file lives under one of the snapshot roots (`snapshot.detached_dir`
|
|
plus anything in `bridge.allow_roots`), or
|
|
2. its file is the exact model named by `WILLTOOLS_SANDBOX_MODEL` in this
|
|
Revit process's own environment -- which only the pipeline that
|
|
launched the sandbox can have set.
|
|
|
|
The live cloud model has no local `PathName` at all (it is
|
|
`Autodesk Docs://...`, and the API reports it empty for a cloud document),
|
|
so it can produce neither alibi and is refused. That is the intended outcome
|
|
and it is not an accident of string matching.
|
|
|
|
WHAT IS NOT HERE, DELIBERATELY
|
|
-------------------------------
|
|
No file reading, no `knowledge.load`, no environment lookups except the two
|
|
helpers at the bottom that are handed their own input. Everything is a pure
|
|
function of arguments so `startup.py` can be tested at the function level
|
|
without a Revit -- the same shape `test_bridge_query_channel.py` had to work
|
|
around by parsing source text, which is a worse test than this one.
|
|
|
|
READ-ONLY, THE POLICY THE OWNER REVERSED THE SAME MORNING
|
|
------------------------------------------------------------
|
|
`bridge.refuse_titles` shipped 2026-09-01 to keep the bridge off the live
|
|
cloud model outright. Hours later the owner reversed it, in his own words:
|
|
"I'm giving you the Revit bridge. Come straight into sample_job. The iron
|
|
rule is: read-only inside live, never write." -- the
|
|
bridge may run on the live model now; the iron rule is it may never write to
|
|
it. `bridge.readonly_titles` is that rule. A title on it answers every read
|
|
route normally (`/status`, `/collect`, `/params`, `/selection` never carry a
|
|
transaction or arbitrary code, so they are reads by construction and never
|
|
consult this at all) and `/exec` -- the only route that can write -- refuses
|
|
when the request carries a transaction, or when the snippet text matches
|
|
`WRITE_PATTERNS` below.
|
|
|
|
`refuse_titles` and `readonly_titles` are NOT layered. A title on
|
|
`refuse_titles` is refused before anything here is reached; a title only on
|
|
`readonly_titles` is never refused outright, only stopped from writing.
|
|
|
|
THIS IS A GUARD RAIL, NOT A SANDBOX
|
|
-------------------------------------
|
|
`WRITE_PATTERNS` is a plain regex scan over snippet text, not a parser and
|
|
not a permissions model. It catches the ordinary way `/exec` writes -- an
|
|
explicit transaction, or a snippet that calls a mutating Revit API method by
|
|
its literal name -- because that is what an ordinary agent request looks
|
|
like. It does **not** catch a `.Name = ...` / `.Pinned = ...` style property
|
|
setter (Revit exposes several that mutate without any method call at all),
|
|
string-built or dynamically-dispatched calls (`getattr(el, 'De'+'lete')`),
|
|
code that reaches a write through a helper this list has not been told
|
|
about, or a snippet using `eval`/`exec` to build a call the scan cannot see.
|
|
A determined snippet gets through. This module only stops the request an
|
|
agent following instructions would send.
|
|
|
|
IronPython 2.7 on the extension side, CPython in the tests: no f-strings, no
|
|
pathlib, `os.path` throughout.
|
|
"""
|
|
# pylint: disable=broad-except,invalid-name
|
|
import os
|
|
import re
|
|
|
|
from vibe.snapshotmeta import MODEL_EXT
|
|
|
|
# A Revit session started by `tools/snapshot_cycle.py` carries the model it
|
|
# was launched on in its own environment. A session started from the taskbar
|
|
# does not, and cannot be made to look as though it did without somebody
|
|
# deciding to.
|
|
ENV_SANDBOX = 'WILLTOOLS_SANDBOX_MODEL'
|
|
|
|
# The denylist, semicolon-separated, for the case where the knowledge layers
|
|
# cannot be read. It is a way to STATE the answer, not a way to switch the
|
|
# guard off -- there is deliberately no off switch, because an off switch is
|
|
# the hole this module exists to close.
|
|
ENV_TITLES = 'WILLTOOLS_BRIDGE_REFUSE_TITLES'
|
|
|
|
# Extra folders whose models count as sandboxes, beyond snapshot.detached_dir.
|
|
ENV_ROOTS = 'WILLTOOLS_BRIDGE_ALLOW_ROOTS'
|
|
|
|
# The read-only list, semicolon-separated, same escape hatch as ENV_TITLES
|
|
# for a machine whose knowledge layers will not load. Stating the answer
|
|
# here does not widen what the bridge may do -- read-only is already the
|
|
# more permissive of the two lists.
|
|
ENV_READONLY_TITLES = 'WILLTOOLS_BRIDGE_READONLY_TITLES'
|
|
|
|
|
|
def normalize_title(text):
|
|
"""A title as it is compared: no model extension, trimmed, case-folded.
|
|
|
|
`doc.Title` is sometimes reported with the extension and sometimes
|
|
without, depending on version and on whether the document was opened from
|
|
a path or from the cloud, so both have to compare equal or the denylist
|
|
works on one machine and not the next.
|
|
"""
|
|
try:
|
|
text = '{0}'.format(text or '').strip()
|
|
except Exception:
|
|
return ''
|
|
if text.lower().endswith(MODEL_EXT):
|
|
text = text[:-len(MODEL_EXT)]
|
|
return text.strip().lower()
|
|
|
|
|
|
def split_titles(raw):
|
|
"""A `;`-separated (or list) denylist into a clean list of strings.
|
|
|
|
Accepts what a YAML layer produces (a list), what an environment variable
|
|
produces (one string), and a single string in the YAML -- the last is a
|
|
predictable mis-edit and reading it as a one-item list beats reading it as
|
|
a list of characters.
|
|
"""
|
|
if raw is None:
|
|
return []
|
|
if isinstance(raw, (list, tuple)):
|
|
items = list(raw)
|
|
else:
|
|
items = '{0}'.format(raw).split(';')
|
|
out = []
|
|
for item in items:
|
|
try:
|
|
text = '{0}'.format(item).strip()
|
|
except Exception:
|
|
continue
|
|
if text:
|
|
out.append(text)
|
|
return out
|
|
|
|
|
|
def _norm_path(path):
|
|
try:
|
|
text = '{0}'.format(path or '').strip()
|
|
except Exception:
|
|
return ''
|
|
if not text:
|
|
return ''
|
|
text = text.replace('/', os.sep).replace('\\', os.sep)
|
|
try:
|
|
text = os.path.normcase(os.path.normpath(text))
|
|
except Exception:
|
|
pass
|
|
return text.rstrip(os.sep)
|
|
|
|
|
|
def is_under(path, root):
|
|
"""True when `path` sits inside `root`. Case- and separator-insensitive.
|
|
|
|
A prefix test on its own says `C:\\snapshots-old\\x` is under
|
|
`C:\\snapshots`, so the separator is required after the root.
|
|
"""
|
|
path = _norm_path(path)
|
|
root = _norm_path(root)
|
|
if not path or not root:
|
|
return False
|
|
return path == root or path.startswith(root + os.sep)
|
|
|
|
|
|
def same_model(path_a, path_b):
|
|
"""True when two paths name the same model file, as strings."""
|
|
a, b = _norm_path(path_a), _norm_path(path_b)
|
|
return bool(a) and a == b
|
|
|
|
|
|
def _on_list_and_not_exempt(title, path, listed, sandbox_roots, sandbox_model):
|
|
"""True when `title` is on `listed` and this document cannot prove it is
|
|
a sandbox copy. Shared by `refusal()` (refuse_titles) and
|
|
`exec_refusal()` (readonly_titles) -- same accusation, same two alibis.
|
|
|
|
No document open (`title` falsy after normalising) is never "on the
|
|
list": there is nothing to protect and nothing to touch.
|
|
"""
|
|
banned = [normalize_title(t) for t in split_titles(listed)]
|
|
banned = [t for t in banned if t]
|
|
if not banned:
|
|
return False
|
|
|
|
here = normalize_title(title)
|
|
if not here or here not in banned:
|
|
return False
|
|
|
|
# The title is on the list. Can this document prove it is a copy?
|
|
if sandbox_model and same_model(path, sandbox_model):
|
|
return False
|
|
for root in (sandbox_roots or ()):
|
|
if root and is_under(path, root):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def refusal(title, path=None, titles=(), sandbox_roots=(), sandbox_model=None,
|
|
config_error=None):
|
|
"""None when the bridge may serve this document; one sentence when not.
|
|
|
|
`config_error` is how a caller says "I could not read the denylist". That
|
|
is NOT the same answer as "the denylist is empty", and it is refused --
|
|
a bridge that cannot prove it is not in the live model has not proved
|
|
anything. The refusal names the two ways to answer it, so the fix is in
|
|
the message rather than in somebody's memory.
|
|
"""
|
|
if config_error:
|
|
return ('REFUSED: this bridge cannot tell whether the open document '
|
|
'is the live model -- the refuse-titles configuration could '
|
|
'not be read ({0}). Fix the job/plant knowledge layer, or '
|
|
'state the answer in {1} (semicolon separated), then restart '
|
|
'Revit. Nothing was read or run.'
|
|
.format(config_error, ENV_TITLES))
|
|
|
|
if not _on_list_and_not_exempt(title, path, titles, sandbox_roots,
|
|
sandbox_model):
|
|
return None
|
|
|
|
return ('REFUSED: the open document is "{0}", a title on '
|
|
'bridge.refuse_titles -- the live model this bridge must never '
|
|
'touch. Nothing was read, run or changed. Its file is {1}, which '
|
|
'is under no configured snapshot root and is not {2}. If this '
|
|
'really is a detached snapshot, open it from under '
|
|
'snapshot.detached_dir, or launch its Revit with {2} set to its '
|
|
'path.'
|
|
.format(title, path or '(no local file -- a cloud model)',
|
|
ENV_SANDBOX))
|
|
|
|
|
|
# --------------------------------------------------------------- read-only
|
|
#
|
|
# See the module docstring's "READ-ONLY" section for the policy and "THIS IS
|
|
# A GUARD RAIL, NOT A SANDBOX" for what this does not catch.
|
|
|
|
# Grounded in what this codebase's own write idioms actually look like:
|
|
# `helpers.tx()` / `helpers.txg()` wrap `Transaction` / `TransactionGroup`;
|
|
# `helpers.set_p()` is `prm.Set(value)`; CLAUDE.md's "any change to the model
|
|
# needs a Transaction" and "never reload" sections name the rest. Matched
|
|
# case-sensitively -- Revit's own API is PascalCase, so this does not fire on
|
|
# an unrelated lowercase `.create(` / `.set(` in ordinary Python.
|
|
WRITE_PATTERNS = [
|
|
('Transaction(', re.compile(r'\bTransaction\s*\(')),
|
|
('SubTransaction(', re.compile(r'\bSubTransaction\s*\(')),
|
|
('TransactionGroup(', re.compile(r'\bTransactionGroup\s*\(')),
|
|
('vibe.tx() / tx()', re.compile(r'(?<!\w)tx\s*\(')),
|
|
('vibe.txg() / txg()', re.compile(r'(?<!\w)txg\s*\(')),
|
|
('vibe.set_p()', re.compile(r'\bset_p\s*\(')),
|
|
('Parameter.Set(', re.compile(r'\.Set\s*\(')),
|
|
('SetParameterValue(', re.compile(r'\.SetParameterValue\s*\(')),
|
|
('.Delete(', re.compile(r'\.Delete\w*\s*\(')),
|
|
('.Create*( -- element/type/family creation', re.compile(r'\.Create\w*\s*\(')),
|
|
('.New*( -- element/family-manager creation', re.compile(r'\.New\w*\s*\(')),
|
|
('ElementTransformUtils', re.compile(r'\bElementTransformUtils\b')),
|
|
('.Duplicate(', re.compile(r'\.Duplicate\w*\s*\(')),
|
|
('doc.Regenerate(', re.compile(r'\.Regenerate\s*\(')),
|
|
('doc.SaveAs(', re.compile(r'\.SaveAs\s*\(')),
|
|
('doc.Save(', re.compile(r'\.Save\s*\(')),
|
|
('LoadFamily(', re.compile(r'\.LoadFamily\w*\s*\(')),
|
|
('Purge', re.compile(r'\bPurge\w*\s*\(')),
|
|
('.Rename(', re.compile(r'\.Rename\s*\(')),
|
|
('WorksharingUtils / SynchronizeWithCentral',
|
|
re.compile(r'\bWorksharingUtils\b|\.SynchronizeWithCentral\s*\(')),
|
|
]
|
|
|
|
|
|
def write_signature(code):
|
|
"""The name of the first write-capable pattern found in `code`, or None.
|
|
|
|
A plain regex scan over the snippet text -- not a parser, and not a
|
|
defence against an obfuscated call. See the module docstring.
|
|
"""
|
|
text = '{0}'.format(code or '')
|
|
for name, pattern in WRITE_PATTERNS:
|
|
if pattern.search(text):
|
|
return name
|
|
return None
|
|
|
|
|
|
def exec_refusal(title, path, readonly_titles, sandbox_roots=(),
|
|
sandbox_model=None, tx_name=None, code=None):
|
|
"""None when `/exec` may run this request; one sentence when not.
|
|
|
|
Only reached once `refusal()` above has already let the request through
|
|
-- a title on `bridge.refuse_titles` is refused outright and this is
|
|
never consulted. `readonly_titles` is the softer rule: the document may
|
|
still be read, but nothing that could write to it may run.
|
|
"""
|
|
if not _on_list_and_not_exempt(title, path, readonly_titles,
|
|
sandbox_roots, sandbox_model):
|
|
return None
|
|
|
|
if tx_name:
|
|
return ('REFUSED: "{0}" is on bridge.readonly_titles -- the iron '
|
|
'rule is read-only, never write. This request asked to run '
|
|
'inside a transaction ("{1}"), so it was not run. Nothing '
|
|
'was changed.'.format(title, tx_name))
|
|
|
|
hit = write_signature(code)
|
|
if hit:
|
|
return ('REFUSED: "{0}" is on bridge.readonly_titles -- the iron '
|
|
'rule is read-only, never write. This snippet contains '
|
|
'{1}, which can write to the model, so it was not run. '
|
|
'Nothing was changed.'.format(title, hit))
|
|
|
|
return None
|
|
|
|
|
|
def payload(title, reason):
|
|
"""The refusal as a routes response body.
|
|
|
|
`ok: False` so every existing client already treats it as a failure, plus
|
|
`refused: True` so `tools/snapshot_cycle.py` can tell "the live Revit
|
|
grabbed the port" apart from "the sandbox is not up yet" -- two failures
|
|
that look identical from the outside and need opposite responses.
|
|
|
|
The title is reported even though the request is refused: "which model is
|
|
open" is the one question a refusal still has to answer, or nobody can
|
|
work out which session they reached.
|
|
"""
|
|
return {'ok': False, 'refused': True, 'doc_title': title,
|
|
'error': reason}
|
|
|
|
|
|
def config_from(know, getter, environ=None):
|
|
"""(titles, roots) out of the knowledge tree and the environment.
|
|
|
|
`getter` is `knowledge.get` passed in rather than imported, so this module
|
|
keeps its promise of importing nothing that reads a file. The environment
|
|
is a UNION with the configured values, never a replacement: a denylist you
|
|
can shrink by unsetting a variable is not a denylist.
|
|
"""
|
|
environ = os.environ if environ is None else environ
|
|
titles = []
|
|
roots = []
|
|
try:
|
|
titles.extend(split_titles(getter(know, 'bridge.refuse_titles', [])))
|
|
except Exception:
|
|
pass
|
|
try:
|
|
titles.extend(split_titles(environ.get(ENV_TITLES)))
|
|
except Exception:
|
|
pass
|
|
try:
|
|
roots.extend(split_titles(getter(know, 'bridge.allow_roots', [])))
|
|
detached = getter(know, 'snapshot.detached_dir', None)
|
|
if detached:
|
|
roots.append(detached)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
roots.extend(split_titles(environ.get(ENV_ROOTS)))
|
|
except Exception:
|
|
pass
|
|
|
|
seen = set()
|
|
titles = [t for t in titles if not (t.lower() in seen or seen.add(t.lower()))]
|
|
seen = set()
|
|
roots = [r for r in roots if not (_norm_path(r) in seen or
|
|
seen.add(_norm_path(r)))]
|
|
return titles, roots
|
|
|
|
|
|
def readonly_titles_from(know, getter, environ=None):
|
|
"""`bridge.readonly_titles`, unioned with its environment escape hatch.
|
|
|
|
Mirrors `config_from`'s treatment of `bridge.refuse_titles` exactly. Kept
|
|
as its own function, returning one list rather than folding a third value
|
|
into `config_from`'s tuple, so every existing caller of `config_from`
|
|
(and its test, which unpacks a 2-tuple) is untouched by this. Sandbox
|
|
roots are shared with `config_from` -- a copy is a copy whichever list is
|
|
being checked against -- so this does not read `bridge.allow_roots` or
|
|
`snapshot.detached_dir` again; callers pass the roots `config_from`
|
|
already returned.
|
|
"""
|
|
environ = os.environ if environ is None else environ
|
|
titles = []
|
|
try:
|
|
titles.extend(split_titles(getter(know, 'bridge.readonly_titles', [])))
|
|
except Exception:
|
|
pass
|
|
try:
|
|
titles.extend(split_titles(environ.get(ENV_READONLY_TITLES)))
|
|
except Exception:
|
|
pass
|
|
|
|
seen = set()
|
|
return [t for t in titles if not (t.lower() in seen or seen.add(t.lower()))]
|