rmguard is itself a security-sensitive program: it reads credential-adjacent files, runs
git inside repositories it does not trust, and can spawn MCP servers. This file is the
full design (what it reads, what it never reads, how output is redacted), the test-only
knobs, and every documented floor: a limit that is known and kept by design. The
README has the short version.
- No secret leakage: Scanners report key names, file existence and permissions — never values. The one deliberate exception is variables that relocate an agent's home or redirect its traffic (
CLAUDE_CONFIG_DIR,*_BASE_URL,HTTPS_PROXY,OLLAMA_HOST, ...): their path or host is not a secret and is shown after sanitization, with URL userinfo dropped;ANTHROPIC_CUSTOM_HEADERSis recorded by presence only. Anything that echoes user-controlled text into a finding (a hook command, an MCP launch line, a registry URL, a git config value) passes through one redaction helper that understands URL userinfo and query parameters,key=valueandkey: valuepairs, HTTPAuthorizationschemes, curl/wget basic-auth and header forms, connection strings, compact JSON bodies and quoted values with whitespace — and keeps the parts a finding needs (the host, the binary,| bash). The guarantee is tested end to end, one canary per surface, with positive controls. The account and organization identifiers Claude Code puts in synced-skill directory names (~/.claude/skills/synced/<org>_<account>/) are masked as<org>_<account>in every output and in--diff, including against a baseline written before the mask. - Attacker text cannot forge the report: every string an attacker chooses (a package version, an extension name, a plugin ID, a probed server's error message, hook and helper commands, skill paths, extension directory names) is sanitized when it is extracted, every finding's title, location and evidence is sanitized again in the terminal renderer, and the whole terminal report is rendered from a copy in which no string can carry a control character. The terminal output keeps only the SGR codes the renderer itself emits (no conceal, black or background codes), so no ANSI sequence or line break can draw a green "clean" line.
--diffoutput is sanitized per line and keeps no escape sequence at all. Text the tool scans for injection (tool descriptions, server instructions) is kept raw for the scanner and never printed. - Adversarially reviewed: the redaction, the git walk, the probe and the diagnostics have each been through multiple rounds of multi-agent adversarial review in which every finding was independently reproduced by a second agent before being accepted. Several of those rounds found that a fix had introduced a second edge; each is documented in the commit history along with the floors that remain by design.
- SSH passphrase detection: Uses
ssh-keygenprobing for OpenSSH-format keys (the PEMENCRYPTEDmarker is unreliable for modern key formats); reports a tri-state (encrypted / no-passphrase / unknown) so a missingssh-keygenis never reported as "unprotected". - MCP probing is opt-in:
--probe-mcpspawns local MCP servers and is gated behind an explicit flag with a runtime warning; each server runs in its own process group under a watchdog, its output is read with enforced deadlines, and the whole group is killed when the probe ends or the user interrupts. - Telemetry export is opt-in and content-free:
--otelexports OTLP traces/metrics of scan timing; ambientOTEL_*env never enables it; only labels, counts, durations and fixed outcome categories are exported (attribute keys are a closed enum, attribute values are&'static str, integers or booleans, and span names are joined from&'static strparts, so no runtime text can become an attribute or a span name;compile_faildoc tests, run in CI, check both); child processes never inheritOTEL_*variables, so a collector API key never reaches a probed server; a dead collector costs ≤2 s and never changes the report or exit code. Tested end to end with canaries in every scanned surface, a fake-tool canary for child environments, and a check that a default run with everyOTEL_*endpoint variable set never connects to the collector port they name (rmguard's own process; tools it runs for--versionare not covered). - HTML XSS prevention: Report data is base64-encoded in script tags to prevent injection; all user content is HTML-escaped including single quotes.
- Input validation:
--format,--fail-on,--walk-budget,--otel-instanceand the other flag values are validated at parse time with clear error messages (a usage error exits 2). An unknown--skipcategory is awarning:line that lists the valid names; it is ignored and the scan goes on. - No
/tmpfallback: Fails fast if$HOMEcannot be determined rather than scanning a shared directory. - What is read from
/proc(Linux), and why: only the scanning user's own processes (stat(2)of/proc/<pid>, owner = euid) are looked into, and nothing is spawned. Thelivenessscanner checks that a.in_use/<pid>marker under~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/names a live process (thatstatonly; the marker is an OBSERVED Claude Code signal, not documented: the plugin docs describe only.orphaned_at), and reads/proc/<pid>/cmdline(at most 16 KiB, at most 8,192 processes scaled by--walk-budget) to see whether a process runs from an npx cache: each argument is compared in memory by path prefix against~/.npm/_npx/, only the hex<hash>directory name and a process count are kept, the buffer is zeroed before the next process, and no argv text is stored, logged, traced or printed (a test plants canary arguments and checks every output format and--trace). The command line is read because nothing else finds these processes: the npx-launchedplaywright-mcpservers this was written against run as the systemnodewith theircwdin a project and no open descriptor or mapping under the cache, soexe,cwd,fd/*andmapsmiss all of them (checked 2026-10-24). Thelistenersscanner reads/proc/net/tcp,/proc/net/tcp6,/proc/stat, and per own processcomm,stat, theexeandcwdlinks,fd/*link targets andtask/<tid>/children; it never openscmdlineorenviron. No scanner opens/proc/<pid>/environ. Floors: off Linux there is no/procand neither signal is reported; an npx cache moved bynpm_config_cacheis not followed. - Bounded, regular-file-only reads: Config files are read up to 1 MiB; identity files (
package.json,METADATA,plugin.xml) are read as a bounded head so padding cannot hide a package; readers refuse FIFOs and devices so a planted named pipe cannot stall the scan; subprocesses (git config,git ls-files,--versionprobes) run under timeouts with stdin closed, and the git-tracked check refuses, without spawning git, a repository whose HEAD, index, config or commondir is not a regular file (behind agitdir:file too) or whose config includes a non-regular file. Each repository is listed once per scan (git ls-files -z), and all tracked-file listings share a 20 s budget, so a repository built to stall git costs one timeout, not one per file. Directory walks are budgeted, and budgets are spent on directories rather than files so junk files cannot exhaust them. - The scanner cannot be turned against itself by a repository: rmguard runs
gitinside project directories it does not trust, which is the GitSpawn shape it catalogues (a repo-localcore.fsmonitororcore.hooksPathturning any git call into code execution). Every working-tree git invocation goes through one hardened command (-c core.fsmonitor=false -c core.hooksPath=/dev/null -c safe.bareRepository=explicit, withGIT_DIR-style environment scrubbed). Thegitprogram itself is resolved once, by absolute path, from the absolute PATH entries only, and the child's PATH keeps only those: git runs with its working directory inside the repository, and a relative entry (.,node_modules/.bin, an empty entry) would otherwise find agitthe repository ships (a canary test plants one and runs withPATH=.:/usr/bin:/bin), and a canary test commits a repo whose fsmonitor would create a file and asserts the scan leaves it uncreated. This was found by automated review after the fact: an earliergit ls-filescheck was exploitable, which is exactly why the catalogue entry exists. - System tools never come from PATH: the hostname and kernel version are read with
uname(2)(nohostnameorunameprocess at all), and the system tools rmguard does run (ps,ssh-keygen, and on macOSsw_versanddefaults) are taken by absolute path from/usr/bin,/bin,/usr/sbin,/sbinor NixOS's/run/current-system/sw/binonly, with that as their PATH, under a timeout and an exec span; a missing tool is an "unknown" answer, never a PATH lookup. A canary test puts a fake of each tool first on PATH and asserts none runs. (The AI tools whose--versionis read are found on PATH by design: that is how they are installed.) - Fuzzed & schema-validated: Untrusted-input parsers (MCP config, threat catalog,
.envand its agent overrides, settings hooks and helpers, skill dynamic context andallowed-tools, marketplace git files,OLLAMA_HOSTreaders, diff, the redaction helper) havecargo-fuzztargets; Blueprint output is validated against the vendored CycloneDX 2.0 schema in CI. - CI-gated: every push runs the full suite on Linux and macOS, denies clippy correctness lints, keeps the fuzz targets compiling, and audits the dependency tree against RustSec advisories (
cargo audit) — a supply-chain scanner should hold its own dependencies to the standard it scans for.
Two end-to-end suites run the shipped binary against planted fixtures:
tests/vulnerable_range.rsbuilds a deliberately-vulnerable "machine" (a threat-catalog MCP server, acurl | bashhook, MCP auto-approval, a poisoned rules file, a toxic-flow skill, a world-readable.env, a world-readable agent transcript store) and asserts the binary catches every planted issue. It's both a regression guard and a reference for "what a compromised machine looks like."tests/no_secret_leakage.rsplants a distinct credential in every surface that echoes user-controlled text — MCP launch args and commands, hook and task commands, every package-registry config, agent configs, marketplace URLs, a token file, agent transcripts (key names and token labels only) — renders every output format, and asserts none of them survives. Each fixture also carries a positive control (its host or provider name) that must appear, so a fixture the scanner never read cannot pass as "clean."
To generate a shareable demo report from the vulnerable fixtures, point --search-dirs
at a scratch copy and render HTML — the planted issues surface at the top of the report
(plus anything real on the host, since your own home directory is scanned too).
The end-to-end tests run the debug binary with HOME and RMGUARD_TEST_SYSTEM_ROOT pointed at temp directories; the latter re-roots the machine-wide paths (/etc/claude-code, /etc/systemd/system, /Library/...) so a developer machine's own managed settings or Ollama unit cannot change a test's result. Release builds ignore it, so run the suite in debug (cargo test, as CI does): under cargo test --release those tests would read the host's real system paths.
A floor is something rmguard knowingly does not read or does not grade. Most are stated next to the behaviour they limit in COVERAGE.md; the longer lists are here.
Floors (documented but not read):
- Claude Code: inline hooks in a marketplace entry (
marketplace.jsonplugins[].hooks), link-mode and local-path plugins (aninstallPathoutside the cache),~/.claude/dev-modssession mods,CLAUDE_CODE_PLUGIN_CACHE_DIRrelocation, plugin monitors, and data flows inside a mod that use no process call ($.fs.readof a credential then$.http.fetch). - Cursor: team hooks (cloud-distributed, not on disk as configuration) and installed marketplace plugins (their on-disk location is not documented).
- Codex: intermediate project config layers between the repository root and the cwd,
commandWindows(not pattern-checked), and plugin activation inconfig.toml[plugins."p@m"] enabled(every cached plugin's graded version is treated as active). - Copilot CLI:
COPILOT_HOMErelocation, Windows registry policies, and agent-scoped hooks in custom-agent frontmatter. - Gemini CLI:
GEMINI_CLI_SYSTEM_SETTINGS_PATHand thehooksConfig.disabledper-hook list. - Devin: plugin hooks (install location undocumented), ancestor-directory discovery between the repository root and the cwd, and dashboard-distributed enterprise hooks.
- OpenCode: npm plugin package sources (installed by Bun into
~/.cache/opencode). - Kiro: legacy IDE
*.kiro.hookfiles and CLI 2.x profile-scoped hooks. - Claude Code: an
asyncRewakehook'srewakeMessageandrewakeSummarytext (used by the official security-guidance plugin; code.claude.com/docs/en/hooks documentsasyncRewakebut not these fields, fetched 2026-10-23) is not read or graded; if they are documented as text put in the agent's context, they will be graded as conversation text. - Everywhere: Windows paths (
%APPDATA%,ProgramData). - Budgets: at most 500 hooks per file entry and per plugin (a plugin manifest's list of hook files is read once per path and only until the plugin's cap) and 10,000 per scan; hooks past either are not parsed (the file is counted as a miss in the diagnostics and traced as "hook budget spent" under
--trace). The on-disk checks of one command (native-binary probe, symlink check) stop after 16 path words, program words first, and after 20,000 per scan; the lexical agent-directory check still sees every word.
Floors: what is inventoried but not graded, or not read:
- Roo Code: approval toggles (
alwaysAllowExecute, ...) live in VS Code's SQLitestate.vscdband are not read. Custom modes'groupsare not graded; the files are rules rows. - Cline: hook contents are not read; hooks are listed by name. The VS Code state that
predates
~/.cline/datais not read. - Continue:
cn --autoand--alloware launch flags and leave nothing on disk. The.continueignorefile is not read. - Aider: a
readorloadpath outside the project, and.aider.tags.cache.v*, are listed or ignored, not read.env-fileis not read. - Goose:
GOOSE_MODEfrom the environment,GOOSE_PATH_ROOT, recipes, plugins and hooks under.agents/, and~/.local/state/goose/logs(LLM request logs) are not read. - Zed: the Rules Library (an LMDB file) is not read. The threads database is counted and sampled as bytes.
- Junie: the IDE plugin's Brave Mode, allowlist and global MCP live in IDE settings whose location is not documented. The CLI's sessions directory is not documented. Project hooks are documented as ignored.
- Amp: threads are stored on ampcode.com. The removed
amp.permissionsandamp.dangerouslyAllowAllkeys are not read. Plugins are listed, not read or graded, because whether a workspace plugin waits for approval is not documented. - Warp: app execution profiles (
[agents.profiles], edited in the UI), Warp Drive rules and MCP servers, and the state database are not read. - All agents: environment-variable relocations (
CLINE_DIR,CONTINUE_GLOBAL_DIR,JUNIE_HOME,XDG_*) are the scanning user's own and are not applied.
- Agent skills: plugins installed in link mode are not walked (Agent Skills).
- Persistence: macOS URL handlers (LaunchServices database, app bundles'
Info.plist) are not parsed (Persistence Artifacts). - Network listeners: macOS has no
/proc, so the scanner is skipped with a warning (Network Listeners). - Hooks: prose redaction of prompt text is a heuristic; short passwords after a flag with no secret-shaped name are not recognised; Devin CLI, Devin Desktop and Gemini CLI prompts are
undocumentedand graded as conversation text; a command built in a variable and passed to an exec call is advisory (Hook surfaces). /procsignals: off Linux neither signal is reported; an npx cache moved bynpm_config_cacheis not followed (Design decisions).- OpenTelemetry: see OPENTELEMETRY.md.
- Scheduled runs: see OPERATIONS.md.