Security: what the bridge protects, what it does not, and how to report a problem
Written from the code at the commit this file ships with, not from a whitepaper. Where a sentence makes a claim, the module that implements it is named so the claim can be checked. The companion page PRIVACY.md lists every host the bridge talks to.
The shape of the thing
- A Python process (
src/server.py) runs on your computer next to Excel and
reads the open workbook live over COM (src/com_layer.py). The file on disk
is not on its path.
- A VBA add-in (
addin/) puts the AI Bridge tab on the ribbon. It talks to the
bridge through small JSON files under %LOCALAPPDATA%\ExcelAIBridge, not
through a socket: bind, connected, health, activity, the report request.
- An AI client (claude.ai, or a local client over stdio) calls the bridge's
tools. In the hosted path the client reaches the bridge through the broker at
bridge.ram.sh, which forwards frames over a WebSocket the bridge dialled out
(src/broker_client.py, broker/src/index.ts). The bearer the client
presents travels with those frames and is checked by the bridge itself, which
is what answers 401 when it does not accept it. Once the sign-in for AI
clients moves, bridge.ram.sh will also run the sign-in that hands the client
that bearer, instead of carrying one the bridge runs.
The bridge can also be reached at an address of your own rather than through bridge.ram.sh: the no-broker mode. On the Python bridge that mode runs a full sign-in of its own, on that address. On the C# bridge the no-broker mode serves the static connector token only - it is not a path for an AI client that signs in, so not a way to connect claude.ai, and every sign-in address on it answers 404 - and that becomes true of every bridge once Python retires.
What is protected, and by what
| Threat | Protection | Where |
|---|---|---|
| The AI reads a cell you marked private | Every tool result is masked at the exit of the process: cells under a workbook Name beginning AI_PRIVATE come out as [redacted]. Masking happens before the value can reach the conversation, because a value that has left cannot be un-sent. | src/guard.py |
| The AI writes over data you needed | The prior contents of every written cell go into a local journal before the sheet is touched; undo_last_write restores them. Kept per workbook, newest 200 records. | src/journal.py, src/bridge.py |
| Cell text that looks like instructions steers the AI | Text that looks like a command, a link or an instruction is returned as data and flagged (suspicious_text), never hidden and never executed. | src/bridge.py |
| Someone on the internet reaches your open workbook | The HTTP transport does not start without a bearer token; unauthenticated HTTP is not a mode the server has. The hosted path adds a per-bridge token that the broker stores only as a sha256, and a Google sign-in on the connector: the Python bridge runs that sign-in itself, on bridge.ram.sh's address or on your own, and the C# bridge will use the one bridge.ram.sh runs. Either way the bearer arrives here, at this bridge, and this bridge is what answers 401 when it does not accept it. On a C# bridge linked to bridge.ram.sh, who may use it is decided by bridge.ram.sh: it hands a pass only to your seat's own address, and this bridge admits a valid pass with no list on this computer consulted - those lists only name addresses in its startup line. On the Python bridge, who may use it is decided here, and by one list rather than a combination: in the hosted path your seat's own address, from the file naming your seat. A file that is there but cannot be read, and a file that has gone from a bridge that registered with it, are answers in themselves - nobody is let in - and not a fall-back to something wider. The list bridge.ram.sh serves (your seat's address and any addresses we allow on every bridge) is used only when that file reads cleanly and names no address, or when there is no such file at all on a bridge that registered from a code rather than from the file. A file that cannot be read is nobody either way, whichever way the bridge was set up. Failing all of that it is the lists on this computer, in order: an EXCEL_BRIDGE_ALLOWED setting, an entry in our own vault - both ours, and a machine that cannot reach the vault reads it as empty - and then your own allowed.json. A list that comes out empty lets nobody in. | src/server.py (require_token), src/connector.py, broker/src/index.ts, excel/AIBridge.Mcp/BridgeServer.cs, excel/AIBridge.Mcp/Link/GoogleVerifier.cs |
| A web page open in your browser files reports as you, or reads your last error | The report page listens on its own loopback port with a per-process key that only the ribbon learns through connected.json; a cross-site request cannot guess it. | src/server.py (REPORT_KEY) |
| The bridge's identity is copied off the machine | The broker token and the workbook passwords are DPAPI blobs bound to your Windows account, with application entropy, never LOCAL_MACHINE. Another account on the same PC, or the same file on another PC, cannot decrypt them. | src/keystore.py, src/broker_client.py |
| A report leaks workbook content | Reports carry addresses, names and counts, never contents; an error message that quotes a value is redacted against that call's own values; anything that looks like pasted rows is refused. | src/support.py (guard, redact), src/audit.py |
| Two bridges save one workbook and clobber it | The registry refuses a second bridge on a path another process holds, and fails closed when it cannot read the registry. | src/registry.py, src/recovery.py |
Every tool call is appended to a local audit line: who called (the Google
account, token:<id>, or local), which tool, which sheet and range, how many
cells were masked. Contents never. privacy_report shows the current private
addresses and the recent audit.
What is not protected: a same-user local agent
The threat model has one honest line in it. **Anything running as your Windows user can do what you can do.** That includes an AI agent with a shell on this machine. Such a process can:
- attach to the running Excel over COM and read the open workbook, private
cells included, without going through the bridge at all;
- read the DPAPI blobs, because they are decryptable by anything running as
this user;
- read the journal and the audit files.
The bridge's guard is for the AI on the other end of the tools, which is
the normal case: claude.ai, or a client that only sees what the tools return.
It is a bar against the reasonable-looking workaround ("the tool is slow, I
will just open the file"), not a wall against a process that is hunting. The
code says so in its own words (src/lock.py, first docstring).
Encrypt-on-bind, and a stolen laptop
While a workbook is bound the bridge puts an open password on its file, with
the password in the DPAPI keystore, and takes it off at unbind and at exit.
This is on by default; EXCEL_BRIDGE_LOCK_ON_BIND=0 turns it off. An
Excel 97-2003 .xls is never passworded (#1872): Excel would ask on every save
whether to convert it to Office Open XML, so it stays readable on disk while
bound, and the bind reply says so (locked: false with a lock_error).
- With it on, the file on disk is an encrypted OLE container while bound. That
stops anything that only has the FILE: a sync or backup copy, a viewer, a
program that opens the .xlsx without the password, and a laptop stolen
mid-session, which exposes the file only to someone who can also sign in as
you. The password is in the DPAPI keystore and decrypts only on this machine
under this Windows account.
- It does not stop a program running as you. The keystore is in the same
%LOCALAPPDATA%\ExcelAIBridge folder and decrypts for any process of your
user, so such a program can read the password first and then open the file;
the keystore's extra entropy value is in the shipped source and is not a
secret either. This was measured (issue #944), not assumed. It is the
same-user limit described in the section above, not an extra defence against
it: the disk lock does not keep private cells from a local agent.
- A crash, a killed Excel, or a reboot before unbind leaves the file
encrypted. The next start of the bridge runs the recovery before it binds
anything and unlocks every workbook it holds a key for (capped at two
minutes; python src/recovery.py does the same by hand). What recovery
cannot undo: a destroyed Windows profile or a reinstalled OS makes the
keystore undecryptable, and a file left encrypted at that moment with it.
That is the risk you accept with the default on; turn it off if the
machine is not backed up.
- With it off, a stolen laptop exposes whatever the disk exposes: the
workbook file is as readable as it was before the bridge existed. Use BitLocker.
Never treat the keystore as a backup. It is crash recovery for this machine only.
Trust boundaries outside the machine
- bridge.ram.sh forwards frames and stores registrations, installation
records and seats. Your bridge's own token is kept there as a sha256 and never in the clear; your seat's email address is kept in clear, because that is what a seat is identified by. Per-request logging is on, at every request, kept three days: the caller's IP address, country, User-Agent and referer, each request's status and timing and its path with the ids in it masked, and the public parameters of a sign-in request. Sign-in codes, state values, cookies and the authorization, bridge and admin token headers are masked there - Cloudflare's behaviour as measured on 2026-09-19, not a guarantee we make. What our own code writes to the log is method, path, status and duration, never a body, a token or an Authorization header. It is not a place where workbook data rests. Once the sign-in for AI clients moves here it will also run that sign-in, and would then hold the key that passes are signed with, as a service secret outside the database - each pass lasts fifteen minutes and is not stored; each AI client's secret as a one-way hash; a one-way hash of the renewal key in force, enough to retire it and to notice it being reused and not enough to use it; and a sign-in and renewal audit carrying no code, secret or token. Whoever holds the signing key can make a pass for any seat, so that one key is what the arrangement rests on; rotating it cancels every pass made under it, which is the step that closes a breach after the intruder is out.
- staff.ram.sh receives reports and turns them into private-repository
issues. Report text is treated as untrusted input by the staff tooling: copied, never followed.
- The AI vendor sees what the tools return. The mask is the only thing
between a private cell and that vendor; that is why it lives at the exit of the process and not in a prompt.
Reporting a vulnerability
Use AI Bridge → Report a bug on the ribbon, or write to support@ram.sh. Say "security" in the first line. Do not put workbook contents in the report; addresses and a description are enough. You will get an acknowledgement by mail if you leave an address, and a "fixed in" mail when a release carries the fix.