Trust and safety¶
This page exists to answer one question: can this thing hurt my computer, or download something that will?
Every claim below names the file that implements it, so you can check rather than believe. Where a guarantee is partial, it says so. A security tool that overstates its own guarantees is teaching you the wrong habit.
The short version¶
| Question | Answer |
|---|---|
Does rescue --auto change my system? |
No. Zero shipped modules opt in to unattended change. |
| Does it ever ask for a password, 2FA code, or recovery key? | No. There is no such prompt anywhere in the tool. |
| Does it send anything anywhere? | Only if you explicitly turn on the AI layer. Otherwise nothing leaves the machine. |
| Can an update push new Python code to my machine? | Not through the sanctioned path. Updates carry data files only, and content commits containing anything else are rejected. |
| Can I read what a check does before running it? | Yes. Every module is one plain Python file in the source tree. |
| Does it verify it has not been tampered with? | Yes, at every launch, with a SHA-256 manifest — but the check warns and continues rather than blocking. |
What it reads, and what it writes¶
Reads. Local system state: process lists, disk and mount information,
network interfaces and the neighbour table, systemd units and cron entries and
launch agents, installed package lists, log and journal output, firewall and
SSH configuration, browser extension manifests, startup items, keychain
metadata. It does this by reading files and running standard system query
commands (systemctl, ip, defaults, powershell, ufw, and friends).
Never reads. The tool has no code path that opens a password manager
vault, extracts keychain or Credential Manager contents, reads browser
history or page contents, or opens your documents and photos. The
evidence_bundle module prints this list explicitly when it runs, under
"Never collected, under any circumstances".
Writes. Only these, and only when you ask:
| Path | Written by |
|---|---|
~/.rescue/sessions/<profile>.json |
rescue guide recording your progress |
~/.rescue/cases/case-<timestamp>.{json,md} |
rescue export |
~/.config/rescue/revoked_signers.json |
rescue trust revoke |
~/.local/share/rescue/content/ |
rescue update, after signature verification |
Case files are written 0600 where the filesystem supports it, because even a
redacted case describes a machine's security posture and lands in a home
directory other local accounts may be able to read.
Auto mode is read-only¶
This is the claim people most want proven, so here is the whole chain.
1. The default. rescue/module_base.py:
class ModuleBase(ABC):
...
# Opt-in to unattended mutation in auto mode. Default False keeps auto mode
# read-only: a module is only auto-applied once its fix() is known to be an
# idempotent, low-impact, reversible SAFE mutation and it sets this True.
auto_apply: bool = False
2. The gate. rescue/orchestrator.py, in run_fixes:
if mode == Mode.AUTO and (
mod.risk_level != RiskLevel.SAFE
or not getattr(mod, "auto_apply", False)
):
# Auto mode is read-only unless a module has explicitly opted
# in to unattended, idempotent SAFE mutation via `auto_apply`.
continue
Both conditions must hold: SAFE and auto_apply = True. SAFE alone is
not enough, deliberately.
3. The count. Verify it yourself:
Zero. Not zero set to True — zero mentions of the attribute at all across
all 287 shipped modules, so every one of them inherits False. As long as that
number is zero, rescue --auto cannot change anything, and the summary line
reflects it:
Scanned 26 module(s), found 20 issue(s). Auto mode is read-only: made 0 system
change(s); 0 manual action(s) require you.
4. The guardrail on the flag. If a module ever does set auto_apply = True
at a non-SAFE risk level, rescue validate reports it as an error
(rescue/validate.py), and CI runs rescue validate on every pull request —
errors fail the build.
5. What is not covered. rescue run <module> --yes and answering y to a
confirmation prompt both call fix() regardless of auto_apply, and fix()
may mutate. That is the point of those flags. The read-only guarantee is about
unattended operation.
Guidance is not a change¶
The data model separates the two, in rescue/models.py:
and a system change is only counted when a mutation actually executed and succeeded:
@property
def executed_mutations(self) -> list[Action]:
return [
a for a in self.actions
if a.kind == ActionKind.MUTATION and a.executed and a.success
]
A module cannot make "here is what you should do" appear in a report as
something that was done, even by setting executed/success flags on a
guidance action. The case export applies the same rule
(rescue/case.py), so a report you hand to someone else cannot overstate what
happened either.
It never asks for your secrets¶
There is no prompt in the tool for an account password, a one-time code, a recovery code, a seed phrase, or a password-manager master password. The only interactive prompts that exist are:
Apply fixes for <module>?(yes/no)Apply this update?(yes/no)- the free-text conversation in
rescue recommend, which is optional and only runs when you have configured an AI provider
Profiles state this explicitly. digital_security_reset ends its description
with: "This tool never asks for an account password, a one-time code, or a
recovery code." Account work — password changes, enabling 2FA, revoking
sessions — happens on each provider's own website, done by you, in the guide.
Some system checks may cause your operating system to prompt for your
administrator password (macOS authorisation dialogs, sudo, Windows UAC).
That is the OS asking, on its own dialog, and the tool never sees or stores
what you type. If something claiming to be this tool asks you to type a
password into it, it is not this tool.
The self-integrity manifest¶
rescue/security/integrity.py ships a SHA-256 manifest of the tool's own
installed Python files — 56 entries covering rescue/**/*.py, stored in
rescue/security/integrity_manifest.json. Every launch recomputes those hashes
and compares.
$ rescue update --check
WARNING: rescue's own installed files do not match the expected integrity manifest.
modified: cli.py
Consider reinstalling the tool. Continuing with existing files.
That is real output from a development checkout where cli.py changed after
the manifest was last regenerated. It detects modified, missing, and added
files, and it needs no network access and no trust in anything beyond the local
filesystem.
Be precise about what this is and is not.
- It is advisory, not blocking.
_run_startup_integrity_checkinrescue/cli.pyprints the warning and continues; the whole function is wrapped in a bareexcept Exception: pass. It is a tripwire, not a lock. Malware with write access to the install could equally rewrite the manifest. - It covers the
rescue/package's Python files only. Module data (modules/*/data/*.json) and guide content (guides/**/*.md) are deliberately excluded, because those are exactly the filesrescue updateis designed to legitimately change — hashing them would make every successful content update look like tampering. Module code undermodules/is likewise not covered by this manifest. - It is skipped inside a PyInstaller bundle, where there are no loose
.pyfiles on disk to hash. - CI keeps it honest: the
integrityjob regenerates the manifest and fails the build on any diff, because a stale manifest prints a tamper warning on every launch and trains users to ignore the one signal that matters.
Signed content updates¶
rescue update is the only command that brings anything onto your machine from
outside. Four separate properties constrain it.
1. Data only, never Python. A content commit is rejected unless every path
in it passes validate_content_paths (rescue/update/manifest.py):
- the first path segment must be
modules/,guides/, orprofiles/ - the extension must be one of
.json,.md,.toml,.txt,.yaml,.yml - anything under
guides/must be.md; anything underprofiles/must be YAML - absolute paths and
..traversal are rejected outright
A .py file cannot satisfy that list, so an approved update cannot deliver
executable code through the sanctioned path.
2. Two independent maintainer approvals. rescue/update/config.py sets
required_approvals = 2. Approval means a maintainer has pushed a git tag,
signed with GPG or SSH, named approved/<signer_id>/<content_version> and
pointing at the exact commit. A commit is only accepted once two distinct
trusted, non-revoked signers have valid signatures on it.
3. Verification never trusts your local keyring.
rescue/update/verify.py builds a throwaway GNUPGHOME and a scratch
allowed_signers file per call, populated only with the public keys shipped
in rescue/security/trusted_signers.json, and points git verify-tag at that.
Trust is decided by what the deployment ships, never by what happens to be
configured on your machine. It does no cryptography of its own — git
verify-tag does all of it — and any nonzero exit, unexpected output, or parse
failure is treated as "this tag does not count". Fail closed, always.
4. Placeholder keys are rejected. validate_trusted_signers in
rescue/security/signers.py raises if any signer's ID, key ID, or public key
starts with REPLACE_WITH_, or if the public key is missing, or if there are
fewer signers than required approvals. The update engine runs that check on
construction.
On the current release, rescue update cannot apply anything
The shipped trusted_signers.json contains three placeholder entries with
REPLACE_WITH_… key material. Real key custody and rotation are a human
task the project has not completed (roadmap P0#3: "real signer key
material, custody, rotation, revocation, threshold approvals, and the
release-signing procedure"). The result today:
$ rescue update --check
Update failed: trusted signer configuration contains placeholder or missing key material
Continuing with existing content.
Exit code 1, nothing fetched, nothing applied. The software guard works;
the keys behind it are not real yet. Until they are, treat the update
channel as closed and get new versions the way you got this one.
Revoking a signer locally. If a maintainer's key is compromised, you do not have to wait for a new threshold-approved commit:
$ rescue trust revoke maintainer-a --reason "key compromise announced 2026-08-01"
$ rescue trust list-revoked
maintainer-a
Revoking below the threshold means updates stop applying — which is the correct failure.
Air-gapped updates. rescue update --sideload <bundle> takes a local git
bundle instead of a network fetch and runs it through the identical
verification and apply pipeline. There is exactly one place signatures are
checked and exactly one place a checkout happens, regardless of transport.
An update you can back out of¶
A safety property that matters as much as verification: you can undo an update without the network and without a working trust root. Two commands, neither of which fetches anything.
rescue update --rollback returns to the content version applied before the
current one. Notably it re-verifies approval on the older commit rather
than trusting that it was approved when it was applied — because you may have
revoked a signer since, and revocation is meaningless if content that signer
approved stays trusted just by virtue of already being on the machine. If the
previous version no longer clears the threshold, it refuses and says so, rather
than quietly rolling forward or back.
rescue update --use-bundled deactivates downloaded content entirely and
returns to what shipped inside the installed package. It removes the applied
marker that runtime.active_content_root() gates on; nothing is deleted,
so rescue update can reactivate content later. Crucially, it does not
construct an UpdateEngine, because doing so would validate the
trusted-signer configuration — and the machine whose trust config is broken is
precisely the one that most needs to get back to known-good content. It works
on this repository today, where the shipped signer keys are still placeholders.
Together with the fetch/apply separation, that means no state a content update can put your machine into is a state you cannot leave locally.
Nothing is sent anywhere unless you opt in¶
There is no telemetry, no analytics, no crash reporting, and no update ping in
the tool. rescue update contacts a git remote when you run it, and the AI
layer contacts a provider when you enable it. That is the complete list of
outbound network activity initiated by the tool itself. (Individual checks read
local state; some, like a network speed test, use the network by their nature —
read the module if you care.)
The AI layer requires both an environment variable to be set and an
explicit opt-in on the command line: --copilot, rescue explain, or
rescue recommend. Without a provider configured it prints "This feature
requires an AI provider" and stops. What is sent is one line per finding —
[category/module] (severity) title: description — and nothing else. See
Privacy for the full payload.
Read the source of any check¶
There is no plugin marketplace and no runtime code download. Every check is one file:
$ ls modules/security/linux_firewall_check/
__init__.py
$ less modules/security/linux_firewall_check/__init__.py
What you will find, in order: a docstring explaining what the check looks at
and why; a class body declaring name, category, platforms, risk_level,
estimated_duration, and emits_codes; a check() method that only reads;
and a fix() method that builds Action objects. Anything that runs an
external command goes through rescue.command.run, which enforces a timeout,
caps captured output, refuses shell strings, and never raises. Anything that
walks the filesystem goes through rescue.fsbounds.bounded_walk, which enforces
depth, file-count, byte, and deadline limits and does not follow symlinks by
default.
The module catalog lists all 287 with their platforms, risk level, and duration.
Where the guarantees are partial¶
Stated plainly, because you should know before you rely on them.
Module discovery imports Python from your install directory
rescue/registry.py loads every modules/*/*/__init__.py with
importlib and executes it in-process, at discovery time — before you
choose anything to run. That means anything with write access to your
modules/ directory can execute code as you, and it means a scan's blast
radius is the whole tree, not just the module you picked.
This is tracked as roadmap P0#10 ("Discovery executes arbitrary Python"), and the fix — running modules in an isolated process — is a Phase-4 architecture change that has not been done. Until then: install from a source you trust, keep the install directory writable only by you, and read modules you have reason to doubt.
load_content_module resolves a .py helper through the content path
A few network and cryptojacking modules share a helper
(modules/network/lan_common/neighbors.py) loaded by path via
rescue.runtime.load_content_module, which resolves through content_file
— and content_file prefers an applied content checkout over the bundled
tree. The sanctioned update path cannot put a .py file there, because
validate_content_paths rejects it before checkout. But if something wrote
directly into ~/.local/share/rescue/content/, bypassing rescue update
entirely, that helper would be loaded from there. Treat that directory with
the same care as the install directory.
Not every module uses the bounded runner yet
rescue/command.py and rescue/fsbounds.py exist because modules
historically called subprocess.run directly, often with no timeout.
Migrating every remaining call site is an in-progress mechanical follow-up
(roadmap P0#7). The orchestrator's per-module 60-second timeout bounds the
session regardless — but note the comment in rescue/orchestrator.py:
Python cannot forcibly kill a thread blocked in a syscall, so a timed-out
check is abandoned on a daemon thread, not killed.
rescue explain bypasses the orchestrator
It calls mod.check(profile) directly in a loop, so the per-module timeout
does not apply to it. scan, --auto, and export all go through the
orchestrator and are bounded.
Detection is indicators, not proof
Findings are observations. An inventory finding exists so you can spot the entry you do not recognise — that is the detection mechanism for anything novel, and it depends on you reading it. Nothing here replaces professional incident response for a real, active compromise.
Verifying what you installed¶
There is no official package or binary to download
This project is not published on PyPI, and nothing in CI builds or publishes a release artifact. The only supported way to get it is a git checkout of https://github.com/lizTheDeveloper/multiverse-device-rescue.
That means: a PyPI package named multiverse-device-rescue is not this
project and should not be installed; and an installer or binary someone
hands you was built by that person, not published by this project. The
repository does contain packaging scripts (scripts/build-macos-app.sh,
scripts/build-windows.bat) so a maintainer — or you — can build a desktop
app locally, but a build is only as trustworthy as whoever ran it.
- Install from source you can read.
git clone, thenpip install .. You cangit log,git diff, and inspect every module before anything runs. - Check the integrity manifest after install. Launch any command (e.g.
rescue version) and confirm noWARNING: rescue's own installed files do not match…appears. That compares your installedrescue/**/*.pyagainst the shipped hashes. -
Regenerate and compare, if you want to be sure the manifest matches the source you read:
$ python scripts/generate_integrity_manifest.py $ git diff --exit-code -- rescue/security/integrity_manifest.jsonNo diff means the committed manifest describes exactly the source in your tree. This is the same check CI runs. 4. Validate the catalog.
6. Run one module at a time first.rescue validateconfirms module names are unique, dependencies resolve without cycles, no module claims unattended mutation at a dangerous risk level, and every profile and guide reference resolves. It executes no module'scheck(). 5. Confirm auto mode is read-only on your copy:rescue run <name>and answerNat the prompt. You lose nothing by looking before you leap.