WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s

History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
This commit is contained in:
2026-09-09 08:35:15 -05:00
commit 25592fa6c2
406 changed files with 223435 additions and 0 deletions
+423
View File
@@ -0,0 +1,423 @@
# -*- 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 ntpath
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):
"""Normalize a model path. These are always WINDOWS paths (the bridge
only ever names Revit model files, drive letters and all) even when this
code runs on a Linux dev box, so the normalization has to use `ntpath`
rather than `os.path` -- `os.path.normcase` is a no-op on POSIX and would
silently break the case-insensitivity this promises.
"""
try:
text = '{0}'.format(path or '').strip()
except Exception:
return ''
if not text:
return ''
text = text.replace('/', ntpath.sep).replace('\\', ntpath.sep)
try:
text = ntpath.normcase(ntpath.normpath(text))
except Exception:
pass
return text.rstrip(ntpath.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 + ntpath.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()))]