WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
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:
@@ -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()))]
|
||||
Reference in New Issue
Block a user