Plugin Safehouse
Plugin Safehouse runs one local Hermes plugin in a macOS sandbox with planted fake secrets and reports what the plugin did, what it tried, and how much of it was exercised.
Install
Install directly from GitHub:
hermes plugins install LaithAskar/hermes-plugin-safehouse
hermes plugins enable plugin-safehouse
Until the catalog submission is merged, Hermes labels this as a custom, unreviewed source. You can also clone the repository and run the CLI without installing anything:
git clone https://github.com/LaithAskar/hermes-plugin-safehouse.git
cd hermes-plugin-safehouse
python3 -m safehouse.cli trial tests/fixtures/benign
From another directory, set PYTHONPATH=/path/to/hermes-plugin-safehouse.
Requires macOS and a local Hermes install. Safehouse finds Hermes at ~/.hermes/hermes-agent. To use another copy, set SAFEHOUSE_HERMES_ROOT; to bypass the launcher probe as well, set all three of SAFEHOUSE_HERMES_ROOT, SAFEHOUSE_HERMES_PYTHON, and SAFEHOUSE_HERMES_SITE_PACKAGES (:-separated).
Dynamic trials support manifest kind: standalone (the default) and kind: exclusive (memory providers: register() runs, the provider lifecycle does not). Other kinds (backend, model-provider, platform, which most of Hermes' bundled plugins use) and portable plugin.json packages, are refused at preflight and reported INCONCLUSIVE.
Security and catalog disclosures
- Safehouse launches a short-lived Python worker under macOS
sandbox-exec. The sandbox denies candidate network access, process spawning, signals to other processes, reads under the real home, and writes outside the disposable trial root. - It reads the candidate plugin directory and the local Hermes runtime, then stages the candidate in a temporary fake
HOMEandHERMES_HOME. It plants fake credentials only; it does not copy real credentials into the trial. - The worker imports Hermes'
PluginManagerand calls its private_scan_directoryloader inside the disposable process so the candidate registers through Hermes' real loader. This is an explicitly disclosed compatibility risk and may require updates when Hermes changes that private seam. - The plugin stores no credentials, sends no telemetry, makes no third-party network calls, and starts no long-running background process. Optional launch-asset scripts invoke local
ffmpeg; they are not used by the installed plugin.
It differs from hermes-plugin-guard, which statically scans plugin source before install: Safehouse runs the plugin and watches its behavior, so the two answer different questions.
What a receipt looks like
sleeper FLAGGED red flags: 3 all during invoke of tool:sleeper_lookup
canary file read file:home/.ssh/id_ed25519 __init__.py:16
canary env read env:OPENAI_API_KEY __init__.py:14
token in egress env:OPENAI_API_KEY in urllib.Request args __init__.py:22
Coverage: 1 of 1 handlers called
What it does (observed capabilities; a person decides what they mean):
network connect to 127.0.0.1:9 (seen at urllib.Request, http.client.connect, socket.getaddrinfo, socket.connect) (denied by the sandbox: yes)
subprocess /usr/bin/true (denied by the sandbox: yes)
NO_FLAGS_OBSERVED is not a safety verdict: it says nothing was flagged in this trial. Every report says what was not observed.
What it catches
Red flags (FLAGGED, exit 1):
- A planted secret was touched without being declared, matched by file identity (inode), not path: a hard link, a rename, a symlink, or a
dir_fdopen still counts. Planted files are~/.ssh/id_ed25519,~/.aws/credentials,~/.config/gh/hosts.yml,HERMES_HOME/.env,HERMES_HOME/auth.json, andHERMES_HOME/state.db, plus the env keysOPENROUTER_API_KEY,ANTHROPIC_API_KEY,OPENAI_API_KEY,TELEGRAM_BOT_TOKEN,DISCORD_BOT_TOKEN, andGITHUB_TOKEN. - A planted token appears in undeclared network or subprocess arguments, in a file the plugin wrote, in a value a handler returned, or in text that would enter the model's context. A
requires_envdeclaration never covers those last three. - Any attempt to read, stat, list, or open something under the real home directory.
- A signal sent to any other process (the sandbox denies it).
- A sandbox escape attempt: a write outside the trial or a denied LaunchServices call.
- Replaced Hermes callables (
host_monkeypatch), and enumerating live objects to reach the recorder (evidence_tamper_attempt).
Review items (REVIEW, exit 3) are behavior a person has to judge. The rules live in one table, safehouse/rules.py:
- Outbound network attempts and process spawns, even though the sandbox denies them. A reverse shell and a GitHub API call look the same here on purpose: Safehouse does not judge hosts or commands.
- Changes to
~/.ssh/authorized_keys,~/.ssh/config,~/.gitconfig, shell rc and profile files,~/Library/LaunchAgents, and the Hermes control surfaces$HERMES_HOME/{SOUL.md,config.yaml,plugins,hooks,skills,memories,cron}, labelledsensitive_write. The parent-side snapshot also catches changes to the$HOMEpaths that no audited write explains, such as a write through a symlinked directory. - Enumerating the whole environment. This can expose every credential injected into the process even when an encoded token is not recognized later.
- Querying the system account database with
pwd.getpwuidorpwd.getpwnam, which can reveal thatHOMEis disposable. - Instruction-like text that would enter model context, plus
pre_tool_call/tool_requestresults with Hermes' explicitaction: modifydirective. - A secret the manifest declares in
requires_env: reading it, and sending it over the network or to a process (declared_env_read,declared_token_egress). Any destination counts the same. - A tool or hook missing from a
provides_toolsorprovides_hookslist that exists. Those fields are optional in Hermes, so a manifest without them only gets a warning.
Loading native code (ctypes, CFFI, or a runtime extension) makes the trial INCONCLUSIVE, because audit hooks cannot see inside it.
Everything the plugin did is listed under What it does as an observed capability that requires interpretation, with the phase and the source line: network attempts with the exact layer (connect, bind, getaddrinfo), subprocess argv, temp files, writes under $HOME (user_home_write) and HERMES_HOME, reading the whole environment, native code, and any text that would enter the model's context. Whole-environment reads and instruction-like context become REVIEW findings.
Only what Hermes actually forwards is scanned: ordinary tool results; system prompt sections and ctx.inject_message; the string a transform_tool_result, transform_terminal_output, or transform_llm_output hook returns; context from a pre_llm_call result; the whole request an llm_request middleware returns, since those are provider kwargs (including forged tool_calls); what an llm_execution middleware returns or passes to next_call (choices, messages, reasoning); and what a tool_execution middleware returns. Tool definitions are scanned for title, description, property names, enum, const, default, and examples; schema text appears under What it does only when it contains a review marker, and planted tokens produce a red flag instead.
Each scan is limited by characters (64 KiB), a shared node budget, depth, and wall time, and reads candidate data through unbound built-ins so no plugin method runs. A scan that hits a limit, or finds a value Hermes would have to run plugin code to read (a custom __str__, an overridden get, a key defining its own __eq__), gets a separate context_scan_incomplete inconclusive finding, even when it collected no text.
Coverage: N of M handlers called covers tools, hooks, slash commands, CLI commands, middleware, and callable prompt sections. A handler counts only once the running interpreter shows its body started. A spoofed __signature__, a generator nothing advanced, a call past the cap (256 per trial), or a handler that raises or times out makes the trial INCONCLUSIVE; starting a handler is not enough to claim its behavior was exercised. Registrations are rechecked after the calls finish, so a tool a handler registers is reported, called, and counted too. A changed file that exceeds the bounded canary-token scan also makes the trial INCONCLUSIVE rather than clean.
CLI commands are parsed and dispatched the way hermes <name> does it, once per reachable subcommand path, each with its own entry in M. A path whose required values cannot be synthesized, or past 32 paths per command, is named in the report and forces INCONCLUSIVE. A command with no handler on a path, where its setup callable is all the code there is, stays NO_FLAGS_OBSERVED, with a cli_setup_only note saying what ran.
What it cannot catch
- Linux and Windows are unsupported. Dynamic trials need macOS
sandbox-exec; elsewhere you get a static scan andINCONCLUSIVE. - Determined evasion. A plugin that detects the trial can stay dormant. Realistic paths and values raise the bar, they do not remove it. Evidence comes from the process the plugin shares, so a determined plugin can forge or suppress it; the sandbox denial still holds.
- Native code. Audit hooks cannot see inside
ctypes, CFFI, or extension modules. A canary read through libc is not detected, so reaching a native bridge makes the trialINCONCLUSIVErather than clean. - Whether a reviewed behavior is acceptable.
REVIEWreports what was attempted. It does not know whether a host, a command, or the destination of a declared token is legitimate. - Plugin kinds beyond
standaloneandexclusive. See Install. - Behavior gated on real data or time. Handlers run once with synthetic arguments. Delayed threads, triggers, and session state stay dark.
- Return-value meaning beyond the checks above. Return values are scanned for planted tokens and a short phrase list, not interpreted generally. Text inside non-standard schema keywords that hold objects is not scanned. For
pre_tool_callandtool_request, Safehouse recognizes Hermes' explicitaction: modifydirective but does not judge the rewritten arguments. - CLI paths that need real values. A handler behind a required value the parser rejects synthetically is reported as not reached rather than run.
- Encoded or split tokens and socket payloads (only addresses are visible). Direct reads through
os.environ,os.environb, andposix.environare still recorded before encoding. Reports show content-free return summaries, so an encoded planted token is not copied into report output. - Exclusive-provider lifecycles.
register()runs, but provider methods are not driven; those trials areINCONCLUSIVE. - Shipped bytecode.
.pycfiles and__pycache__directories are excluded. The report warns that the trial ran the staged source-only package. Native artifacts and.pyofiles remain rejected.
Reproducible benchmark
scripts/verify.py is the bundled mini-corpus. It runs known-benign, known-malicious, review-required, blind-spot, and timeout fixtures under the real dynamic trial and checks the expected verdicts:
| fixture | behavior | expected verdict |
|---|---|---|
| benign | declared echo tool | NO_FLAGS_OBSERVED |
| deceptive | canary access during registration and calls | FLAGGED |
| sleeper | looks clean at registration, steals planted secrets when invoked | FLAGGED |
| hook_abuser | abuses a registered hook | FLAGGED |
| invoke_modes | one handler raises and another times out | INCONCLUSIVE |
| attacks/oversized_write | changed file exceeds the canary scan cap | INCONCLUSIVE |
| attacks/review_reverse_shell | shell and outbound network attempt | REVIEW |
| attacks/libc_canary_read | native-code blind spot | INCONCLUSIVE |
| attacks/cffi_canary_read | CFFI native-code blind spot | INCONCLUSIVE |
| attacks/malformed_url | malformed outbound destination | REVIEW |
| attacks/tool_result_injection | instruction-like ordinary tool result | REVIEW |
| attacks/unreadable_write | changed file cannot be snapshotted | INCONCLUSIVE |
| hanging | handler exceeds its budget | INCONCLUSIVE |
Run PYTHONDONTWRITEBYTECODE=1 python3 scripts/verify.py; it exits nonzero if any verdict, containment probe, cleanup check, or redaction check differs. tests/fixtures/attacks/ contains the broader adversarial corpus, one reproducible plugin per attack. scripts/corpus.py <corpus.json> remains available for local third-party plugin collections, but no external corpus result is claimed here.
Details
Verdicts
| Verdict | Meaning |
|---|---|
| FLAGGED | At least one red flag. |
| INCONCLUSIVE | No red flag, but the trial could not support a conclusion: a handler was skipped, the worker failed or timed out, evidence broke integrity or overran its limit, native code loaded, preflight or the sandbox refused, or only static mode ran. |
| REVIEW | No red flag and the trial completed, but observed behavior needs a human decision. It is neither a red flag nor a clearance. |
| NO_FLAGS_OBSERVED | Nothing was flagged in this trial. If nothing was callable the receipt says Coverage: registration only, nothing to call. |
Precedence: FLAGGED > INCONCLUSIVE > REVIEW > NO_FLAGS_OBSERVED. Exit codes: 0 NO_FLAGS_OBSERVED, 1 FLAGGED, 2 INCONCLUSIVE, 3 REVIEW, 64 usage error.
Usage
python -m safehouse.cli trial path/to/plugin # Markdown summary
python -m safehouse.cli trial path/to/plugin --json # full JSON report
python -m safehouse.cli trial path/to/plugin --static-only # preflight + source scan, run nothing
python -m safehouse.cli trial path/to/plugin --no-invoke # load and register only, call nothing
python -m safehouse.cli trial path/to/plugin --timeout 10 --output ./safehouse-out
Inside Hermes: the safehouse_trial tool takes {"path": str, "static_only"?: bool, "no_invoke"?: bool, "timeout"?: number} and returns {"ok": true, "report": {...}} or {"ok": false, "error": {"code", "message"}}. The /safehouse <plugin-dir> [--static-only] [--no-invoke] [--timeout N] command returns concise Markdown. Safehouse deliberately registers no hermes <subcommand>: Hermes' host isolation skips plugin CLI commands.
How a trial works
- Preflight. Never executes candidate code. Rejects symlinks, hard links, special files, path escapes, native artifacts, oversized trees, and malformed manifests.
- Staging. The plugin is copied, re-hashed, into a temporary root that looks like an ordinary profile:
HOMEat<root>/Users/<name>,HERMES_HOMEat$HOME/.hermes. The name, paths, probe values, and canary tokens look like a normal machine and contain no tool name. - Containment and smoke check. The SBPL profile denies network, process fork and exec (except the worker interpreter), all writes outside the trial root, reads under the real home except the Hermes and interpreter trees, signals to anything but the worker itself,
lsopen, and mach lookups of LaunchServices. Before any plugin code runs, a probe under the exact profile must prove each rule against a real victim process, a real loopback listener, and a real LaunchServices call. If any rule fails, the trial refuses to run and reports INCONCLUSIVE. - Worker. Runs
python -I -S -B, installs the audit hook, recording views ofos.environ,os.environb, andposix.environ, and watchers forstat,dir_fdopens, native extension loads, account-database lookups, andctx.inject_message. It loads a no-op plugin first so Hermes' lazy setup is not attributed to the candidate. ThenPluginManager._scan_directory,_load_plugin, capture, invoke, unload. Events stream as JSONL over an inherited pipe with a nonce trailer. - Invoke. Every registered tool, hook, slash command, CLI command, middleware, and callable prompt section runs once under a 2 second per-call budget and a total budget, with realistic synthetic arguments. Actual return values are scanned, but the report stores only content-free type and size summaries.
- Supervision and compare. The parent owns the timeout and starts the worker in a new session. Evidence is accepted only with contiguous sequence numbers and a matching trailer. Events are attributed to the plugin or the host from stack frames; host-origin events appear only as host activity.
Report shape
TrialReport (safehouse/model.py, schema safehouse.report/v0.6): verdict, mode, target, candidate (including requires_env), findings[] (severity in red_flag/inconclusive/review/warning/observed/info, category, code, message, evidence[], phase, invocation, seqs, site), capabilities[] (kind in network/subprocess/temp_files/user_home_write/sensitive_write/hermes_home_write/plugin_data_write/env_enumeration/native_code/context_injection/trial_probe, detail, phase, invocation, site, seqs, denied), coverage (per-kind registered and called counts, skipped[], native_code[]), invocations[] (kind, name, args, outcome, detail, returned, duration_s, first_seq, last_seq, entered), secrets_touched[], host_activity[], timeline[], declared, observed, event_counts, file_changes[], sandbox, evidence, host_sentinel_unchanged, limitations[], not_observed[], duration_s.
Verification
PYTHONDONTWRITEBYTECODE=1 python -m unittest discover # dynamic tests skip off macOS
PYTHONDONTWRITEBYTECODE=1 python -O -m unittest discover # same suite with assertions stripped
PYTHONDONTWRITEBYTECODE=1 python scripts/verify.py # smoke proofs + fixture trials, receipt
PYTHONDONTWRITEBYTECODE=1 python scripts/corpus.py <corpus.json>
PYTHONDONTWRITEBYTECODE=1 python scripts/demo.py # add --write-transcript to save docs/demo-transcript.txt
Limitations that apply to every report are listed in the report itself: the audit hook and environ wrapper are evidence, not containment; sandbox-exec is deprecated by Apple and reads outside the real home are allowed; the worker relies on the same private Hermes seams as hermes plugins doctor; and origin attribution can be spoofed by a hostile plugin (the sandbox denial still applies).
README from the reviewed commit 8e2e5ce; it updates when the author re-pins.

LaithAskar