Review follow-up on the private-page action guard:
- Add test_guard_inactive_does_not_block_or_probe: when the SSRF guard is
inactive (local backend / allow_private_urls), click/type/press must proceed
WITHOUT probing the page URL. This is the branch most likely to silently
regress if the guard condition is inverted; a mutation check (flipping the
condition) confirms the test fails as designed.
- Add test_camofox_short_circuits_before_guard: camofox mode returns from the
dedicated camofox_* path before the guard runs; guards never consulted.
- Fix PEP8: 3 -> 2 blank lines before _blocked_private_page_action.
When the primary provider's auth fails (expired token / 429 quota cap),
_resolve_runtime_agent_kwargs() falls through to the fallback provider
chain, whose runtime dict carries its own 'model' key. api_server's
_create_agent then did AIAgent(model=model, **runtime_kwargs), colliding
on 'model' and 500ing every /v1/chat/completions request while a fallback
was active. Pop the runtime model and let it override the config model,
mirroring the native gateway path (_resolve_session_agent_runtime).
Salvaged from #35716 by @ryo-solo (earliest submitter); the PR's second
half (Mistral reasoning_content strip) is already handled on main and
dropped.
Co-authored-by: Hermes Agent <noreply@nousresearch.com>
hermes doctor's final 'configure missing API keys' summary counted every
toolset with unmet key requirements, including default-off and explicitly
disabled ones. Filter the summary to toolsets actually enabled for the CLI
platform, with a graceful fallback to prior behavior when config resolution
fails.
Fixes#11336
Bare print() output is swallowed by patch_stdout while an interactive
prompt_toolkit Application owns the terminal, so /sessions and /history
rendered nothing. Route those emissions through _cprint (prompt_toolkit's
native renderer) when an app is running, and fall back to print otherwise.
Fixes#36815
## What does this PR do?
Closes a critical hole in the hardline command floor. HARDLINE_PATTERNS is
the unconditional last line of defense: detect_hardline_command runs BEFORE
every yolo / approvals.mode=off / cron approve-mode bypass, so it is the only
gate standing between the agent (or a prompt-injected instruction) and an
irrecoverable disk wipe. The three rm rules anchored on a bare path token,
and _normalize_command_for_detection never strips shell quotes — so the
ordinary, recommended shell idioms slipped straight through:
rm -rf "/" rm -rf '/' rm -rf "/etc"
rm -rf "$HOME" rm -rf ${HOME} rm -rf "${HOME}"
All of these returned NO hardline match. A leading quote pushes the path out
of reach of the flag group, a trailing quote breaks the `(\s|$)` terminator,
and the `${HOME}` brace form was never listed at all. Under --yolo,
approvals.mode=off, or cron approve-mode the dangerous-command layer is also
skipped, so these commands reached execution with zero gate — exactly the
unrecoverable data loss the floor is documented to make impossible. Because
quoting paths and `${HOME}` are normal shell usage, not exotic obfuscation,
this is a high-severity, easily-triggered bypass.
The fix makes the rm path matcher quote- and brace-tolerant while staying
conservative: a path is matched when it is either fully wrapped in its own
matching quote pair (`"/"`) or bare with a whitespace/end terminator. The
matching-quote requirement is deliberate so the change adds no new false
positives — a dangerous-looking string that is merely an argument to another
command (e.g. `git commit -m "rm -rf /"`) has a closing quote but no opening
quote of its own around the path, so neither branch fires.
## Related Issue
N/A
## Type of Change
- [x] 🔒 Security fix
## Changes Made
- `tools/approval.py`: added `_hardline_rm_path()` (matches a destructive
path either fully quoted or bare-with-terminator), factored the protected
system-dir list into `_HARDLINE_SYSTEM_DIRS` and the rm flag prefix into
`_RM_FLAG_PREFIX`, and rebuilt the three rm `HARDLINE_PATTERNS` on top of
them, adding the `${HOME}` brace form. Kept as plain concatenation so regex
backslashes never land inside an f-string field (Python 3.11 floor).
- `tests/tools/test_hardline_blocklist.py`: added quoted (`"/"`, `'/'`,
`"/etc"`, `"$HOME"`, ...) and brace (`${HOME}`, `"${HOME}"`) cases to the
must-block set, a dedicated `_QUOTED_BRACE_BYPASS` regression parametrization,
no-false-positive guards (`git commit -m "rm -rf /"`), and extended the
yolo-cannot-bypass integration test to cover the quoted/brace forms.
## How to Test
1. Reproduce the bypass on `main`: `detect_hardline_command('rm -rf "/"')`
returns `(False, None)` — the floor lets it through.
2. With this change it returns `(True, "recursive delete of root filesystem")`;
the same holds for `'/'`, `"/etc"`, `"$HOME"`, `${HOME}`, `"${HOME}"`.
3. Run the suite: `scripts/run_tests.sh tests/tools/test_hardline_blocklist.py`
— 125 passed, including the new bypass and no-false-positive cases.
## Checklist
### Code
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits (`fix(scope):`, etc.)
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix (no unrelated commits)
- [x] I've run the relevant tests and they pass
- [x] I've added tests for my changes (required for bug fixes)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)
### Documentation & Housekeeping
- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) — pattern-only change, ruff + footgun gate pass
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
Cron's job runner was the last entry point still reading
fallback_providers/fallback_model as an either/or, silently dropping the
legacy fallback_model when fallback_providers was set. Every other entry
point (cli, gateway, oneshot, fallback_cmd, tui_gateway, auxiliary_client)
already merges both keys via get_fallback_chain(). This aligns cron with
them at both call sites: the auth-fallback resolution loop and the
AIAgent(fallback_model=...) argument.
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
The forum-topic skill-binding lookup assumed config.extra['group_topics']
was always a list of {chat_id, topics} entries. When an operator writes the
natural mapping shape ({"-100...": [...]}), iterating yields string keys and
chat_entry.get(...) raises AttributeError, breaking dispatch for that group.
Normalize both shapes to a common iterator and guard non-dict/non-list
entries so malformed config falls through cleanly instead of crashing.
mcp-tokens/ holds live MCP OAuth access tokens (<server>.json) and
dynamically-registered OAuth client credentials (<server>.client.json),
layout per tools/mcp_oauth.py. This is the same credential class as
auth.json/credentials/, which _media_delivery_denied_paths() already
blocks. The write side already denies this dir (file_tools
_check_sensitive_path), but the media-delivery (read/exfil) side did
not, leaving an unpaired half-door.
Without it, a prompt-injection MEDIA: tag emitting
~/.hermes/mcp-tokens/<server>.json would, in default (non-strict)
mode, pass the denylist and exfiltrate a live OAuth bearer token to
the same untrusted channel. Sibling follow-up to commit 4ec0adebe
(config.yaml media-delivery denylist).
mcp-tokens is a directory and _path_under_denied_prefix already does
containment matching, so the whole subtree (.json/.client.json/
.meta.json) is denied, mirroring credentials/.
A context-compaction handoff banner is inserted with role="user" when the
protected head ends in an assistant/tool message. On a resumed or
multi-compaction session, _find_last_user_message_idx would return that
banner as the latest user turn, so _ensure_last_user_message_in_tail anchored
the tail to the summary and rolled the genuine last user message into the
next compaction — the exact active-task loss the anchor exists to prevent
(#10896/#22523).
Reuse the existing _is_context_summary_content helper to skip summary banners
when locating the last real user message.
Salvaged from #36626 by Frank Song (issue #36624). The PR's other two changes
(demoting completed tool results inside the protected tail; a preflight
compression_exhausted result) are superseded on current main by the min_tail
floor (#39170), the no-op compression counting (#40803), and the existing
413/disabled terminal-error paths.
Inside httpx AsyncClient response event hooks, response.next_request is
often None even for a genuine redirect, so guards keyed on
`if response.is_redirect and response.next_request` silently never fire.
A public URL that 302s to http://169.254.169.254/ was followed anyway,
defeating the pre-flight is_safe_url() check.
Resolve the redirect target from the Location header (via urljoin, so
relative Locations work too), falling back to next_request only when no
Location is present. Extracted as tools.url_safety.redirect_target_from_response
and wired into every SSRF redirect guard:
- gateway/platforms/base.py (shared image + audio download for all platforms)
- tools/vision_tools.py (two download hooks)
- plugins/platforms/slack/adapter.py
Original fix by @zapabob (PR #35940), which targeted the since-refactored
gateway/platforms/slack.py; reconstructed onto the current shared sites and
widened to the whole bug class.
Review of the salvage found the timeout-message redaction left the more
common failure mode unguarded: when the first websockets.connect(cdp_url)
fails (bad URI / refused / TLS), the raw websockets exception -- which
embeds the full cdp_url incl. ?token= and user:pass@ -- is stashed as
_start_error and re-raised verbatim by start(), and two reconnect
logger.warning sites log the same raw exception.
Add a module-level _redact_cdp_error_text() chokepoint (delegating to
agent.redact.redact_cdp_url) and route all four supervisor egress points
through it:
- start() TimeoutError message (already covered; kept)
- start() _start_error re-raise -> now raises a redacted RuntimeError with
'from None' so no secret leaks via message OR traceback cause chain
- connect-failed and session-dropped reconnect warnings
Guard tests assert the re-raised message is redacted for both token and
userinfo, the raw cause is suppressed, and the helper preserves non-secret
context (host/reason). Verified with a mutation check: reverting to the raw
'raise err' fails the new tests. Correct the redact_cdp_url docstring to
scope its guarantee to direct-URL redaction and point exception callers at
the supervisor helper.
The session-log fix (browser_tool._sanitize_url_for_logs) and the
supervisor attach-timeout fix (CDPSupervisor.start) both composed the
same three redactors (redact_sensitive_text -> _redact_url_query_params
-> _redact_url_userinfo) to mask CDP endpoint credentials. Two copies of
one policy drift: tune one site (e.g. add fragment masking) and the other
silently re-leaks.
Promote that composition to a single public helper redact_cdp_url() in
agent/redact.py -- the one place the CDP-URL redaction policy lives -- and
route both call sites through it (_sanitize_url_for_logs becomes a thin
wrapper; the supervisor imports the helper instead of re-composing the
private redactors). Add direct unit tests for the seam covering query
tokens, multiple credentials, userinfo passwords, plain-URL passthrough,
non-string/exception coercion, and None.
No behavior change at the call sites; both leak paths remain closed.
PR #54851 added _sanitize_url_for_logs() and wired it into the three log
sites inside _resolve_cdp_override(). A fourth site was missed:
_create_cdp_session() logs the already-resolved cdp_url unconditionally,
and CDPSupervisor.start() interpolates the raw cdp_url[:80] into the
attach-timeout TimeoutError (which _ensure_cdp_supervisor() logs with %s).
Both leak query-string credentials (e.g. ?token=secret from hosted CDP
providers) into Hermes logs.
Sanitize the URL at both remaining sites. The raw URL is preserved
unmodified in the returned session dict and used for the real connection;
only the logged/error representation is redacted.
Salvaged from #55883.
Co-authored-by: srojk34 <286497132+srojk34@users.noreply.github.com>
Ephemeral empty-response/prefill recovery scaffolding (the synthetic
assistant "(empty)" turn, the user nudge, the terminal "(empty)"
sentinel, and the thinking-only prefill placeholder) exists only to
drive the next API retry; the in-memory loop pops it before appending
the real response. The append-only flush did not mirror that, so a
mid-turn persist could commit scaffolding to the SQLite session store
(and JSON log), and a resumed session would replay synthetic
"(empty)"/nudge turns as genuine context — re-poisoning the empty-retry
boundary forever.
Filter ephemeral scaffolding at both durable-write sites
(_flush_messages_to_session_db + _save_session_log), by flag not
position, so buried scaffolding (an answered nudge leaves the synthetic
pair mid-list) is skipped too. Covers all three flags including
_thinking_prefill.
Adapted onto current main's identity-tracking flush.
Cherry-picked from #41281 by petrichor-op.
Whole-bug-class follow-up to the tui_gateway fix: the same -1
last_prompt_tokens sentinel (parked by conversation_compression after a
compression) leaked into other status readers, producing a raw -1 or a
NEGATIVE usage_percent on the transitional turn:
- agent/context_engine.py get_status() (the ABC default every external
context engine inherits) — highest blast radius
- gateway/slash_commands.py /usage context line
- cli.py session usage printout
All clamped to >=0, mirroring cli.py _get_status_bar_snapshot and the
tui_gateway fix. Adds an ABC get_status sentinel-clamp regression test.
The salvaged fix guards with `if ctx_max and last_prompt`, but last_prompt
comes from `last_prompt_tokens or 0` — the post-compression -1 sentinel
(conversation_compression) is truthy, so it leaked context_used=-1 on the
transitional turn. Clamp <0 to 0 so it reads as unknown (no gauge), matching
the CLI status-bar path (cli.py _get_status_bar_snapshot).
Follow-up on the salvaged #50518 (r266-tech).
_get_usage substituted the cumulative lifetime session_total_tokens into
the current-window context_used when an external context engine did not
report last_prompt_tokens, producing impossible status-bar readings
(e.g. 1.9m/120k clamped to 100%). Populate context_used/percent only
from a real current occupancy; leave the gauge unset otherwise. The
built-in compressor always reports last_prompt_tokens, so it's unaffected.
Fixes#50421.
Migrating the scheduler-reload seam from a single dotenv.load_dotenv patch to
two patches (load_hermes_dotenv + reset_secret_source_cache) lengthened the
positional list _make_run_job_patches returns, so the 4 callers that applied
patches[0..4] silently dropped the resolve_runtime_provider patch (now at [5]).
Under CI's hermetic env (all API keys blanked) auth then failed and AIAgent was
never constructed → 'NoneType has no attribute kwargs'. Callers now apply
patches[0..5]. Passed locally (keys present) but failed on CI shard 5/8.
Two live cron bugs, both surfaced by @banditburai in #35616 (whose larger
watchdog/supervisor work is already superseded by the CronScheduler provider
refactor on main):
- #32896: `cron list` crashed on a present-but-null `deliver` field —
`job.get("deliver", ["local"])` returns None for an explicit null, which
then hit `", ".join(None)`. Coalesce with `or ["local"]` (same pitfall
the sibling `repeat` line already guards against).
- #33465: cron jobs 401'd on Bitwarden/BSM-backed secrets. The per-run env
reload used a bare `load_dotenv(override=True)`, which re-applied only the
.env placeholder — startup had already recorded this HERMES_HOME in
env_loader._APPLIED_HOMES, so the external-secret re-pull no-oped. Route the
reload through load_hermes_dotenv() and call reset_secret_source_cache()
first to force the re-pull (Bitwarden's 300s value-cache keeps it off the
network; override honours secrets.bitwarden.override_existing, mirroring
startup).
Tests: null-deliver regression guard in test_cron.py; reset-before-reload
ordering guard in test_scheduler.py. Migrated 31 scheduler-reload test seams
from patching dotenv.load_dotenv to the new load_hermes_dotenv /
reset_secret_source_cache seam.
Salvaged from PR #35130 (the safe subset of jnibarger01's security pass):
- threat_patterns.py: replace unbounded (?:\w+\s+)* filler with bounded
{0,8} + cap scan input at MAX_SCAN_CHARS (64KiB), and bound the .*
runs in the exfil/config-mod patterns. Kills catastrophic backtracking
on adversarial near-misses.
- hermes_state.py: cap FTS5 query length (MAX_FTS5_QUERY_CHARS) and
extract quoted phrases with a linear scan instead of a regex so
pathological quote runs can't induce backtracking.
- acp_adapter/edit_approval.py + agent/tool_dispatch_helpers.py: recognize
'*** Move File: src -> dst' V4A headers so patch-mode edits are
permissioned/traversal-checked (previously only Update/Add/Delete), and
surface a proposal for mode=patch V4A calls (previously replace-only).
Tests: +ReDoS-bound + FTS5-cap + Move-File-target + V4A-approval cases.
The MCP input-schema normalizer in _normalize_mcp_input_schema promotes the
legacy JSON Schema 'definitions' meta-keyword to '$defs' (draft 2019-09+)
so local '$ref' resolution works downstream. The previous walk renamed
*any* key named 'definitions' anywhere in the tree, including inside
'properties' dicts. That turned user-facing parameter names into '$defs',
producing property keys that contain '$', which Anthropic and OpenAI
both reject with HTTP 400 (pattern '^[a-zA-Z0-9_.-]{1,64}$').
Real-world repro: an MCP server that exposes a CI/pipelines tool whose
'definitions' parameter is an array of pipeline-definition IDs. Such a tool
is enough on its own to break every conversation, because the full tools
array is sent on every request.
Fix: when descending into a 'properties' or 'patternProperties' mapping,
iterate property-name -> schema pairs directly, leaving the property names
verbatim. Ordinary JSON Schema semantics resume inside each property's
schema, so a legitimately nested 'definitions' meta-keyword inside a
property's schema is still promoted.
Adds two regression tests:
- test_definitions_as_property_name_is_preserved (the property-name case)
- test_definitions_property_and_meta_keyword_coexist (both forms in one
schema; the property name stays, the meta-keyword promotes)
The gateway/API server rebuilds the in-memory TodoStore by replaying
caller-supplied conversation_history. _hydrate_todo_store previously
accepted any role:tool message containing a "todos" array, so a forged
bare tool result could seed arbitrary todo state and re-inflate context
every turn (GHSA-5g4g-6jrg-mw3g).
Restrict hydration to tool results paired with an earlier assistant
todo tool call (matching tool_call_id, function name == todo, no
user/system boundary between). Reuse the existing _get_tool_call_id/
name_static helpers so dict- and object-shaped tool calls both work.
Add a generous MAX_TODO_RESULT_CHARS payload guard to drop absurd
forged results before parsing; item/content caps already exist on main.
Co-authored-by: Hermes Agent <agent@nousresearch.com>
The handoff seed path inlined its own int(chat_id) > 0 private-chat
check; delivery.py already had the identical heuristic. Promote it to
a public name and reuse it from both sites instead of duplicating.
Follow-up for salvaged PR #35840: current main removed the
use_llm_processing kwarg (LLM summarization dropped) and moved the input
SSRF gate to async_is_safe_url. Adjust the new firecrawl-final-url test
to match.
Two related bugs caused subagent delegation to silently return empty summaries
with 0 tokens when the user configured delegation.provider=bedrock alongside
delegation.base_url=https://bedrock-runtime.<region>.amazonaws.com.
Root cause #1 — misrouting in _resolve_delegation_credentials():
The configured_base_url branch unconditionally forced provider='custom' and
api_mode='chat_completions', only specializing for chatgpt.com, anthropic,
and kimi hosts. Bedrock (and other native-SDK providers) fell through as
'custom' + chat_completions, which then POSTed OpenAI-shaped JSON at
Bedrock's native API. Bedrock rejected the payload and returned nothing,
which looked like an empty LLM response to the child agent.
Fix: when provider is one of {bedrock, vertex, google, google-genai}, skip
the base_url short-circuit and fall through to resolve_runtime_provider(),
which knows how to construct the proper SDK client. base_url can still be
forwarded through that path for regional overrides.
Root cause #2 — '(empty)' sentinel accepted as success:
After N retries of empty LLM responses, run_agent.py emits the literal
string '(empty)' as final_response. _run_single_child then hit
`elif summary:` — '(empty)' is truthy, so status became 'completed' and
the parent surfaced a blank result with no error. Users saw api_calls=4,
tokens=0, duration~0.4s, status=completed.
Fix: treat final_response.strip() == '(empty)' as a failure so the parent
surfaces it instead of silently accepting zero-content 'success'.
Both paths were reproduced in a live Hermes TUI session on us-west-2 Bedrock
(provider=bedrock, model=us.anthropic.claude-sonnet-4-6) and are covered by
new tests in tests/tools/test_delegate.py.
`_resolve_chat_guid` no longer consults the participants list — it
matches strictly on `chatIdentifier`/`identifier`. The
`with: ["participants"]` request parameter is now wasted bandwidth on
every chat list query and serves no purpose. Drop it so the BlueBubbles
server can skip the participant join on each call.
No behavioral change; pure payload trim.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The outbound chat resolver in BlueBubblesAdapter._resolve_chat_guid()
matched on participant addresses after the exact chatIdentifier check,
which let an outbound DM reply leak into a group thread when the same
contact existed in both a 1:1 DM and a group chat: if the group chat
was returned earlier by /api/v1/chat/query and the DM's
chatIdentifier differed from the bare address, the participant match
on the group fired first and returned the group GUID. That GUID was
then cached under the bare address, so every subsequent reply went to
the wrong chat.
Restrict resolution to:
1. raw GUID passthrough
2. exact chatIdentifier / identifier match
When no exact match exists the resolver now returns None and the
caller already handles that path safely: send() creates a fresh DM via
_create_chat_for_handle for address-shaped targets, and
_send_attachment fails with a clear "chat not found" error rather than
guessing into a group.
Adds regression tests under TestBlueBubblesGuidResolution covering:
- exact chatIdentifier match still resolves to the DM
- participant-only presence does not resolve to the group
- the DM is chosen even when the group is returned first
- unresolved targets are not cached (no stale-None and no stale-group)
Fixes#24157.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HermesCLI.process_command() and tui_gateway command.dispatch both handle
type: exec quick commands via subprocess.run(shell=True) with no env=
parameter, so the child inherits the full process environment — all API
keys and bot tokens stored in os.environ are visible to the script.
Any output is returned raw to the terminal or web-UI client without
redaction.
Fix: mirror the approach applied to gateway/run.py in #23584.
Apply _sanitize_subprocess_env() before spawning the subprocess and
redact_sensitive_text() on the collected output before display.
Symmetric across all three exec quick-command paths.
Parity with gateway/run.py fix in #23584.
`hermes debug share` printed a privacy notice and then uploaded the
report to a public paste service in the same breath — the user never got
to say yes or no. Add a consent gate: an interactive [y/N] prompt, a
--yes/-y flag to skip it, and a hard refusal (exit 1) in non-interactive
contexts (no TTY on stdin) so debug data can't be exposed silently in
scripts/CI.
- New _confirm_upload() helper gates the actual upload after the notice.
- Applied to BOTH upload paths: the public paste.rs path and the --nous
Nous-S3 path (the latter is a sibling site the original PR missed).
- The /debug slash command passes yes=True (typing /debug is itself the
consent action, and input() would hang inside prompt_toolkit).
- Rewrote the privacy notice for accuracy: secrets (API keys/tokens/
passwords) ARE force-redacted before upload; PII (display name,
platform user ID, verbatim message content, filesystem paths) is NOT,
and that URL is public.
Fixes#22016.
Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
When the last user message sits exactly at head_end (the first compressible
index), _ensure_last_user_message_in_tail's final max(last_user_idx,
head_end + 1) clamp returns head_end + 1, pushing the user into the compressed
region without its assistant reply. The summariser then records it as a
pending ask, and the next session re-executes the already-completed task
(lights off twice, file deleted twice, message re-sent).
Fix: apply Causal Coupling — a compaction boundary must never split a
(user -> assistant [-> tool results]) turn-pair. Add _find_turn_pair_end and,
when the clamp would orphan the user, push the cut forward to pair_end so the
completed pair is summarised together and marked done.
8 new tests in TestTurnPairPreservation; 133 compressor tests pass.
Under display.busy_input_mode: queue, sending two messages back-to-back
hung the session on 'Analyzing…' until a manual Ctrl+C.
The submit path only marked the session busy inside the .then of an
async input.detect_drop RPC. dispatchSubmission routes queue-vs-send on
getUiState().busy, so a second Enter inside that RPC window read
busy===false and raced a second prompt.submit down the send path
instead of enqueuing locally. The gateway accepts the mid-turn submit
as a success ({status:'queued'}, not an error), and the client's only
re-queue recovery is gated on catching a 'session busy' error — which
never fires — so the message became invisible to the client-side drain
effect and the UI stayed busy forever.
Extract the ready-prompt submit into a pure submissionCore module and
mark the session busy synchronously at the choke point, before the
detect_drop round-trip, closing the gap for every caller (mainline
submit, queue-edit picks, drain, interpolation). Verified the real
gateway already queues+drains both turns correctly, so the fix is
purely client-side. Adds submissionCore.test.ts whose regression
assertions fail without the synchronous busy and pass with it.
Follow-up to the salvaged #22070. The cron-deny tirith ImportError branch
was unconditionally fail-open; now it honours security.tirith_fail_open:
false by blocking (a cron session has no user to approve), mirroring the
main flow's fail-closed synthesis (#20733).
Adds regression tests: tirith-only content threat blocked in cron-deny,
plus fail-closed/fail-open ImportError behavior.
In check_all_command_guards, the cron-deny path only ran
detect_dangerous_command (regex patterns). The tirith check starts at
line 1017, after the early return at line 1002, so content-level threats
caught only by tirith (homograph URLs, pipe-to-interpreter, terminal
injection) were silently approved in cron sessions even with
approvals.cron_mode: deny.
Add a tirith call inside the cron-deny block, mirroring the same
ImportError guard used in the main flow.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The CLI routes user input typed while the agent is running into
``_interrupt_queue`` (separate from ``_pending_input``) so the explicit
interrupt path can opt to deliver them as a single combined message.
That path only drains the queue when ``busy_input_mode == "interrupt"``
AND a ``pending_message`` was acknowledged.
If the agent's turn finishes naturally (no interrupt fires), any
messages typed during the turn stay stuck in ``_interrupt_queue``
forever. Subsequent ``Enter`` presses route input to the same blocked
queue and the CLI appears to hang. Original report: lunarnexus in
The fix restores the post-turn drain that was originally part of
drain off as "worth its own review" and never re-landed it; the user-
visible regression is that any non-interrupt-mode user typing during
a turn is silently dropped.
Implementation: extract the drain to a small helper
``_drain_interrupt_queue_to_pending_input`` matching the existing
``_maybe_continue_goal_after_turn`` style. ``process_loop``'s
``finally`` block calls it once per turn after the status-line refresh
and before goal continuation (so re-queued user input preempts an
auto-continuation prompt). The helper swallows ``Exception`` so it
can never break the main loop.
Addresses #20271.
test_no_dedup_seed_when_thread_creation_fails asserted the agent still ran
inline when auto-thread creation failed — the pre-#20243 silent-fallback
behavior. Flip that to assert_not_awaited() to match the new fail-closed
contract; the test's actual contract (phantom thread id must not leak into
the dedup cache on failure) is unchanged. Give the fake channel a send mock
so the failure-notice path runs cleanly.
When discord.auto_thread is enabled and a top-level server-channel message
should be routed to a new thread, a transient thread-create failure (e.g.
Cannot connect to host discord.com:443) returned None and _handle_message
fell through to an inline parent-channel reply — dumping a new task into a
shared channel and breaking thread-first workflows.
- _auto_create_thread retries the primary + seed-message paths once after a
750ms backoff for transient connect errors.
- _handle_message treats None as a hard failure: posts a short visible notice
in the parent channel and returns without invoking the agent. The notify
send is wrapped so a secondary connect error can't raise.
Fixes#20243