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"})));
    }
}