The public list `hermes plugins validate` checks plugin imports against
was a hardcoded frozenset inside hermes_cli/plugin_validate_private_imports.py,
with no page that showed it to plugin authors.
Move the list, unchanged (89 entries), into hermes_cli/data/plugin_api.json
(already shipped as package data via `data/*.json`) and load it once in the
validator. scripts/gen_plugin_api_docs.py renders
website/docs/developer-guide/plugins/public-api.md from the same file, and a
test fails when the page is stale. Validate output is unchanged for the same
inputs.
The plugin guide example said get_session_env returns "" outside a session. When the
session ContextVar was never bound it reads os.environ first and only then returns the
default, so the comment now says that.
The plugin guide told authors to read session identity with
`gateway.session_context.get_session_env(...)`, written as a dotted module
call with no import line. `hermes plugins validate` treats only
`from gateway.session_context import get_session_env` as public, so an author
who wrote `import gateway.session_context` to match that spelling got a
private-import warning.
The developer guide and the user guide now name that import, and the
slash-command example uses it to read the platform and the chat id. A test
validates the guide's `get_session_env` example as a plugin and expects no
private-import warning.
The block sections now say the prompt is stored as the withheld placeholder
(later turns, compaction, session_search, memory providers and review read
only that; transcripts still show what was typed), that the hook runs before
turn-start compaction, and that a callback which raises or times out is
skipped, so the turn goes ahead. observer-hooks.md lists the block return.
A pre_llm_call callback (Python plugin or shell hook) may now return
{"action": "block", "message": "..."} to veto the turn: no model call is made,
the message is the reply the user sees and the transcript keeps a normal
user -> assistant pair, and the result carries
turn_exit_reason=blocked_by_plugin_pre_llm_call. This is the supported seam for
DLP / privacy / policy plugins that previously had to patch core (#57364).
The vetoed prompt also stays on the host for that turn: no session titling call,
no external-memory prefetch or sync, no background memory review. Its user row
gets an api_content placeholder for the main-loop replay. That alone does not
keep the prompt out of later turns (compaction, session_search and memory
providers read the row's content); the next commit stores the placeholder as
the content.
First valid block wins; a Python block without a message is ignored (same as
pre_tool_call). Shell hooks accept both the Hermes {"action": "block"} and the
{"decision": "block", "reason": ...} shapes, mirroring pre_tool_call.
Install scanner (tools/plugin_guard*, PLUGIN_SCANNER_VERSION bumped once):
- hardcoded_secret: a PostHog project key (phc_, write-only ingest key) joins the
Google installed-app secret as a public client key -> high (caution), only
when every matched literal on the line has a public prefix (telesec
telemetry.py:27 was dangerous). phx_ personal keys and any other token stay
critical.
- proc_access: reads of /proc/self|<pid>/{cgroup,mountinfo,stat,status,...}
(container placement, pid start time) land at low instead of high
(hermes-lcm-x cpu_capacity.py was caution). environ, cmdline, mem, maps, fd,
root/cwd, ns, bare /proc/self, any unlisted leaf and any write keep high.
Generalises #144304's cgroup/mountinfo allowance and keeps the finding
visible instead of dropping it.
- bytecode_file: a committed .pyc/.pyo (git ls-files; plus any bytecode outside
__pycache__) is a high structural finding, the binary_file tier (rule 17;
hermes-emotion shipped tests/__pycache__/*.pyc and scanned safe). Untracked
caches the validate run writes are not flagged.
Validator (hermes plugins validate, warnings only):
- private imports: gateway.session_context.get_session_env is the documented
route (plugins/index.md) and a plugin's fallback import of its own install
path (plugins.memory.openltm._db) is not a core import.
- new warning for private-method calls on core handles (SessionDB, AIAgent,
GatewayRunner, PluginContext) the plugin does not define itself
(opencode-sync db._execute_write, runner._evict_cached_agent).
- desktop surface review warning for raw window.hermesDesktop calls and app
markup selectors ([data-slot], [data-tree-*], [data-pane-host]) in CSS text
or closest() (squad-hub, novel-reader, search-sources).
Review findings on the config_keys seam:
- Discovery: drop the glob scan. config_keys is parsed onto PluginManifest
(plugin.yaml, and extensions.com.nousresearch.hermes in a portable
plugin.json) and read from the loader's own collect_directory_manifests +
entry points + resolve_manifest_winners, so skip rules, precedence and the
project-plugin gate are the loader's.
- Only active plugins count: gate_manifest must load the plugin (disabled wins,
plugins.enabled opt-in, requires_hermes); memory providers count when
memory.provider selects them.
- Bounded: a declared path is at least two segments deep and is ignored when
DEFAULT_CONFIG already defines it; runtime keys are consulted only after the
schema walk refused a key, so a plugin declaring [agent] no longer hides
agent.max_turnz.
- No lru_cache: a plugin installed or enabled in a long-running process counts
on the next config get/set (the lookup runs only for refused keys).
- computer_use.backend (read by the plugins/computer_use selector) and
dashboard.kanban move to a core table with their readers; the selection
sections derive from read_selection's own _SELECTION_NAME_KEYS.
- Tests use fixture plugins (enabled/disabled, hostile [agent], selected memory
provider, portable plugin.json, project opt-in).
`hermes config get browser.cloud_provider` / `browser.use_gateway` warned "not a recognized
config key" although the runtime selects the browser provider from them. The validator only
walks DEFAULT_CONFIG, and provider selections stay unseeded on purpose (a stored key is the
explicit pick), as do core-section keys plugins read.
- tools.tool_backend_helpers.selection_config_keys(): the keys read_selection() honours
(<category>.use_gateway, browser.cloud_provider, stt/tts/image_gen/video_gen provider keys,
web.backend/search_backend/extract_backend).
- plugin.yaml / dashboard manifest.json `config_keys`: a plugin names the core-section paths it
reads; declared for retaindb, byterover, computer_use/cua, kanban dashboard.
- hermes_cli/config_runtime_keys.py consults both before the DEFAULT_CONFIG walk.
Found on Hermes Cloud staging.
Admission rule 18 (README and the mirrored developer-guide copy): a plugin that registers a
browser_vault login backend is listed only at tier verified, from the password-manager
vendor's own repository, or at tier official when Nous maintains it, and states that Hermes
enforces the same rule at load. browser-vault-plugin.md gains "Who can register a backend".
post_llm_call now carries a single-use side_question_token when a loaded
plugin holds the new llm.side_question capability. ctx.side_question(token,
prompt) redeems it: ONE text-only request on a cache-parity fork through
the turn loop's own request phases (assemble_api_request, build_api_request,
perform_api_call), never the tool loop. The parent's system prompt, tools[],
reasoning config and max_tokens are replayed verbatim, so the request reads
the warm prompt cache. Tool calls in the reply are dropped, nothing is
persisted, and usage lands on the session as side_question.
Lease rules: one attempt per turn shared by every plugin (consumed even on
failure), 60 s, invalid once the parent starts its next turn, prompt capped
at 4096 chars, timeout capped at 60 s. The lease holds its parent weakly and
runs under the turn's captured contextvars (profile and secret scope).
Refused with None: MoA, agent-as-provider runtimes, requests that carry
provider-executed tools.
has_capability moves with side_question into a PluginContext mixin
(hermes_cli/plugins_capability_ctx.py) so hermes_cli/plugins.py shrinks.
Fixes#143788
A portable plugin's skills registered as agent-plugin-<slug>-<sha8>:<skill>
(agent-plugin-blender-5a80e4ba:blender) because one manifest field named both
its plugin-data directory and its skill namespace. The hash only matters on
disk. Skills now take the plugin's own name (blender:blender), the form every
native plugin skill uses; a clash with an already-registered qualified name is
skipped with a warning naming the owner, as before. The data directory keeps
its hashed name, so no plugin data moves, and skill_view still resolves names
saved in the old form.
The catalog entry's requires_hermes was display-only: `hermes plugins
install <name>` cloned and published a pin the catalog says needs a newer
Hermes, and `hermes plugins update` re-pinned an installed plugin onto it.
Only the plugin's own plugin.yaml floor was checked, and only at load.
- install_catalog_entry (the one seam every install by catalog name goes
through: CLI, dashboard/Desktop/TUI plugins.manage, `hermes setup`'s
importer step, the left-core and memory-provider auto-installs) refuses
with "<plugin> needs Hermes <spec>; this is <version>. Update Hermes
(`hermes update`) and install again." before any clone. The connector
card's pre-check uses the same function.
- repin_catalog_plugin (every catalog update) keeps the installed pin and
says the update is available after updating Hermes; `hermes plugins
update` with no name lists such a plugin as held instead of applying it,
and check-updates marks it.
- `hermes plugins search` / `info` still list incompatible entries,
marked as needing a newer Hermes.
Portable packages (plugin.json) have no version field, so the catalog
entry's requires_hermes is their only floor; the gate is kind-agnostic and
covers them (tested), and the agent_plugins comment and docs say so.
The verdict is plugins_manifest.requires_hermes_error itself (same
running_hermes_version + version_satisfies), so a dev checkout, canary
build or tagless checkout is judged exactly as the plugin.yaml gate judges
it. No override flag.
Rewrite the browser feature page, configuration, tools/toolsets/env-var/pm
references, docker, windows, bot-screen, termux/installation and the secondary
developer-guide/integration/feature pages (EN + zh-Hans): drop agent-browser,
the built-in browser_* tools, browser.backend and /browser use; document
browser.executable_path / browser.user_data_dir / browser.chromium_args and the
AGENT_BROWSER_* migration; point Camofox users to 'hermes plugins install camofox'.
PluginContext.ask_choice(question, choices) -> str | None lets a sync
slash-command handler ask the user who ran it to pick one option and get
the picked value back. Choices are strings or {value, label, description}.
Each surface binds an asker around the handler call
(hermes_cli/plugin_choices.py), and every asker is that surface's
existing clarify prompt. No surface gets a new card type or wire shape:
- messaging chats (gateway/plugin_command_choice.py): adapter.send_clarify
plus the clarify tool's own delivery (_clarify_send_then_wait, text
fallback, card retire). Telegram/Discord/Slack show buttons, other
platforms a numbered list. The typed reply reaches the clarify
text-intercept while the command still holds the chat.
- local sessions on the gateway (classic CLI, Ink TUI, Desktop): the
`clarify` server request to the session's viewers. Two fixes make the
answer arrive while slash.exec is still waiting for the handler:
* tui_gateway/ws.py: a viewer's answer to an open server request (a
response frame, `clarify.lock`) is settled at once instead of queuing
behind that socket's running slash.exec.
* hermes_cli/gateway_plugin_commands.py: the classic CLI answers the
session's `clarify` requests with its clarify panel while it waits for
slash.exec. No turn pump reads the session's frames between turns.
- in-process classic CLI and the legacy `hermes serve` backend: their
clarify panel / `clarify` server request.
Returns None when nothing can ask (hooks, cron, async handlers on the
event loop), when the user cancels or answers with something that is not a
choice, or when the clarify timeout passes. The handler then prints its
text usage.
delegate_task asks the active delegation provider (delegation.backend, a new
provider category registered with ctx.register_delegation_provider) for each
task's route: model/provider/endpoint/reasoning resolved by the existing
delegation credential resolver, or a Hermes profile the child runs as. A
provider may add enum-checked per-task schema parameters built once with the
tool definitions. Core keeps the whole lifecycle.
- agent/delegation_provider.py + agent/delegation_registry.py: ABC + registry;
the built-in 'default' strategy is today's behaviour and the fallback for an
unregistered/unavailable backend.
- tools/delegate_tool_routes.py: per-task routes, parameter validation, and the
profile scope binding (home override + secret scope + multiplex semantics +
terminal policy) around a profile child's construction and conversation.
- Host isolation: HOST_OBJECT_BASES row, so providers run under
plugins.isolation: host.
- delegation.backend in DEFAULT_CONFIG; registrar table moved to
hermes_cli/plugins_registrars.py to keep plugins.py under its cap.
- Default config: delegate_task schema and every hermes-cli tool definition
are byte-identical to main.
Enabling or updating a plugin whose pyproject.toml declares `dependencies = []` (the official
agent-importers plugin) ran "Resolving / Installing Python dependencies" and built a new
environment generation although nothing gets installed, and every update of the plugin did it
again.
Workspace membership keyed on "has a pyproject", so such a plugin became a uv workspace member,
and members_stamp hashes a member's whole tree into the venv stamp: enabling it, or any change to
its files, made the recorded environment stale. A plugin is now a member only when it installs
packages (`PythonDeclaration.installs_packages`, the predicate dependency consent already uses
since the sibling fix for the empty consent step), so membership, requires-python checks and the
stamp all follow what the plugin actually puts in the environment.
Test fixtures that used a dep-less pyproject as a cheap stand-in for a member now declare a real
dependency (a local wheel through tool.uv.sources, resolvable offline, or a requirement where no
resolution runs); their assertions are unchanged. New: enabling a `dependencies = []` plugin
through the real admission path keeps the selected generation and recorded stamp, a later change
to its files keeps the venv current, and a plugin with a real dependency still joins.
The rule could not tell a bundled library's own classes from patching a renderer global: it kept
failing legitimate bundles (three.js once minified to $-names) and still missed a bare $ receiver.
Prototype writes in a Desktop plugin are now read by the catalog reviewer instead; the policy in the
catalog docs stays, with a note that review checks it by hand. eval/new Function, non-SDK imports,
script injection and app-DOM reach are still linted.
Independent review of #142470:
- Compression (CLI, TUI/Desktop) and /branch continue a chat under a new session id, which reset its
spend total: a chat at its limit got a fresh budget. The continuation hook every rotation site already
calls (transfer_session_yolo) now moves the total too.
- Every cron job in a process shared one tally (their approval session key is ""), so one job's spend
refused the next and stayed spent until restart. The cron run scope binds its own run id.
- tool_costs keys the server does not offer (a typo, or Hermes' mcp_<server>_ prefix) are named in a
warning at registration instead of silently carrying no budget.
- Untrusted over-limit prompts lead with the limit notice (the gateway text fallback keeps ~200 chars);
"1 credit", not "1 credits"; the multiplex rekey moves the prompt slots with the totals.
- Tests: rotation + per-run cron tally + unknown-key warning (red on the previous head), and parallel
over-limit prompts asked one at a time (red with the prompt slot made a no-op).
- Docs: where a total survives and resets, the counted-on-start wording, the same-name override note.
The "prototype patching" admission rule matched any `X.prototype.y =` line. A
bundled library setting fields on classes it declares itself trips it: three.js
alone hits it 29 times (`var Vector2 = class _Vector2 { constructor() {
_Vector2.prototype.isVector2 = true } }`, `KeyframeTrack.prototype.ValueTypeName
= ...`), so no Desktop plugin could ship a three.js viewer.
The write now fails only when the receiver is not a class/function the file
declares, or is a renderer/browser global (Storage, HTMLElement, Array, ...),
which keeps failing even when the file shadows it with a same-named class (a
regex cannot see scope). Every attack shape the rule exists for still fails:
`Storage.prototype.setItem = ...`, an alias of a global, a shadowed global.
A portable Agent Plugins v1 package (plugin.json) had no way into Settings >
Plugins: the schema reader only looked at plugin.yaml, and v1 rejects unknown
top-level fields. It now reads extensions.com.nousresearch.hermes.config_schema
(the vendor namespace that already carries server trust and timeouts) for the
form, the save, the loader's schema warnings and catalog admission.
Two gaps any plugin with settings hit once the form existed:
- The plugin's Desktop half had no way to learn a save happened. Every
plugins.manage settings save now broadcasts plugin.<id>.settings.changed
({written}); a secret-only save still makes the (empty) settings call so the
event fires for a new API key too.
- A secret the form stored never reached the plugin's running MCP server (its
env ${VAR} was rendered at spawn). After a save, Hermes reconnects the plugin's
servers whose resolved launch inputs changed (identity digest, rendered the way
the connect path renders it); a toggle-only save leaves them alone.
A revoked grant kept its dead access and refresh tokens on disk; it now keeps only the marker
and fingerprint. hermes plugins remove drops the plugin's grants, so a later plugin installed
under the same key does not inherit the sign-in, and hermes auth logout <plugin>:<id> still
removes a grant after the plugin is gone. hermes auth add refuses to run from the gateway, cron or
an agent session and says which hosts get the code, the client secret and the token first.
Maintainer requirement on #139985: authorize_url, token_url and hosts are https at manifest
validation and catalog admission, with no loopback http exception. client_id_env and
client_secret_env may not name a variable Hermes itself uses (a provider API key, a platform
token): Hermes POSTs those values to the manifest's token_url, so a manifest naming
OPENROUTER_API_KEY would have sent it there. A host outside the IdP's domains is a reviewer
warning, and the install notice names the sign-in host.
Microsoft and Notion were named without being tried against this route, and
Notion's token endpoint expects HTTP Basic client authentication, which the
broker does not send.
Refs #139077
The authorize URL is the page the user's browser opens to sign in. A catalog
re-pin that points it at another host now shows up in the consent delta, next
to the token host, hosts and scopes. A new path on the same host still only
changes the fingerprint, so the user signs in again.
Refs #139077
A plugin that calls a third-party API with the user's own OAuth client
(Google, Microsoft, Notion) declares it under `oauth:` in plugin.yaml
instead of running the consent flow and keeping the refresh token itself.
- `hermes auth add|status|refresh|logout <plugin>:<id>` runs the PKCE
login of auth_oauth_pkce_plugin and keeps the grant in the active
profile's auth.json (section `plugin_oauth`, keyed by the plugin's key)
- `credentialed_provider_request(<id>, ...)` attaches the bearer for the
entry's declared hosts only (https, port 443) and refreshes the grant
under the auth-store lock; the calling plugin's own entry is resolved
before `_PROVIDERS`, so a later core provider of the same name cannot
take it over
- each grant stores a fingerprint of the entry it was made for: a plugin
version that changes the token URL, hosts, scopes or client variables
gets ProviderNotSignedIn until the user signs in again
- the client secret is read with get_secret right before each token POST
(OAuthPKCEConfig.token_request) and never sits in the frozen config
- grant-dead codes retire the grant; invalid_client, unauthorized_client
and invalid_scope surface as AuthError codes; a sign-in granted fewer
scopes than declared saves nothing
- `hermes plugins validate` checks the block and the declared variables
and warns when plugin code names the token URL; install/show notices
and the catalog re-pin consent delta list the entries
Refs #139077
Follow-up to #141379 from an independent post-merge review:
- the MCP SDK re-adds its inherited set (PATH, HOME, TERM, ...) at spawn,
so popping an unset override never unset those keys. Treat the entry as
absent instead: the key keeps its baseline value, one rule for every key,
matching what the child actually receives.
- carry resolution provenance from interpolation (_RenderedStr) instead of
guessing from the rendered text, so a set variable whose value is
literally '${OTHER}' is passed through, not dropped.
A stdio MCP server env entry like "GHIDRA_INSTALL_DIR": "${GHIDRA_INSTALL_DIR}"
was passed to the child as the literal text "${GHIDRA_INSTALL_DIR}" when the
variable was unset. Servers that validate the value refuse to start (REA:
"GHIDRA_INSTALL_DIR must be absolute"), so a portable plugin could not forward
an optional variable, and Hermes' filtered MCP env means forwarding is the only
way a user's variable reaches the server.
_build_safe_env now drops an env entry whose whole value is still one
unresolved ${VAR}/${env:VAR} reference. Partial references, context vars and
url/headers keep their existing behaviour. Applies to config.yaml mcp_servers
and plugin mcp.json alike (same launch path).
Co-authored-by: Rodrigo Gomes <2362425+rodrigogs@users.noreply.github.com>
The salvaged hook fired from ContextCompressor.compress(), which is not where
a compaction commits: compress_context can still refuse or roll back the
candidate (snapshot stale, commit fence, split failure), the gateway's
/compress runs its own compressor and commits elsewhere, and Codex-native,
micro-compaction and proactive-prune rewrites never call compress() at all.
session_id was also the compressor's bound id, which is stale after rotation.
Fire it from the compaction-record seam (agent/compaction_events.py) that
already sees exactly one committed record per rewrite on every agent path,
and from the gateway session.mutate compress commit. Payload: session_id (the
session holding the compacted transcript), old_session_id, kind, reason,
before/after token estimates, messages_removed. Bounded by
plugins.hook_callback_timeout, since it runs inside the commit window.
Tests trimmed to one invariant (fires once per committed compaction, never
for an aborted one, raising plugin isolated); docs in hooks.md and the
plugin guide.
Finn's state-layer hook missed the paths Desktop actually uses: single
delete and archive go through the canonical mutation receipt
(mutate_runtime_session), not SessionDB.delete_session, so a plugin never
heard about either. Archive (the "this is done" signal #132639 asked for)
fired nowhere.
- mutate_runtime_session fires after its write commits for operation
'delete' and for a real unarchived -> archived sidebar flip; an exact
retry replays the receipt and stays silent.
- set_session_archived fires reason="archive" on a real flip, so TUI
session.archive, `hermes sessions archive` and deferred TUI archives are
covered from one place. Repeats, un-archives, the idle auto-archive
sweep and profile adoption (notify=False) stay silent.
- Payload is now session_id + reason + profile on every path (profile
from the store's home), deleted_ids when more than one row went;
primary id is the one the caller asked for, not the alphabetically
first.
- One fail-open seam (emit_session_deleted) calls lifecycle.invoke_hook
directly; the extra notify_session_deleted wrapper is gone.
- Tests trimmed to two invariants against the real hook registry.
Co-authored-by: Lucas Piera <pieralukasz@gmail.com>
Widen the contributor's opt-in `source=` to the calling-session context plugins
asked for (#140199, #91526, #123245): a `ctx.register_command` handler that
declares any of `session_id`, `session_key`, `platform`, `source` (or
`**kwargs`) receives them; one-argument handlers keep `handler(raw_args)`.
- One call boundary (hermes_cli/plugin_command_dispatch.py) used by the gateway
(messaging platforms and local CLI/Desktop sessions via
`_hm_run_plugin_handler`), the TUI/Desktop RPC (`_run_plugin_command`) and
the classic CLI's off-gateway fallback. The first positional parameter
always gets raw_args whatever its name.
- Messaging plugin commands look up the chat's current session entry (never
create one), so HERMES_SESSION_ID / `session_id` are the id a later /new
reports as old_session_id instead of "" (#123245).
- Fold the contributor's separate gateway adapter module back into
`_hm_run_plugin_handler` so the local-session path shares it.
- Tests trimmed to two invariants, both red on origin/main.
Plugins that show the agent's skills (catalog views, "open this skill", drift checks against the
installed copy) need the same walk, order, search roots and lookup gate the skills_list / skill_view
tools use, plus the package hash the bundled-skill sync stores. All seven were private.
Rename, with every internal caller moved and no underscore aliases (same shape as the platform
helpers in e6cf2a9):
- tools/skills_tool.py: find_all_skills, sort_skills, skill_search_dirs, locate_skill,
skill_lookup_path_error
- tools/skills_sync.py: dir_hash
- tools/skill_usage.py: read_skill_name
The names join _PUBLIC in the private-import lint and are documented under "Read the skill catalog"
in the plugin developer guide.
Builds on #129698 by @nekwo, which proposed the same names with legacy aliases kept.
Closes#129696
Co-authored-by: nekwo <beastant1@gmail.com>
The rule-9 pointer to the platform helpers section broke the docs/README rules mirror test;
the section stays reachable from the platform adapter guide.
hermes plugins validate warned on every platform plugin's use of
OwnAccessPolicyMixin, tools.approval.resolve_gateway_approval, the clarify
button API, the SSRF client pieces and the setup-wizard helpers. Those names
join _PUBLIC and get a "Public helpers for platform plugins" section (plus
the two new PlatformEntry authz fields) in adding-platform-adapters.md.
A plugin `/command` typed in the classic CLI now runs in the gateway that
owns the session, so `os.getcwd()` there is the gateway's directory and
`print()` goes to the gateway's stdout. The plugin docs did not say so.
The user guide (features/plugins.md) and the build-a-plugin guide now say
to return text instead of printing, to take the session's directory from
`agent.runtime_cwd.resolve_agent_cwd()`, and to read session identity with
`gateway.session_context.get_session_env()`. The real-daemon test's plugin
gains a `/probe-cwd` command that checks both helpers return the session's
values while `os.getcwd()` is the gateway's.
Refs #134889 (CLI-13)
Refs #134889 (PLG-7)
Refs #139523
The portable loader raised on any key in extensions.com.nousresearch.hermes
.servers.<name> it did not know, so a plugin using a key added in a later
release could not be installed at all on an older one: REA's per-server
`timeout` broke every v0.22.2 install. Ignore unknown keys with a diagnostic
(logged at load, listed by `hermes plugins validate`), the same treatment
unknown top-level manifest fields already get. Known keys, `trust` included,
are still validated and applied.
#137110 pinned every core-locked package to its exact locked version in
the generated plugin workspace (`_trust_core_lock`). That overrode PM's
deliberate rule that a plugin may move a transitive core package within
core's declared ranges (only core's exact pins are fixed), and #137110
rewrote PM's test for that rule to assert the opposite. Five catalog
plugins stopped installing: cognee, loreconvo, mnemosyne, omnivoice and
radio-dm-gateway.
Remove `_trust_core_lock` and restore
tests/pm/test_workspace_build_inputs.py byte-for-byte from before #137110.
What #137110/#137159 got right stays: generated workspaces carry no
release-age cutoff, so users' machines never check upload dates and
mirrors without them keep working; the 3-day check still runs at
`hermes pm lock` and in CI.
tabro bundles a prebuilt Windows .exe (relay-native-host-0.4.2.exe) in its
plugin tree. The install scanner cannot read machine code, and the SHA-256 the
plugin checks it against is committed in the same commit, so it only proves the
file was not changed after that commit, not where it came from. A reviewer
cannot tell it matches the in-repo C++ source. That is a precedent worth
closing before someone ships a binary that is not benign.
- Delists tabro (deletes plugin-catalog/tabro.yaml). Existing installs keep
working; not added to removed.yaml — there is no evidence of malice, only no
way to verify the binary. An attested build (rule 17) is the way back.
- Adds admission rule 17: no compiled binaries, committed Python bytecode, or
archives hiding either, detected by file header not extension. A native
component is allowed only with a GitHub build-provenance attestation tying it
to the plugin repo's own workflow at the pinned commit; a setup-time download
is allowed only from the tool's official source, pinned by checksum and
disclosed. Mirrored into the docs submission guide (the mirror test enforces
identical text).
A full-content scan of all 543 pinned entries found tabro is the only one with
a native executable. The enforcing gate ships in a follow-up PR.