# -*- 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'(?