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.
A malformed .git/config makes `git config --get-regexp` exit 3; the
rc gate in noninteractive_repo_git_env must refuse rather than treat it
as "no filters", but no row exercised it, so removing the gate stayed
green. Add a broken_config row.
Parametrize the filter rows with their data (attrs, config, refused)
instead of a string-keyed dict plus a restated id tuple, hoist
_CLEAN_GIT_ENV above its first use and use it everywhere.
Fold the core.sshCommand reclaim test into the index-reading test: the
same malicious repo gets an ssh origin, a hostile core.sshCommand and a
.git/shallow file, so ls-remote and fetch --unshallow are exercised
without a separate two-commit source repo and depth-1 clone. Keeps the
stack at two new tests.
bounded_probe_run returns a CompletedProcess from communicate() with
text=True, so stdout is always a str once proc is not None; the
`or ""` guard could never fire. GIT_CONFIG_COUNT is always written by
noninteractive_git_env (it pops any caller value and sets
str(len(overrides))), so the defensive `.get(..., "0") or 0` parse
only hid that invariant.
Three gaps in noninteractive_repo_git_env, two probe-verified:
- Dedup was on key.lower(), but git keeps the subsection's case:
[filter "evil"] + [filter "Evil"] with `filter=Evil` overrode only the
lowercase driver and the hostile smudge ran. Dedup on the exact name
`git config --name-only` prints.
- Only includeIf "onbranch:" failed closed. includeIf "gitdir:<repo>/.git/
worktrees/" matches the new worktree's git dir during `worktree add`, so it
loads a filter discovery never saw. Refuse on ANY repo-local includeIf
(global/system config is already /dev/null).
- The discovery probe used subprocess.run(timeout=2), which on Windows can
deadlock in the post-timeout communicate() and flashes a console. Use
bounded_probe_run (rc 1 = "no filters" stays a verdict, so not
bounded_git_probe).
Also share the refusal stderr as FILTER_DISCOVERY_FAILED across the kanban
and worktree-gc call sites and the test, and fold the fail-closed test into
the filter test (shared repo/env helpers) with case_collision and
gitdir_include rows.
Every discovered filter key adds GIT_CONFIG_KEY/VALUE env entries. A repository
with tens of thousands of filter sections makes the spawned git fail with
OSError(7, "Argument list too long") inside kanban/worktree-gc `_git`, which do
not expect it. Past 256 keys, fail closed like any other untrusted discovery.
One parametrized invariant covers both fail-closed paths (onbranch include and
filter flood) through the kanban `worktree add` call.
Fixes#126017
noninteractive_repo_git_env discovers repository-named filter keys with
`git config --includes`, which evaluates includeIf "onbranch:..." against the
branch checked out NOW. A `worktree add -b X` then checks out X, git loads the
X-only include, and its smudge filter runs with no override pinned. Any
onbranch include in the effective config means the inventory cannot be trusted,
so return None and let callers skip the automatic git call.
Refs #126017
The reclaim sweep (_reap_prune_verdicts), the orphaned-branch pass
(_prune_orphaned_branches) and the cleanup after a failed worktree add
(_cleanup_failed_worktree_add) ran `git branch -D` with the inherited
environment. A ref update runs the repository's reference-transaction hook
through core.hooksPath, so the unattended sweep executed whatever the
checkout's own config named. The three calls now use the same hardened env
as the other worktree_ops calls, with stdin closed.
(cherry picked from commit 31eec2f67e47a39c1b715f55d4fa649208076473)
The reclaim sweep's ls-remote, the shallow-repo fetch --unshallow and both worktree prune calls ran with the inherited environment, so a repository-level core.sshCommand or credential.helper ran unattended. They now use the same pinned env as the base fetch.
(cherry picked from commit a0d8e2a592790a90f6b666445017ea73f88cbc63)
Note the Git LFS consequence (pointer files) and the fix (git lfs pull in the worktree), EN and zh-Hans.
(cherry picked from commit 9870e29a42a77865eac79d01d3b99d516e5ebf0f)
finalize_subagent_worktree's status probe re-hashes files the child touched, so it ran a
repository-named clean filter after every delegated subagent. worktree_ops._worktree_is_dirty, the
dirty probe kanban teardown and the stale-worktree sweep run unattended, called git status with no
hardened env at all: core.fsmonitor ran as well. worktree_ops._worktree_add (hermes -w) had the
fixed pins only, so its checkout ran a named smudge filter.
All three now use noninteractive_repo_git_env. When filter discovery fails, finalization keeps the
worktree as unproven, the dirty probe reports dirty (the reclaimers keep the tree), and hermes -w
refuses to create the worktree. worktree remove --force at prune runs no filter once the status
probe is neutralized.
(cherry picked from commit e727281137a495602457fe4a86b51212af2fdbb7)
The recovery-hint/completion test and the kanban/worktree-gc test ran the same malicious-repo fixture; one test now runs all four sites. Reverting any one site alone still turns it red, so the PR keeps two invariant tests.
(cherry picked from commit 0b1a5da4055b26aa278bcba444635aeb05a943a1)
The session-start workspace snapshot runs git status, which re-hashes a
re-timestamped file through the repo-named clean filter, and a delegated
subagent's worktree add runs the smudge filter. Both now use
noninteractive_repo_git_env and skip the call when filter discovery fails.
(cherry picked from commit 04170a83d19a026fe6c5f968ce6e61b61f53c418)
worktree add runs a repo-configured smudge filter and status runs its clean filter;
the fixed env pins cannot name the driver. These three sites now use
noninteractive_repo_git_env and skip the call when filter discovery fails. The Cmd-P
ls-files probe stays on the plain env: it never hashes or checks out content.
(cherry picked from commit 8d1233886a477f8fc623caee65dd28ce5e080eeb)
A repository names its clean/smudge/process filter drivers in .gitattributes, so the
fixed GIT_CONFIG_KEY pins in noninteractive_git_env cannot enumerate them. Discover the
checkout's filter command keys under the isolated env and append empty command overrides
plus required=false. Discovery failure returns None so callers skip the git call.
Ported from #126075.
(cherry picked from commit ebdb7feb263512a2e76f06d9ee20d483af6adc24)
The recovery-hint git status, the TUI fuzzy-completion ls-files, the kanban dispatcher's worktree add and the worktree gc wrapper spawned git with the inherited environment, unlike the other Hermes-initiated git calls hardened in #101483.
(cherry picked from commit 3a8a79e6815f802b62195a24a05bc4239947fe17)
The hourly housekeeping loop hard-coded 24 while every cache accessor
defaults to MEDIA_CACHE_MAX_AGE_HOURS; use the constant so the two cannot
drift (docstring too). iter_cache_files did is_symlink + is_file + stat
per item behind an Optional-cutoff helper; one lstat per item with
S_ISREG (symlinks still excluded, unreadable entries skipped) and a
hoisted generated-root string give identical results with fewer syscalls.
cache/generated holds provider-generated images/videos that are now
deliberately never swept (they are the only copy of the user's output).
_KEPT_CACHE_SUBDIRS did not list it, so `hermes backup` treated it as a
regenerable cache and dropped it — for the root home and profile homes.
Add GENERATED_SUBDIR (stdlib-only agent.provider_media) to the kept set
and pin both locations in the existing cache-pruning backup test.
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)
`hr.read_record(hr.ROLE_GATEWAY)` re-reads the on-disk, undrifted record,
so it never exercises the `drifted` copy the test is about; the
record_is_stale/liveness_is_proven asserts on `drifted` already cover the
invariant. Removing it keeps the test focused on drift tolerance.
The block is entered only when profile_is_standalone(home) is True, and
standalone_attach_decision returns None only when that same check is False,
so the trailing _refuse_second_host_gateway(owner) could never run. The
function's final refusal still covers every non-standalone path.
The WSL create_time drift test was linux-only and monkeypatched
_pid_incarnation_matches, so it never exercised the real start-fingerprint
path and was skipped on macOS. Drift the record's create_time with
dataclasses.replace instead: the live PID's stable start fingerprint must
still prove liveness. Red on the pre-fix host_rendezvous.py, green on POSIX.
Also pin the standalone lock-loss probe sequence exactly
(calls == [None, None]) rather than a loose length/last-element check.
Behaviour-neutral review cleanups for the stack:
- fuzzy_match: _preserve_unicode_in_replacement now requires the plan
(it is always precomputed by the caller), so the unused old_string param
and the plan-less fallback go away. _apply_replacements takes the
precomputed unicode_plan instead of a preserve_unicode flag, keeping the
strategy decision in fuzzy_find_and_replace where it is made.
- file_operations: _json_nonstandard_constant skips the full json.loads
when the text cannot contain NaN/Infinity (every JSON write pays this
scan), strips the BOM via the shared _strip_bom helper, and catches only
the errors json.loads can raise on str input instead of a blanket
Exception.
- backup: the --keep validator comment now states why it exists (a
negative keep slices away the newest archives).
_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.
`/snapshot prune -1` passed keep=-1 to prune_quick_snapshots, whose slice
then deletes the newest snapshots instead of the oldest - the same bug
class #128886 fixed for `hermes backup --keep`.
Use isdecimal() for both entry points. For `backup --keep` the argparse
type is now the single validation (cmd_backup is run_backup's only
caller), so the duplicate run_backup guard is removed; non-numeric input
still exits 2 with a clear message.
The test is folded into tests/hermes_cli/test_backup.py, replacing
test_backup_retention_validation.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.
b533839532 gates _loop_wakeup_fire_one on the emergency stop because loop
wakeups are internal events that skip the inbound estop gate, but nothing
exercised that leg. Fold a pause leg into the existing direct-harness loop
wakeup test: while engaged, fire_tick is never called and no turn reaches the
adapter; after disengage the same due tick fires exactly once. The estop
sentinel is isolated under the test's own HERMES_HOME.
Reverting run_goals.py to b533839532^ turns this red
(fire_tick claimed + "tick" injected while paused).
/loop wakeups are injected with internal=True, and _hm_estop_gate returns early
for internal events, so `hermes pause` did not stop loop ticks from starting
agent turns. This is the same problem the heartbeat poller fix handles.
Check the emergency stop before LoopManager.fire_tick. is_due() is read-only and
fire_tick() is what moves next_due_at forward, so the tick stays due and fires on
the first scan after `hermes resume`. The watcher sleeps for its interval (15s)
after every scan, so being paused does not make it busy-spin. check_paused logs
once per engagement.
The poller now checks `hermes pause` before claiming a heartbeat tick, so the
inbound gate only sees heartbeats that slipped past that check. Say so, and
point at settle_heartbeat_attempt as what keeps the tick due for after resume.
While ESTOP was engaged the heartbeat poller kept injecting the due tick every
5s; the estop gate answered each one with the user-facing paused notice and the
refunded tick stayed due, so the notice was re-sent every poll (~10k Telegram
messages in 5h, twice in prod).
- _heartbeat_poll_watch: skip before claiming the tick while paused (logged once
per engagement via check_paused); it fires normally after hermes resume.
- _hm_estop_gate: a synthetic heartbeat event is dropped silently; real user
messages still get the paused notice.
(cherry picked from commit 74c04121e35e4479f59dd522281298a7142ea951)
Catalog policy (Teknium): a listed plugin extends Hermes only through public
surfaces. A plugin that replaces core functions, methods or Desktop UI in place
collides with every other plugin patching the same seam and breaks on any core
release.
- New `no core override` admission check (hermes_cli/plugin_validate_core_override.py):
static taint pass over the import closure of __init__.py and dashboard/*.py.
Core values are Hermes modules (plus hermes_plugins.*) and attribute chains off
them, reached by import, importlib/sys.modules lookups, getattr, iteration, or a
plugin helper that returns one. Rebinds are attribute assignment/deletion,
setattr/delattr, sys.modules writes, writes into a core module's tables,
mock.patch, and passing a core value to a helper that setattr's its parameter.
Tests, benchmarks and CI/smoke scripts outside the import closure are not judged.
- Desktop lint: new `app DOM reach` rule (document-level queries of the app's own
data-slot/data-tour/data-sidebar/data-testid markup, and document.body
childList/subtree MutationObservers).
- Desktop lint bug: the comment stripper did not skip string literals, so a string
holding `/*` (a glob like '**/*.md') opened a fake comment and hid every line up
to the next `*/` from all rules. Strings are now matched first and kept.
- README rule 9, the plugin-catalog user guide and the plugin developer guide's
compatibility contract state the rule.
A/B over 466 pinned trees (121 open catalog PR entries + 349 listed entries +
humalike): the new checks flag exactly the hand-verified overrides; zero crashes;
no previously reported Desktop finding disappears.
The session-identity test compared full private _delivery_info records,
pinning fields (mirror, route, profile) this fix does not change, so any
new record field would break it without a behaviour change. Assert only
each identity's own deliver_extra.chat_id, which still collides on the
old webhook:{route}:{id} shape, and drop the unused payload.
Header-less POSTs fell back to a millisecond timestamp as their delivery
id. Now that the id is the dedup key within a route, two distinct
header-less deliveries to the same route in the same millisecond were
treated as a retry and the second was dropped as "duplicate". A uuid4
keeps each header-less delivery unique; providers that send a delivery
header still dedup on it.
The standalone parser-differential test duplicated the authorize-rejection
assertions of test_native_redirect_uri_boundary_matches_browser_authority;
moving its URI into the rejected tuple keeps the coverage (400, no cookie)
with one test instead of two.
The host check was a three-branch if/elif mapping each accepted loopback
literal to its URL authority spelling; a lookup table states the same
allow-list in one place. The final urlunparse also re-set scheme, netloc
and fragment even though every earlier check already guarantees they are
canonical (http, netloc == canonical_netloc, '#' rejected), so only the
empty-path -> "/" normalisation is applied. Behaviour and 400 messages are
unchanged.