The hand-off quits the Desktop and launches `hermes update` as the marker's
delegate. When the update lock then refuses that claim (the pre-a28a5d03a9f
whole-second creation-time bug on macOS, still present on installs that cannot
pull the fix through the button), no other updater exists: the Desktop shows
"Hermes couldn't finish updating" and the user retries. The metrics row read
outcome `refused`, which the rollup treats as neither attempt nor failure, so
~320 Desktop update failures a fortnight vanished from the failure rate.
Record that one case (marker names this process as its delegate) as `failed`;
a refusal under another update's claim stays `refused`.
Review of this PR: the inline_shell_exec downgrade assumed the runtime never
expands !`cmd` in a community hub skill. The runtime gate
(agent.skill_preprocessing._is_community_hub_skill) matches only the hub lock
entry's own directory, so a nested SKILL.md inside a community bundle is listed
as its own skill and expands when skills.inline_shell is on; so does the same
skill seen through another profile's external_dirs, or any read with an
unreadable lock. With the downgrade, such a bundle installed without --force.
inline_shell_exec stays high for community installs. The sudo/bind-all doc
notes and every other rule change are unchanged. Corpus cost: 4 of the 33
skills this PR made installable stay blocked (index 83 -> 71 instead of 68).
The A/B over the index sample showed two shapes the earlier relaxations let through:
base64 -d into tar (an embedded payload the scanner never sees) and
curl ... | sudo -E bash (base never matched it; sudo_usage in Markdown was the only
finding). Both now fire.
A random sample of our own hermes-index blocked ~15% of third-party skills; most
blocks were pattern precision bugs, not threats:
- provider token shapes (AKIA, ghp_, github_pat_, sk-, sk-ant-, glpat-, BEGIN PRIVATE KEY)
are scoped case-sensitive: the table compiles with IGNORECASE, so `[0-9A-Z]{16}`
matched the placeholder `AKIAxxxxxxxxxxxxxxxx`
- destructive_root_rm exempts the Dockerfile package-cache cleanup
(/var/lib/apt/lists, /var/cache/{apt,apk,yum,dnf}); traversal out of them, a later
`/` or `/etc` operand, and `--no-preserve-root /` still fire
- base64_decode_pipe fires when the decoded bytes reach an interpreter at any later
stage (or inside eval/source), not on `| jq` / `| grep`; plugin_guard's
is_data_decode step-down is now redundant and is removed (it also stepped down
`base64 -d x | sh | grep`)
- html_comment_injection needs injection phrasing, not a bare ignore/system/hidden
- setuid_setgid is critical only in executing shapes (set*id( call, cap_set[ug]id,
chmod u+s / 4755); the bare word is a medium note
- deception_hide's guidance exemption covers diagnosis/diagnostic (diagnos\w*)
- community scans: inline_shell_exec is informational (preprocess_skill_content never
expands it for a community hub lock entry), and sudo_usage / bind_all_interfaces in
Markdown docs are notes; scripts keep full severity
SCANNER_VERSION bumps to skills-guard-v9 so cached verdicts rescan.
skills_guard's base64_decode_pipe required the pipe right after the
flag, so `base64 -d payload.b64 | sh`, `base64 --decode < p | bash`,
`base64 -di p | python3` and `openssl base64 -d | sh` produced no
finding. plugin_guard's is_data_decode stepped the finding down to
medium when the FIRST consumer was not an interpreter, so
`base64 -d | gunzip | sh` scored like a data decode into grep.
Widen the pattern to those decode forms (not `||`), and have
is_data_decode read the decode off that same pattern and require every
later pipe stage to be a non-interpreter.
AWS access key IDs are all-caps by spec, but THREAT_PATTERNS compiles
with re.IGNORECASE, so a case-folded AKIA+16 byte collision inside a
base64-encoded asset fired one unoverridable critical and hard-blocked
the whole plugin install (#132155). Scope the pattern case-sensitive
with an inline (?-i:...) group — same technique as the hardcoded_secret
carve-out (#116221).
The bare phrase `curl | sh` in documentation prose scored critical, and a
critical on a community source is a dangerous verdict that --force cannot
override, so a skill whose docs name the install method could not be installed
at all. curl with no URL fetches nothing and a real download-and-execute names
its source, so requiring one non-pipe character after the command costs no
coverage. `curl_pipe_python` carried the same optional-operand prefix.
Both scanner versions bump because cached verdicts are keyed on them: without
the bump an already-scanned skill or plugin keeps its dangerous verdict and
stays blocked.
The local-setup card appeared above the input after the first finished turn
on any machine whose catalog recommends a local model, and stayed across
relaunches until dismissed. It read as a nag on every new session.
The offer keeps its quiet homes: the "Run locally" row at the top of the
model menu (until setup completes) and the handoff tour's model-pill step.
The card's state machine (armed/shown/dismissed, the message.complete hook,
the menu-open request) existed only for the card, so it goes with it; the
store keeps the eligibility read both remaining surfaces use.
- _fetch_file_bytes: a DNS-poisoned / NXDOMAIN raw.githubusercontent.com raises
SSRFConnectionBlocked (a ValueError), which escaped the httpx.HTTPError suppress and
failed the install before the Contents API fallback. Any transport failure on the raw
attempt now falls back, and latches raw unusable on the source so a dropping firewall
costs one 15 s timeout, not one per file.
- ensure_bundled_skill: a leftover dest is refused if any entry in it is a symlink or
junction (copytree with dirs_exist_ok followed them out of skills/), and only missing
files are copied (a user-edited file was overwritten). The canonical-path "present"
check also requires the SKILL.md frontmatter name to match; a dir holding another
skill's SKILL.md is refused, as on main.
- Docstring: a restore over leftover user files reads as user-modified, so update keeps it.
`hermes-index rate_limited` (26 events / 9 installs in 14 days): GitHubSource.fetch
paid one Contents API call per file in the skill folder on top of repo + tree,
so an anonymous user (60/h) could never install a skill with ~58+ files and
four average skills drained the hour. Live on origin/main, anonymous:
skills-sh/remotion-dev/skills/remotion-best-practices cost 143 API calls and
failed rate_limited.
A file fetch pinned to the tree's commit sha now reads
raw.githubusercontent.com/<repo>/<sha>/<path> first: the same immutable bytes,
outside the REST limit. A raw miss (private repo, raw outage) falls back to the
Contents API as before. The same install now costs 2 API calls and succeeds
anonymously; anthropics/skills/pdf drops from 16 to 2.
Post-fix telemetry still showed `hermes skills install llm-wiki` failing as
failure_class=filesystem_error, source=hub. Two restore paths failed for good:
- A skill dir left behind without its SKILL.md (a manual delete that kept user
files, or a copy that died partway) made copytree raise EEXIST on every retry.
The restore now copies around the leftovers (dirs_exist_ok); a dir with no
SKILL.md is no skill, and user files in it are kept.
- A skill present behind a symlinked category (which the loader follows, so it
works) was missed by the rglob scan, so the restore ran into the containment
guard and failed. The canonical SKILL.md now counts as present.
A restore that still fails (read-only dir, a symlinked category with the skill
missing) was recorded as source=hub, because the failed exit returned no bundle
and _record_skill_install fell back to "hub". The bundled branch now marks the
attempt origin=bundled, so every bundled-path outcome, raised or returned,
records source=bundled.
d2639c0dc8 started probing plugins/<name> with is_junction before the
first install of a plugin. On Windows lstat() of that missing slot raised
FileNotFoundError, so every fresh plugin install crashed (caught by the
MSIX smoke's plugin step on rc.7-v0.21.7). os.path.isjunction answers
False for a missing path; match it, and let POSIX's absent reparse tag
fall into the same False.
The image bakes eight extras into /opt/hermes/.venv but never recorded
them as PM's dependency selection. The first generation a container built
(a lazy extra such as fal, a plugin's dependencies) started from an empty
selection, so it carried only what was requested and dropped aiohttp, the
messaging SDKs and mcp. After the next restart the API server, platform
adapters and MCP servers failed to start.
- pm/build_env.py, Dockerfile: --record-selection writes the frozen
build's --extra list to the store facts as the venv selection, as a
bundle build does. enabled-features.json is not written, so the frozen
lazy-install policy stays out of Docker.
- pm/recovery.py: stage2's refresh restores extras the image ships that a
volume's recorded selection lacks, through the normal explicit sync. On
failure the image venv boots and the missing extras stay recorded for
the next attempt. A malformed recorded fact is refused, as
venv_is_current does.
- pm/install.py: share the PEP 685 extra-name key between
_still_declared and the boot reconciliation.
Fixes#135329
publish runs the same install-stamp and boundary scripts on the same
runner matrix, including arm64 whose system python3 is 3.10 and lacks
datetime.UTC. Without this, PR checks pass and main-push publish breaks.
lefthook refuses to install when core.hooksPath is set globally and
exits non-zero. prepare forwarded that status, so npm ci failed and
took down hermes update's node-deps step (desktop, tui and web).
Hooks are a contributor convenience; lefthook already prints its own
remediation, so let it warn and keep the install going.
The void-tag pass from the previous commit fixes `<br>`, but mermaid's
label HTML carries more than void tags: a non-breaking space (` `,
mermaid's `#nbsp;`, or a literal U+00A0 in the source) is serialised as
the ` ` entity, which XML does not define. Such a label still
decodes to a broken image.
mermaid.render() returns the HTML serialisation of the diagram, so parse
it with the HTML parser it was written for and re-serialise it with
XMLSerializer. That fixes the whole class at once. Against mermaid
11.16 in Chromium, across flowchart, class, state, ER, mindmap, block,
kanban, sequence, journey, gantt, pie, timeline, gitGraph, quadrant and
xychart output, the round-trip yields the same element tree and image
size as the regex wherever the regex output is valid XML, and also loads
the non-breaking-space cases the regex leaves broken.
Drop a label's plain `xmlns` attribute before serialising: the
serializer declares the namespace itself, and a spec-conformant one
(jsdom) writes both, a duplicate attribute.
The tests' fixtures put `<br>` directly in SVG <text>, which mermaid
never emits; replace them with the real 11.16 label shape (XHTML in a
<foreignObject>) and assert the label keeps its line break and its
non-breaking space.
Refs #133089, #134031
`mermaid.render()` returns SVG aimed at inline HTML, and its sanitizer
gives back a `<br/>` written in a label as an open `<br>`. That string is
not well-formed XML, so the strict `image/svg+xml` parse in
`normalizeSvgSize` yields the parsererror document instead of an `<svg>`,
takes the `el.tagName !== 'svg'` early return, and hands the *unnormalized*
string back. The diagram `<img>` then gets it as a `data:` URI, which Blink
decodes as strict XML and rejects for the same reason — so the message shows
a broken image whose alt text reads "Mermaid diagram", with nothing thrown
and nothing written to any log.
Close HTML void tags before the markup reaches either XML parse. The pass is
generic rather than a `<br>` special case, because the same class of input
reaches `<hr>`, `<img>`, `<input>`, …, and it is idempotent: already-closed
tags and non-void tags (including names that merely start with a void tag,
such as `<break>`) pass through untouched.
Fixes#133089
Verified by replaying 32 real ```mermaid fences from a session database
through the app's own renderer chunks in Electron 40.10.2 / Chromium 144:
24/32 fail before this change — every failure has `<br/>` in a label, with a
perfect 1:1 correlation — and 31/31 renderable diagrams succeed after it. The
32nd is an invalid Mermaid source that correctly keeps falling back to its
source block.
Follow up the shared-cache copy observation on PR #135458 with behavioral regression coverage and a sibling staging file. Make the lock-change fixture independent of timestamp granularity.
Prepared with OpenAI Codex assistance; no human review claimed.
An interrupted detached turn sets the client-gone and turn-cancel latches. If
the active /loop or /heartbeat then re-armed the orphan timer after the turn
settled, neither latch ever cleared: no tick could claim the turn and every
reattach was refused with 4009, so the loop never reached its budget and the
session stayed pinned. Collect the settled session as before instead.
Also: TTL eviction checks the cheap clock before the store-reading exemptions,
and the heartbeat branch gets its own reaper tests (active local pins; paused,
cleared or gateway-owned releases).