app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
Drew Owens
0.9.0: bridge data moved out of the install cache (recovery survives updates), closed-in-place records, gates route thr…
5d02ca9
20d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
//! THE CHOKE POINT: one place that decides whether this bridge may write.
//!
//! Five rounds of adversarial review found five Critical defects, and all five
//! were the same shape — the bridge writing to something the user owns, through
//! a route whose guard had been forgotten:
//!
//! | Round | The route that was not guarded |
//! |---|---|
//! | 2 | `swx_run_script` built geometry into the user's open document |
//! | 3 | `targetDocument` authorized one document; the com host changed another |
//! | 4 | a title matched two open documents; `save_as` renamed the user's |
//! | 5 | `close_doc {save:true}` was an ungated `Save3`; `save_as`/`export`/`pack_and_go` silently overwrote existing files |
//!
//! Gating each route failed five times because there is always another route.
//! So the posture is inverted: **writes are DENY-BY-DEFAULT**, and every one of
//! them — every verb, and independently the com host — passes through
//! [`authorize`]. Two conditions must hold, with no per-verb exceptions:
//!
//! 1. **the document is one this bridge created, in this SOLIDWORKS session**, and
//! 2. **the destination path does not already exist.**
//!
//! Anything else needs a deliberate opt-in that a caller cannot grant itself:
//! the bridge process must be started with `SWX_ALLOW_USER_WRITES=1`, AND the
//! call must carry `allowUserWrites:true` with `confirm:true` and a `reason`.
//! Every granted exception is recorded in `swx_action_ledger`.
//!
//! WHY THE ENVIRONMENT VARIABLE, and not just the three arguments: the com-host
//! child inherits the environment, so it can check the opt-in ITSELF. Rounds 3
//! and 5 both turned on the server-authorizes / host-acts split — a server-side
//! check is a claim, not a guarantee. Now the server and the host each hold an
//! independent veto: the host has its own record of the documents it created
//! and its own view of the filesystem, and it refuses a write the server
//! "authorized" if its own answer differs. Fabricating the escape from the
//! server alone is not possible.
//!
//! What this costs: with the default posture, an AI can read everything, create
//! documents, model in them, and export to NEW paths — the whole useful surface
//! — and it cannot touch a file the user already had. That is the trade this
//! bridge should have started with.
use crate::verbs::Target;
use serde_json::{json, Value};
/// The process-level opt-in. Read by the SERVER and, independently, by the
/// com-host child (which inherits it).
pub const ENV_ALLOW: &str = "SWX_ALLOW_USER_WRITES";
/// Is the process-level opt-in on?
pub fn user_writes_enabled() -> bool {
std::env::var(ENV_ALLOW).as_deref() == Ok("1")
}
/// `"enabled"` / `"disabled"`, for `swx_status` and `swx_describe` so a caller
/// can tell which posture it is in before it plans anything.
pub fn posture() -> &'static str {
if user_writes_enabled() {
"enabled"
} else {
"disabled"
}
}
/// What a write is doing, for the message and the ledger.
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Kind {
/// Writes the document back to its own file (`Save3`).
SaveInPlace,
/// Retitles the open document onto a new path (`SaveAs3`, Silent).
Rename,
/// Writes a separate file, leaving the document alone (`SaveAs3`, Copy).
ExportCopy,
/// Changes geometry, parameters or properties in memory.
ModifyDocument,
/// Writes many files (Pack and Go).
CollectFiles,
/// Changes a SOLIDWORKS preference on a document.
Preference,
/// Closes a document — cheap to undo for a clean one, but it is still the
/// user's window, their undo history and their place in the work.
CloseDocument,
/// Ends or disturbs the user's SOLIDWORKS session — quitting it
/// (`swx_stop`), force-killing it (`swx_kill`), closing everything in it
/// (`swx_close_all_documents`), or closing a dialog this bridge did not
/// raise. Harmful BY ITSELF: there is no document to be foreign and no
/// destination to clobber, so this kind is always gated. On SOLIDWORKS
/// Connected the bridge cannot start it again, which makes quitting close to
/// a one-way door.
///
/// PLACEHOLDER-SAFE: see [`Kind::RunMacro`] for the other always-gated kind.
///
/// `swx_kill` and the dialog-closing path reach ab rather than the com host,
/// so the host's independent veto is NOT in their path — the server-side
/// check is the only one they have. That is stated plainly rather than
/// papered over: two of these verbs have one veto, not two.
EndSession,
/// RUNS THE USER'S OWN CODE inside SOLIDWORKS. A macro is a VBA project
/// with the full SOLIDWORKS API in scope and the filesystem behind it: it
/// can model, save, overwrite, delete, batch-export, quit the application,
/// or do all of those to documents this bridge has never heard of. Nothing
/// the bridge inspects about the CALL tells it what the macro will do.
///
/// So there is no "safe" case to carve out, and this kind is always gated:
/// the two conditions the rest of the choke point tests — is the document
/// ours, is the destination clear — cannot bound a macro's effects. What is
/// really being authorized is arbitrary code execution on somebody's
/// desktop, and the only honest gate for that is the human's explicit
/// opt-in, every single time.
RunMacro,
}
impl Kind {
fn verb_phrase(self) -> &'static str {
match self {
Kind::SaveInPlace => "write",
Kind::Rename => "rename and relocate",
Kind::ExportCopy => "export from",
Kind::ModifyDocument => "modify",
Kind::CollectFiles => "collect files from",
Kind::Preference => "change a preference on",
Kind::CloseDocument => "close",
Kind::EndSession => "end the SOLIDWORKS session of",
Kind::RunMacro => "run a macro against",
}
}
/// Gated even with no foreign document and no clobbered file.
fn always_gated(self) -> bool {
matches!(self, Kind::EndSession | Kind::RunMacro)
}
/// Does this touch the DOCUMENT (as opposed to only writing a new file)?
///
/// `CollectFiles` (Pack and Go) DOES: it walks the document's whole
/// reference tree and copies it, which is a bulk extraction of the user's
/// design rather than one export — and the com host already treated it that
/// way, so the two sides disagreed and the verb was dead on a real machine.
fn touches_document(self) -> bool {
!matches!(self, Kind::ExportCopy)
}
}
/// A granted write. `host_args` must be merged into whatever is sent to the com
/// host, so the host can see what was authorized — and re-decide for itself.
#[derive(Debug)]
pub struct Grant {
pub host_args: Value,
pub user_write: bool,
}
impl Grant {
/// Nothing was needed and nothing is claimed to the host.
pub fn none() -> Self {
Grant { host_args: json!({}), user_write: false }
}
/// Fold another grant in. A verb that authorizes TWICE — `swx_close_doc`
/// checks the close and, separately, the save — must send the host the union
/// of what it was granted. Keeping only the second grant is what made the
/// close authorization invisible to the host: the server said yes and then
/// told the host nothing, so the host refused on its own.
pub fn merge(&mut self, other: Grant) {
if let (Value::Object(m), Value::Object(o)) = (&mut self.host_args, other.host_args) {
for (k, v) in o {
m.insert(k, v);
}
}
self.user_write |= other.user_write;
}
}
/// Which parts of the per-call opt-in chain are MISSING. Empty means the caller
/// asked for the exception in full.
///
/// Shared with the com host on purpose. The host used to re-check only
/// `allowUserWrites`, so its "independent veto" was a WEAKER test than the
/// server's — and the two could drift apart with nothing to catch it. One
/// function, called from both sides, cannot drift; and a `Grant` now carries the
/// whole chain to the host (see [`Grant`]) so the host has the same evidence to
/// re-check rather than a single boolean.
pub fn missing_opt_in(args: &Value) -> Vec<&'static str> {
let mut missing = Vec::new();
if !args.get("allowUserWrites").and_then(|v| v.as_bool()).unwrap_or(false) {
missing.push("allowUserWrites:true");
}
if !args.get("confirm").and_then(|v| v.as_bool()).unwrap_or(false) {
missing.push("confirm:true");
}
if args.get("reason").and_then(|v| v.as_str()).map(|s| s.trim().is_empty()).unwrap_or(true) {
missing.push("reason:\"...\"");
}
missing
}
/// Did the caller ask for the exception, in full?
fn opted_in(args: &Value) -> bool {
missing_opt_in(args).is_empty()
}
fn refusal(code: &str, error: String, hint: String, extra: Value) -> Value {
let mut out = json!({
"success": false,
"errorCode": code,
"error": error,
"userWrites": posture(),
"_hint": hint,
"_reportIssues": crate::verbs::report_issues(),
});
if let (Value::Object(m), Value::Object(e)) = (&mut out, extra) {
for (k, v) in e {
m.insert(k, v);
}
}
out
}
/// The one decision. Every write in this bridge comes through here.
///
/// `target` is the document being changed (`None` when nothing is — a pure file
/// write). `destination` is the path being written (`None` for an in-place
/// change). A verb that does both passes both.
pub fn authorize(
args: &Value,
verb: &str,
kind: Kind,
target: Option<&Target>,
destination: Option<&str>,
) -> Result<Grant, Value> {
let enabled = user_writes_enabled();
let asked = opted_in(args);
// (2) The destination must not already exist. This is the guard the DSL's
// `save_as` shipped alone, correctly, while `save_as`, `export` and
// `pack_and_go` silently destroyed whatever was there.
// THREE ANSWERS. An unverifiable destination refuses outright, before the
// opt-in is even consulted — the opt-in answers "may I overwrite this?", and
// this is "I do not know what is there".
let state = destination_state(destination);
if let Destination::Unverifiable(io) = &state {
let d = destination.unwrap_or("");
return Err(crate::verbs::err_code(
"destination_unverifiable",
unverifiable_message(d, io),
UNVERIFIABLE_HINT,
));
}
let clobbers = state == Destination::Occupied;
// (1) The document must be ours.
let foreign = kind.touches_document() && target.map(|t| !t.owned).unwrap_or(false);
if !foreign && !clobbers && !kind.always_gated() {
return Ok(Grant { host_args: json!({}), user_write: false });
}
// ── DREW'S STANDING AUTHORIZATION, 2026-09-11, and the ONE rule it changes.
//
// "I despise every time you ask for help closing. You MUST find/build a way
// to safely close the docs/program."
//
// Ending the user's SOLIDWORKS session is `Kind::EndSession`, and until now
// it needed the full opt-in chain INCLUDING the process-level environment
// variable. That variable exists for a good reason — an AI must not be able
// to grant itself write access to somebody's data — and it stays in force for
// every other kind of write, unchanged.
//
// But for THIS act the human has already answered, standingly, and requiring
// the env var would mean the bridge answering "ask the user to restart me
// with a flag" — which is the exact experience the authorization exists to
// end. So `userAuthorized:true` WITH A REASON is a grant for ending the
// session and for nothing else.
//
// What makes that safe is not this check. It is that nothing is lost:
// `swx_close_all_safely` writes every open document to the recovery folder
// and verifies it on disk before anything closes, `swx_stop` still quits ONLY
// a session with zero documents open, still refuses on a dialog, and still
// never kills anything. The consent question moved; the data-safety
// guarantees did not.
if kind == Kind::EndSession && !foreign && !clobbers {
let authorized = args.get("userAuthorized").and_then(|v| v.as_bool()).unwrap_or(false);
let reason = args
.get("reason")
.and_then(|v| v.as_str())
.map(|r| r.trim().len() >= 8)
.unwrap_or(false);
if authorized && reason {
return Ok(Grant {
host_args: json!({
"allowUserWrites": true,
"confirm": true,
"reason": args.get("reason").cloned().unwrap_or(Value::Null),
"userAuthorized": true,
}),
user_write: true,
});
}
}
let what = if kind == Kind::RunMacro {
"run a macro — arbitrary code — inside the user's SOLIDWORKS".to_string()
} else if kind.always_gated() {
"end the user's SOLIDWORKS session".to_string()
} else if foreign {
format!(
"{} '{}', which this bridge did not create",
kind.verb_phrase(),
target.map(|t| t.describe()).unwrap_or_default()
)
} else {
format!("overwrite the existing file {}", destination.unwrap_or(""))
};
if !enabled {
return Err(refusal(
if clobbers && !foreign { "destination_exists" } else { "user_writes_disabled" },
format!("{verb} would {what}"),
format!(
"This bridge is in its DEFAULT posture: it will not write to anything the user \
already has — not their documents, and not over their files. That covers \
everything except documents it created itself (swx_new_document / swx_import) \
and destinations that do not exist yet. {}Five rounds of review found five \
different routes by which the bridge could write to the user's data, so writing \
is now refused unless somebody deliberately turns it on: the bridge process must \
be started with {ENV_ALLOW}=1 AND the call must carry allowUserWrites:true, \
confirm:true and a reason. Ask the user — an AI cannot grant this to itself.",
if clobbers {
"Choose a destination that does not exist yet. "
} else {
"Work on a document you created, or open a copy. "
}
),
json!({
"destination": destination,
"destinationExists": clobbers,
"targetDocument": target.map(|t| t.title.clone()),
"bridgeCreatedDocument": target.map(|t| t.owned).unwrap_or(false),
"requires": {"env": format!("{ENV_ALLOW}=1"), "args": ["allowUserWrites:true", "confirm:true", "reason:\"...\""]},
}),
));
}
if !asked {
return Err(refusal(
if foreign { "not_bridge_document" } else { "destination_exists" },
format!("{verb} would {what}"),
format!(
"User writes are ENABLED on this bridge process, but this call did not ask for \
one. Pass allowUserWrites:true together with confirm:true and \
reason:\"why the user wants this\" — all three, on the call that does it. The \
exception is recorded in swx_action_ledger with your reason. {}",
if clobbers {
"Or choose a destination that does not exist yet, which needs no permission."
} else {
"Or work on a document this bridge created, which needs no permission."
}
),
json!({
"destination": destination,
"destinationExists": clobbers,
"targetDocument": target.map(|t| t.title.clone()),
}),
));
}
crate::ledger::record(
"user_write_authorized",
json!({
"verb": verb,
"kind": format!("{kind:?}"),
"document": target.map(|t| t.title.clone()),
"path": target.and_then(|t| t.path.clone()),
"destination": destination,
"overwrote": clobbers,
"reason": args.get("reason").cloned(),
}),
);
// THE GRANT CARRIES THE WHOLE CHAIN. It used to carry `allowUserWrites`
// alone, which left the host re-checking one third of the condition the
// server had checked. Now the host sees `confirm` and the `reason` too, so
// its own veto can apply the identical test.
Ok(Grant {
host_args: json!({
"allowUserWrites": true,
"confirm": true,
"reason": args.get("reason").cloned().unwrap_or(Value::Null),
}),
user_write: true,
})
}
/// [`authorize`], plus the list of documents the caller COULD work on — the
/// field that turns a refusal into a next step. Kept separate so the decision
/// itself stays a pure function with no `Ctx` to stand up in a unit test.
/// IS THE DESTINATION FREE? THREE ANSWERS, NOT TWO.
///
/// `Path::exists()` returns `false` both for "nothing is there" and for "I could
/// not look" — a parent directory this process cannot traverse, a UNC share it
/// has no token for, a device that is not ready, a path over the length limit.
/// The write gate read the second as the first, which is the empty-to-confident
/// collapse in the one place in this bridge that decides whether somebody's file
/// survives.
///
/// It matters because the two processes DO NOT SHARE A FILESYSTEM VIEW. The com
/// host is a child of Adom Bridge; SOLIDWORKS runs in the user's interactive
/// session with the user's network token. `\\server\share\part.sldprt` that this
/// bridge cannot stat is a file SOLIDWORKS can open — and overwrite — with the
/// gate having already said yes.
///
/// `symlink_metadata` rather than `try_exists`, deliberately: a DANGLING SYMLINK
/// is something at that path, and writing through it would land somewhere the
/// caller did not name. `Ok(_)` is occupied, `NotFound` is free, and every other
/// error is UNVERIFIABLE — which refuses.
#[derive(Debug, Clone, PartialEq)]
pub enum Destination {
/// Nothing is there, and we could see that for ourselves.
Free,
/// Something is there. Overwriting it needs the full opt-in.
Occupied,
/// The question could not be answered. NOT permission.
Unverifiable(String),
}
pub fn destination_state(destination: Option<&str>) -> Destination {
let Some(d) = destination.filter(|d| !d.is_empty()) else { return Destination::Free };
match std::fs::symlink_metadata(std::path::Path::new(d)) {
Ok(_) => Destination::Occupied,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Destination::Free,
Err(e) => Destination::Unverifiable(format!("{e}")),
}
}
/// The refusal a caller gets when the destination could not be checked. Shared so
/// the two enforcement layers cannot drift into different words for one rule.
pub fn unverifiable_message(destination: &str, io_error: &str) -> String {
format!(
"this bridge cannot check whether anything is already at {destination} ({io_error}), and a \
destination it cannot check is NOT free"
)
}
pub const UNVERIFIABLE_HINT: &str =
"The write was refused because the question could not be answered, not because the answer was \
no. This bridge and SOLIDWORKS do not share a filesystem view — the com host is a child of \
Adom Bridge, while SOLIDWORKS runs in the user's session with their network token — so a \
path this process cannot stat (an unreachable UNC share, a directory it cannot traverse, a \
device that is not ready, a path over the length limit) may hold a file SOLIDWORKS would \
happily overwrite. Check the destination yourself, or use a path this bridge can see. The \
opt-in does NOT waive this: it is not a permission question.";
pub fn authorize_in(
ctx: &crate::verbs::Ctx,
args: &Value,
verb: &str,
kind: Kind,
target: Option<&Target>,
destination: Option<&str>,
) -> Result<Grant, Value> {
authorize(args, verb, kind, target, destination).map_err(|mut e| {
if let Value::Object(m) = &mut e {
m.insert(
"bridgeDocuments".into(),
Value::Array(
ctx.scratch_paths()
.into_iter()
.map(|(t, p)| json!({"targetDocument": t, "path": p}))
.collect(),
),
);
}
e
})
}
/// Merge a grant's host args into a call payload.
pub fn with_grant(mut call_args: Value, grant: &Grant) -> Value {
if let (Value::Object(m), Value::Object(g)) = (&mut call_args, grant.host_args.clone()) {
for (k, v) in g {
m.insert(k, v);
}
}
call_args
}
#[cfg(test)]
mod tests {
use super::*;
fn target(owned: bool) -> Target {
Target { title: "thing.sldprt".into(), path: Some("C:\\u\\thing.sldprt".into()), owned }
}
#[test]
fn the_default_posture_allows_exactly_the_safe_subset() {
// Ours, to a destination that does not exist: no permission needed.
let g = authorize(&json!({}), "swx_save_as", Kind::Rename, Some(&target(true)), Some("/tmp/does-not-exist-xyz.sldprt"))
.expect("a bridge document to a new path is the normal case");
assert!(!g.user_write);
assert_eq!(g.host_args, json!({}), "nothing extra is claimed to the host");
// A pure file write with no document involved.
assert!(authorize(&json!({}), "swx_export", Kind::ExportCopy, None, Some("/tmp/new-xyz.step")).is_ok());
}
#[test]
fn a_document_the_bridge_did_not_create_is_refused_by_default() {
let e = authorize(&json!({}), "swx_save_as", Kind::Rename, Some(&target(false)), Some("/tmp/new-abc.sldprt"))
.expect_err("the user's document must be refused");
assert_eq!(e["errorCode"], "user_writes_disabled");
assert_eq!(e["userWrites"], "disabled");
assert_eq!(e["bridgeCreatedDocument"], false);
// ...and the caller cannot talk its way past it without the env opt-in.
let asked = json!({"allowUserWrites": true, "confirm": true, "reason": "the user asked"});
let e = authorize(&asked, "swx_save_as", Kind::Rename, Some(&target(false)), Some("/tmp/new-abc.sldprt"))
.expect_err("args alone must not be enough");
assert_eq!(e["errorCode"], "user_writes_disabled");
}
#[test]
fn an_existing_destination_is_refused_even_for_our_own_document() {
// THE round-5 defect: save_as / export / pack_and_go destroyed whatever
// was already at the destination. Ownership of the SOURCE says nothing
// about the file being overwritten.
let dir = std::env::temp_dir().join(format!("swx-writes-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let existing = dir.join("already-here.step");
std::fs::write(&existing, b"the user's file").unwrap();
let path = existing.to_string_lossy().to_string();
let e = authorize(&json!({}), "swx_export", Kind::ExportCopy, Some(&target(true)), Some(&path))
.expect_err("an existing destination must be refused");
assert_eq!(e["errorCode"], "destination_exists");
assert_eq!(e["destinationExists"], true);
assert_eq!(
std::fs::read(&existing).unwrap(),
b"the user's file",
"and nothing was written"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_export_does_not_need_to_own_the_document_it_reads() {
// Copy mode leaves the document alone, so reading the user's part to
// write a NEW file is not a write to their data.
let g = authorize(&json!({}), "swx_export", Kind::ExportCopy, Some(&target(false)), Some("/tmp/fresh-xyz.step"))
.expect("exporting FROM the user's document to a new file is allowed");
assert!(!g.user_write);
}
#[test]
fn opting_in_needs_all_three_arguments() {
for partial in [
json!({"allowUserWrites": true}),
json!({"allowUserWrites": true, "confirm": true}),
json!({"confirm": true, "reason": "x"}),
json!({"allowUserWrites": true, "confirm": true, "reason": ""}),
] {
assert!(!opted_in(&partial), "must not count as an opt-in: {partial}");
}
assert!(opted_in(&json!({"allowUserWrites": true, "confirm": true, "reason": "the user asked"})));
}
}