Commit Graph

172 Commits

Author SHA1 Message Date
Teknium adf23550f5 fix(tools): profile-scoped checkpoint/snapshot paths, tool caches, TZ and schema paths under multiplex
Under `gateway.multiplex_profiles` one gateway process serves every profile
under ~/.hermes/profiles/NAME/; each routed turn runs with a context-local
HERMES_HOME override while `os.environ` still holds the DEFAULT profile's
values. Anything evaluated once at import, or memoised in a single unkeyed
module slot, therefore freezes the LAUNCH profile's value and leaks it into
every other profile's turns. This lands the tools-side half of that class:

- tools/process_registry.py, tools/environments/{modal,singularity}.py:
  `_checkpoint_path()` / `_snapshot_store()` resolve `get_hermes_home()` at
  call time (same seam as `tools/skills_tool._skills_dir`, so the existing
  `monkeypatch.setattr(CHECKPOINT_PATH)` test sites keep working). Completes
  the checkpoint_manager / sticker_cache half cherry-picked from #56315.
- plugins/platforms/feishu/feishu_comment_rules.py: `_MtimeCache` is now
  path-keyed (accepts a Path or a zero-arg resolver, one (mtime, data) slot
  per resolved path) with `invalidate()`; `_rules_file()` / `_pairing_file()`
  resolve the routed profile's files. Proposed in #63962.
- tools/tool_output_limits.py, tools/browser_tool.py, tools/browser_camofox.py:
  the process-lifetime config caches are dicts keyed by `hermes_home_key()`;
  the `_X_resolved` flags and the lifecycle reset keep their shape.
  tools/file_tools.py drops its private `file_read_max_chars` memo and reads
  the already mtime+path-cached `load_config_readonly()`.
- hermes_time.py: `get_timezone_name()`; when `is_multiplex_active()` the
  env `HERMES_TIMEZONE` (bridged from the default profile's config at gateway
  startup) is ignored in favour of the routed profile's config.yaml. Both
  sandbox TZ sites (code_execution_env/_tool) now use it.
- tools/cronjob_tools.py, tools/tts_tool.py, tools/skill_manager_tool.py:
  the static schema text is profile-neutral and `dynamic_schema_overrides=`
  rebuilds the `display_hermes_home()` / create-dir hint per
  `get_definitions()`, so a routed profile's model sees its own paths.

Refs #95685.

Co-authored-by: Nathan Shan <nathanielcrush51@gmail.com>
(cherry picked from commit 6d3fc6b07b3155c6196b1fd61a829283f1d7855c)
2026-09-11 15:44:00 -07:00
Teknium fdd140557b Merge pull request #103551 from NousResearch/fix/tool-output-caps
feat(file_tools): write_file flags a whole-file rewrite that mostly re-sends what is on disk (661 such writes, ≈$155, in one run)
2026-09-06 12:05:53 -07:00
liuhao1024 478e66475b fix(tools): keep truncated search_files output pure JSON
When results were truncated, search_files appended the pagination hint
as plain text after the serialized payload ("{...}\n\n[Hint: ...]"),
so the tool result was no longer parseable JSON — downstream tool-message
handling on providers strict about tool-content formatting could reject
or mishandle it, contributing to 400 upstream errors in sessions with
truncated search output (#90322).

Move the hint into the payload as a structured _hint field, matching the
existing _omitted/_warning side-channel convention in the same function.
The model-facing guidance (explicit next offset) is unchanged.

Fixes #90322
2026-09-07 00:20:11 +05:30
Teknium 3009efe75e fix(tools): read_file dispatch honors the advertised 2000-line default when the model omits limit
#76996 raised DEFAULT_READ_LIMIT, the read_file_tool signature and the schema
default from 500 to 2000, but the registry dispatch handler `_handle_read_file`
kept its own literal `args.get("limit", 500)`. Since models omit `limit` on most
reads, every dispatched read still stopped at 500 lines with `truncated: true`,
so the default flip never reached production traffic.

All three sites now read the single DEFAULT_READ_LIMIT constant, so the schema,
the Python default and the dispatch fallback cannot drift again.

Live probe (registry.dispatch on a 1500-line file, no limit): before 501 lines /
truncated=true; after 1501 lines / truncated=false.
2026-09-06 05:32:49 -07:00
Teknium 08a3c49812 fix(file_tools): the rewrite hint reads through the task's backend, skips non-regular and oversized files, and compares in linear time
Independent review: the hint read the HOST path even for remote/sandbox
backends (a different file), blocked forever on a host FIFO while holding
the write lock, and used SequenceMatcher, which is quadratic on repeated
lines (a 460 KB same-line file took ~22 s). It now reads the old content via
the task's file ops read_file_raw (regular files only, correct backend),
skips files above 400 K chars, and compares line multisets (Counter
intersection, linear).

Reviewer's own probe on this head: 460 KB repeated-line file 22.3 s -> 0.0 s
(skipped), 230 KB 46 ms, remote backend gets no host-derived hint, FIFO write
returns at once.
2026-09-05 06:32:30 -07:00
RelaxJonh c851ef4d0f fix(tools): file-tools env creation uses _is_container_backend() for the cwd guard (#101013)
_create_terminal_env_for_file_ops tested the static _CONTAINER_BACKENDS set while the
terminal tool's sibling sites already go through _is_container_backend(), which also
honours plugin backends declaring is_container. A host cwd override on such a plugin
backend therefore reached the sandbox as its workdir from the file-tools path only.

Salvaged from #101389 by @RelaxJonh.
2026-09-05 18:03:05 +05:30
Teknium a1ffb27ef8 feat(file_tools): write_file says when it just re-sent a large file that was already on disk
In the 1,393-agent refactor run write_file carried 92.8M chars of content
(23.2M output tokens, ~$580, 20% of all output tokens). 661 of those calls
were read -> whole-file rewrite of an existing file >20k chars (24.9M
chars, ~$155): 130k-char agent/model_metadata.py, 109k
model_setup_flows.py, 107k google_chat/adapter.py, re-sent in full to
change a few lines. Meanwhile patch succeeded 4,602/4,617 (99.7%) at 1.3k
chars per call. The tool result is the only place the model reads, so the
cost signal goes there.

When an overwrite targets an existing file and both old and new content
are >=20k chars, write_file diffs them by line (difflib, autojunk off)
and, if >=80% of lines are unchanged, adds a `hint` naming the unchanged
count, the changed count, the size, and "use patch". The write itself is
unchanged and still happens; new files, small files and genuine rewrites
get no hint. 300 ms on the 119k-char model_metadata.py.

Tests (2): a 40k-char file with 2 lines changed gets the hint and is
written; a new file, a small file and a wholly rewritten file get none.
2026-09-05 02:01:04 -07:00
Teknium d63e380324 compat(plugins): warn once per name when a plugin resolves an old import path; lint step restored in CI
Every PLUGIN-COMPAT __getattr__ now calls hermes_cli.plugin_compat.warn_once(facade, name, target) before
resolving, emitting a HermesPluginCompatWarning (FutureWarning) once per process per name: old path, new
path, removal target. Importing a facade for its live API stays silent; only resolving a moved name warns.
COMPAT_MANIFEST.md documents the warning and how to silence it during migration.

Verified the runtime never routes through a pointer: every entry point (run_agent, cli, hermes_cli.main,
gateway.run, tui_gateway.server, web_server, model_tools + tool discovery, hermes_state, cron.scheduler,
browser_tool, mcp_tool, kanban, auth) imports clean and `hermes doctor` runs end to end with the warning
promoted to an error.

Also restores the check_compat_pointers CI step to .github/workflows/lint.yml, which a0be177aac dropped
when the compat layer was regenerated (the lint script itself was present; the workflow step was not).

hermes_cli/plugin_compat.py, tests/test_plugin_compat_warning.py and the two-line insert per facade are
part of the compat layer and go away with it.
2026-09-04 00:15:16 -07:00
Teknium 2776813df3 compat(plugins): temporary import-path shims for external plugins — ONE commit, revert on schedule
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:

    git revert <this sha>

removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.

What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
  so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
  tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
  relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)

Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
2026-09-03 17:13:22 -07:00
Teknium eb8a30cc2f simplify(compat): skills_sync/skill_manager/kanban/computer_use — drop 37 re-exports/aliases, repoint 8 callers + 9 test files 2026-09-03 13:27:01 -07:00
Teknium b818085298 simplify(compat): doctor/status — drop 13 re-exports + the doctor_* globals() facade (97 names), repoint 6 callers / 13 tests 2026-09-03 13:10:52 -07:00
Teknium e83816a4d1 review-fix(comments): restore lost #NNNN rationale comments across non-test source (mechanical sweep, condensed, code unchanged)
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.
2026-09-03 09:44:26 -07:00
Teknium 0071ba9965 Merge origin/main (561b053f79) into simp/forwardport: forward-port 220 main commits into the simplified tree 2026-09-03 03:31:03 -07:00
Teknium e4436ec9e5 refactor(tools): hand-compact docstrings across file_tools group (keep every WHY) 2026-09-02 23:17:52 -07:00
Teknium 48de33b77f refactor(tools): compact file_tools group docstrings; regex exact strategy; one approval tail 2026-09-02 23:10:54 -07:00
Teknium af95ff9f9f refactor(tools): pack re-export import blocks in file_tools 2026-09-02 23:01:16 -07:00
Teknium 40f2c7951b refactor(tools): AST-neutral layout compaction of file_tools group 2026-09-02 22:51:55 -07:00
Teknium b15ac86686 refactor(tools): file_tools shares write/patch plumbing and terminal env bring-up 2026-09-02 22:31:59 -07:00
Royalaid 413fd2a1fc feat(search): add fast discovery file ordering 2026-09-03 04:56:59 +05:30
RelaxJonh 6fdd93ab45 fix(tools): release per-task file registry state (#86514)
Clear file-operation trackers when a terminal task ends and remove per-path
locks after the last holder or waiter exits. This prevents long-lived gateway
processes from retaining file state indefinitely while preserving sibling-write
tracking and path-level serialization.

Fixes #86514
2026-09-03 04:34:45 +05:30
Teknium aed2e156ad refactor(tools/files): finish file_operations wiring to extracted modules; restore WHY comments in file_tools; repoint test 2026-09-02 14:47:03 -07:00
Teknium d4cec15b47 refactor(tools): first-wave simplification of tools/ (file ops split, lazy_deps, code_exec, approval, browser, delegate, mcp, skills, terminal, voice, media)
Behavior-neutral structural pass over tools/*: god-file extractions into
sibling modules (file_operations_common/lint/search, file_tools_paths/
read_tracking/write, code_execution_env/rpc, tool_search_catalog/names/
validation, tts_command_provider, ...), duplicate helper unification,
if/elif -> dispatch tables, dead-code removal, docstring compaction.
Tool schemas (get_tool_definitions) verified byte-identical to base.
2026-09-02 14:43:45 -07:00
Enzo Adami 02a6e95228 fix(files): allow one full read after compaction 2026-09-03 03:07:37 +05:30
webtecnica 4a0aae8aae fix(agent): preserve tool-output dedup state across context compaction (#84857) 2026-09-03 03:07:37 +05:30
muhifni 1cd736ff63 fix(terminal): scope terminal config per turn under profile multiplexing
A multiplexed Hermes process (gateway.multiplex_profiles, unified
dashboard/TUI, or cron) serves several profiles at once, but terminal.*
resolved through process-global TERMINAL_* env vars bridged ONCE at
startup from the launch profile (gateway/run.py ~2700-2760) plus the
one-shot _ensure_terminal_env_bridged() guard. Every routed profile
therefore inherited the launch profile's backend, cwd, docker volumes,
SSH target and shared-container key: a local profile ran inside another
profile's docker sandbox (or a docker profile escaped to the host), and a
container labeled profile A carried profile B's RW bind mounts.

Fix: an authoritative per-profile terminal policy seam, mirroring
agent/secret_scope.py:

- tools/terminal_scope.py: ContextVar holding the routed profile's
  COMPLETE effective TERMINAL_* policy (defined defaults <- profile .env
  TERMINAL_* <- config.yaml terminal:). While bound, terminal_env()
  resolves ONLY from it - an omitted key yields the defined default,
  never os.environ. Unreadable/malformed policy installs a refusal
  scope; terminal_tool / execute_code refuse instead of running under
  ambient launch-process policy (fail closed).
- Installed at every in-process profile boundary: gateway
  _profile_runtime_scope, tui_gateway session/build/turn scopes, cron
  per-job fire. The unscoped single-process path is byte-identical.
- Every terminal.* consumer reads through the scope: terminal_tool
  (_get_env_config, _resolve_container_task_id shared key, orphan
  reaper lifetime, degraded mode), gateway/platforms/base.py docker
  media translation (volumes, shared key, persistence), runtime_cwd /
  agent_init / skill_utils / code_execution_tool / file_tools cwd
  anchors, prompt_builder / browser_tool / env_probe backend checks,
  gateway footer, @-refs and slash-command cwd. env_probe resolves the
  backend in the caller's context, since the probe worker thread does
  not inherit the ContextVar.

Salvage of #99225 onto current main: adds the three ambient reads the PR
missed (tools/file_tools.py TERMINAL_CWD, tools/browser_tool.py and
tools/env_probe.py TERMINAL_ENV; shape from #79117) and trims the test
module to the leak matrix driven through the real gateway boundary,
omitted-key defaults, refusal, and boundary reset.

Fixes #68559
Fixes #94200
Fixes #101132
Fixes #95470

Co-authored-by: x7peeps <9640837+x7peeps@users.noreply.github.com>
Co-authored-by: Eva <239388517+100yenadmin@users.noreply.github.com>
Co-authored-by: ExitMaster <292490062+ExitMaster@users.noreply.github.com>
2026-09-02 05:34:28 -07:00
Teknium 1ee30352ca fix: background review can now read skills before patching — denial storm ended, cache parity intact (#61521, #39996)
The self-improvement review fork advertises the parent's full tool schema
(deliberate — tools[] must stay byte-identical for prompt-cache parity)
but denied everything except memory/skill tools at dispatch. Models
naturally reach for read_file to inspect a SKILL.md before patching, got
denied, then attempted a blind skill_manage patch which the
read-before-write guard correctly refused. One deployment logged ~142
denials + ~204 refusals over 2 days: the self-improvement loop ran
continuously but almost never landed a skill patch.

Fix is dispatch-side ONLY — zero request-body change, cache untouched:

- Whitelist read_file + search_files on the review fork (reads are
  side-effect-free). Write tools (write_file/patch/terminal) stay denied:
  autonomous maintenance must go through skill_manage's validation.
- read_file now registers full reads with the review fork's
  read-before-write guard (same as skill_view), so the natural
  read_file -> skill_manage(patch) sequence lands. Partial reads
  (offset>1 / truncated) don't count. No-op outside review forks.
- Self-correcting deny message: names skill_view/skill_manage/memory as
  substitutes so one denial redirects the model instead of a storm
  (the actionable half of #61521's proposal 2).

Rejects #39997's alternative (narrow the advertised schema on local
endpoints): local backends have KV/prefix caches too, and re-prefilling
a large snapshot is most expensive exactly there.

Live A/B (real dispatch path, isolated HERMES_HOME): on main,
read_file DENIED -> patch REFUSED (read-before-write); on this branch,
read_file OK -> patch LANDED. tools[] identical in both.
2026-08-29 18:33:41 -07:00
Teknium dadbfd8990 refactor(patch): V4A mode gated to OpenAI-family mains — base schema is replace-only with real required (365 -> 195 for everyone else, -149; handler accepts both shapes from any model) (#97403) 2026-08-28 12:59:41 -07:00
Teknium a9e72f1b58 refactor(read_file): schema diet + bundle anydoc 0.2.4 + typed NeedsOcrError/hosted-OCR wiring (#97195)
* refactor(read_file): capability-gate the anydoc format list; PDF coverage teaching lives in the response-time warning (426 -> 244/291 tok/call)

* feat(read_file): bundle firecrawl-anydoc 0.2.4 in core, typed NeedsOcrError handling, config-gated hosted OCR with local-OCR-first guidance

* refine(read_file): NEEDS-OCR warning hints at checking for an OCR skill without naming one; hosted_ocr knob unadvertised (maintainer-directed)

* simplify(read_file): drop the anydoc schema gate — bundled core dep makes absence a broken install, not a variant; formats stated unconditionally (263 tok/call)

* feat(read_file): PDF wording upgrades to 'scanned or text' when a trusted hosted-OCR route exists (direct key or explicit config; nous gateway excluded until Parse proxy works)

* simplify(read_file): FIRECRAWL_API_KEY is the ONLY hosted-OCR gate — nous gateway route removed (Parse proxy broken), config true no longer unlocks; false still disables
2026-08-28 08:46:11 -07:00
Teknium eff97a8a05 refactor(profiles): retire the cross-profile write guard — profiles are not isolated (maintainer decision); mirror lost-write guards (#32049) survive; patch/write_file schemas drop cross_profile (-83 tok/call) (#97165) 2026-08-28 06:36:22 -07:00
takealook97 5fd6811dfe fix: avoid macOS privacy prompts during broad searches 2026-08-26 06:26:02 -07:00
Teknium 0484910787 feat(terminal): pluggable terminal environment backends via plugin registry
Third-party sandbox vendors can now ship a terminal backend as a standalone
plugin instead of landing in core. Adds the five-piece pluggable-subsystem
pattern for terminal environments:

- agent/terminal_env_provider.py — TerminalEnvironmentProvider ABC with
  declarative classification flags (is_remote, is_container,
  skip_container_guards, cache_path_base, strip_env_keys,
  session_isolated_when_nonpersistent) so every historical
  frozenset-of-names classification site consults the registry instead
- agent/terminal_env_registry.py — thread-safe scoped registry; built-in
  backend names are reserved and unregistrable
- PluginContext.register_terminal_environment_provider() mirroring
  register_browser_provider
- _create_environment falls through to registered providers; unknown-backend
  errors list plugin names
- Classification sites wired: approval guard skip, container path/cwd
  handling (terminal/file/code-exec), prompt-builder env hints + probe,
  host env probe suppression, skills remote-env note, cache path
  translation, subprocess secret stripping (both spawn paths),
  per-session isolation for name-resumed sandboxes
- Surfaces: hermes setup picker + doctor + status rows, dashboard
  terminal-backend picker rows/probe/validation, terminal.backend schema
  options recomputed per request
- Docs: developer-guide/terminal-environment-plugin.md + sidebar + plugins
  capability table
2026-08-24 20:10:44 -07:00
thenabbu acf8245607 fix(tools): pass single_query_deny_message to the ssh-config write approval gate
Commit 1596148ff made single_query_deny_message a required keyword-only
parameter of _run_approval_gate() and updated its two callers inside
tools/approval.py, but missed the third caller: the SSH-config write
guard in tools/file_tools.py (_check_approval_required_write,
pattern_key="ssh_config_write").

Any gated write to an SSH client config therefore raised
  TypeError: _run_approval_gate() missing 1 required keyword-only
  argument: single_query_deny_message
instead of routing through the human-approval flow.

- Pass the kwarg with a single-query-specific deny message that points
  operators at approvals.single_query_mode: approve.
- Add a regression test asserting the gate call passes every required
  kwarg (fails on unpatched main).

Fixes #93201
2026-08-23 17:54:20 -07:00
Kang Wang dd2b5172e4 fix: pass single_query_deny_message to approval gate for ssh config writes 2026-08-23 17:54:20 -07:00
Brooklyn Nicholson 165d1849e2 fix(approval): stop the CLI and ACP offering a scope the protected gate discards
The protected agent-instruction gate grants one operation and persists
nothing, but only the TUI/desktop and Runs transports were taught that.
The prompt_toolkit panel, the input() fallback, and the ACP editor menu
still rendered "Allow for session", so a user editing SOUL.md tapped it,
got re-prompted on the next write, and read the gate as broken.

Thread allow_session through prompt_dangerous_approval so a caller that
re-asks every time collapses every surface to once/deny, and cover the
producer-to-transport contract end to end.
2026-08-23 17:45:47 -05:00
kshitij 9f004c8217 refactor: hoist binary_extensions import to module level
Lazy import inside _check_binary_document_write was unnecessary —
binary_extensions is a leaf module already imported at line 15.
Hoisted has_opaque_document_extension and is_pdf_path to the existing
module-level import. /simplify-code finding.
2026-08-15 02:51:59 +05:30
Teknium 6d51c831eb fix(file_tools): refuse plain-text writes that corrupt binary documents
Port from nearai/ironclaw#7109: read_file auto-extracts .docx/.xlsx/.pptx
(and PDF via anydoc) to readable text, so a model plausibly believes it
holds the file's contents and writes the edited text back with
write_file/patch — silently destroying the document container. Proven
live on main: write_file over a valid .docx left a non-zip corpse, and a
text write over an existing .pdf clobbered the %PDF header.

- tools/binary_extensions.py: OPAQUE_DOCUMENT_EXTENSIONS +
  has_opaque_document_extension() + is_pdf_path() (pure string checks)
- tools/file_tools.py: _check_binary_document_write() — opaque container
  formats (doc/docx/xls/xlsx/ppt/pptx/odt/ods/odp) always rejected; .pdf
  rejected only when overwriting an existing regular file (new-PDF
  creation stays allowed, matching the upstream split guard). Wired into
  write_file_tool and patch_tool (replace + V4A Update/Add headers;
  Delete/Move skip the guard since they write no text).
- tests/tools/test_binary_document_write_guard.py: guard unit tests +
  end-to-end write_file/patch coverage incl. bytes-untouched assertions.
2026-08-15 02:51:59 +05:30
ethernet 16a173a8d6 fix(tools): do not adopt a stale cwd after an interrupted command
The command wrapper prints the cwd marker after the command returns. A
killed or timed-out command emits no marker, so ``env.cwd`` still holds the
directory of the last command to FINISH. One local environment serves every
session, because ``_resolve_container_task_id`` collapses cwd-only overrides
to ``"default"``. That leftover directory is therefore routinely another
session's.

The post-command dual-write copied ``env.cwd`` into the interrupted session's
durable record. Every later command in that session then ran in the foreign
directory, and the cwd echo told the model it had moved there. A desktop chat
silently re-homed into a worktree that another chat had opened.

Report the observation instead of inferring it. The marker parse now sets
``result["cwd_observed"]``, and both the record write and the echo read that
flag. The local override clears the flag when it rolls back a path that does
not exist, because the restored value is also unobserved. When a command
reports no cwd, the session keeps the directory it already had.

This needs no second session to be wrong: a lone session that interrupts a
command re-adopts a stale value too. A second session only makes the wrong
directory belong to somebody else.

The same class of write exists in the file-tools rescue for a reaped
environment (#26211). That rescue copied the cached snapshot of the shared
``env.cwd`` into the session record. The rescue is now fill-only: it writes
the snapshot when the session has no record, and it never overwrites a
record that the session wrote for itself.

The tests drive ``terminal_tool`` itself through an interrupt, not a copy of
its gate. Review found that a revert of either call site passed the first
version of the tests. Each gate now has a test that fails when the gate is
removed (verified by mutation).

Two exact-dict assertions in the Vercel sandbox tests now assert the two
fields they care about, so a new result key does not fail them.
2026-08-13 20:30:26 -07:00
Teknium 62a9c0f0e9 fix(file-safety): approval-gate ~/.ssh/config writes instead of hard-denying (#84663)
The write_file / patch file tools hard-denied ~/.ssh/config as a
"protected system/credential file", while the terminal tool only
*asked* for approval on ~/.ssh writes. That inconsistency meant a write
to ~/.ssh/config was refused via write_file but succeeded via terminal
after an approval prompt -- the same operation flip-flopping between
denied and OK depending on which tool ran it.

The SSH client config carries no private-key material, and editing it
(host aliases, ProxyJump, VS Code Remote-SSH targets) is a routine,
user-initiated task. It CAN carry ProxyCommand / Match exec directives
that run commands, so a free write is still inappropriate -- approval,
not a flat refusal, is the right policy, matching what the terminal tool
already does.

Changes:
- agent/file_safety.py: remove ~/.ssh/config from the flat credential
  deny; add build_write_approval_paths() + is_write_approval_required(),
  and short-circuit it out of the ~/.ssh/ prefix deny so the file is
  allowed at the classifier layer. Private keys, authorized_keys, and
  everything else under ~/.ssh/ stay hard-denied.
- tools/file_tools.py: _check_approval_required_write() routes ssh config
  writes through the shared _run_approval_gate (once/session/always,
  honors --yolo, fail-closed with no human), wired into write_file_tool
  and patch_tool right after the protected-instruction gate.
- Non-interactive consumers fail closed: the ACP file bridge
  (copilot_acp_client) rejects approval-required paths outright, and the
  TTS output-path picker refuses them as before.
- Docs + tests updated (security.md exception note;
  TestSshConfigApprovalGate covers config approval-gated, keys still
  hard-denied).
2026-08-12 11:04:45 -07:00
Elisa Martinez Abad 9c541de91a fix(tools): improve patch tool parameter description 2026-08-12 15:21:47 +05:30
teknium1 1362ffc7d2 feat(file-ops): name the binary type in read_file refusals (magic-byte sniff)
'Binary file - use appropriate tools' names a recovery the model may
not have — in a file-only toolset it thrashed for 41 turns / 178 tool
calls / 1.5M tokens on a PNG-behind-.txt (readtool eval, qwen3.8-max)
hunting for tools that did not exist. Name the type instead: 25 magic
signatures (images, archives, executables, media, SQLite), ftyp check
for ISO media, size in human units. 'Binary file (PNG image data,
4.1 KB) - cannot display as text.' answers what-is-this in one read.

Both ShellFileOperations refusal sites (read_file + read_file_raw) use
the shared describe_binary_file(); the extension-based guard keeps its
extension message (an extension is a claim; only sniffed content earns
a type name).
2026-08-10 12:07:50 -07:00
Teknium 0e63ed1feb feat(tools): stat-based special-file guard for read_file + readtool eval harness
read_file on a workspace FIFO/socket blocked until the exec timeout —
the existing device guard is name-based (/dev/*, /proc/*) and cannot
see an arbitrary special file. Add _special_file_kind(): one os.stat
on the resolved path, refusing FIFO/socket/char/block devices with a
plain note ('no read was attempted') instead of hanging. Host-visible
filesystems only; regular files, dirs, and missing paths unchanged.

Also adds evals/readtool/: an A/B harness that runs the real AIAgent
against hostile-file fixtures (huge lockfile, one-line bundle, FIFO,
NFD filenames, lying extensions) and measures accuracy, turns, tool
calls, and tokens. Measured for this guard (3 reps, file-only arm):
qwen3.8-max fifo task tokens 122k -> 26k (-79%), turns 9.3 -> 5.0;
opus-4.8 tokens 40k -> 23k; accuracy held 1.00 both arms.
2026-08-09 23:30:02 -07:00
Teknium e95e13783b fix(docker): per-session container isolation and session-scoped workspace mounts
Two bugs reported on the docker terminal backend (desktop app, sandboxed
profiles with container_persistent: false):

1. A NEW chat's container inherited the PREVIOUS session's workspace,
   bind-mounted rw at /workspace, because the mount source was the
   process-global TERMINAL_CWD env var (written by the workspace picker,
   outliving its session) and all sessions shared one 'default' container.

2. Every command failed with exit 126 because the desktop gateway recorded
   the HOST launch directory as the session cwd, and each command was
   prefixed with 'cd /Users/<user>/...' inside the container.

Fixes (class-wide, single owners):

- container_persistent: false + docker now keys containers PER SESSION:
  fresh container per chat, removed at session close/idle. delegate_task
  children share the parent's container via an explicit alias registry.
  container_persistent: true keeps the documented ONE-long-lived-container
  contract unchanged.
- _resolve_task_host_cwd() is the single owner of the cwd->/workspace mount
  policy across all four env-creation sites; under isolation it refuses
  process-global cwd sources and mounts only the session's own attached
  workspace (tui_gateway now tags overrides with cwd_source).
- _resolve_command_cwd() gains the same host-path guard the env-creation
  sites already had (#50636/#54447 sibling site): a recorded host cwd is
  discarded on container backends instead of cd-ing every command into a
  nonexistent path.

E2E-tested against real Docker: distinct containers per session, no stale
mount in a fresh session, no exit 126 from host cwd records, containers
removed at session teardown.
2026-08-09 14:35:02 -07:00
Teknium cbb8cee47d fix(read_file): surface document extraction failures instead of the generic binary-file error
When extraction of a binary document format (.pdf, .docx, .xlsx, Office,
EPUB…) fails for a specific reason — the anydoc size cap, an encrypted or
malformed file — read_file previously swallowed the ExtractionError at
debug level and fell through to the generic 'Cannot read binary file'
guard, so the agent never saw the actionable reason (e.g. 'Document too
large to convert (N bytes, limit is 52,428,800)').

read_file now returns the specific extraction failure for binary document
formats. Fallthrough behavior is preserved where a raw read is still
useful: .ipynb (plain JSON) and converter-unavailable PDFs keep their
historical raw-read path, and the 'Unsupported document type' shape (no
extra information) keeps the generic guard.

Follow-up to #80004, where the size-cap message was being generated but
never reached the agent.
2026-08-08 05:40:45 -07:00
fangliquanflq 8de3ddb9ef fix(tools): preserve document extraction boundaries 2026-08-08 05:13:46 -07:00
Teknium 89c14aeb9e fix(read_file): warn when PDF pages yield no text (scanned-image coverage gap)
anydoc converts the PDF text layer only and emits no image placeholders
or page markers, so a mostly-scanned PDF extracts 'successfully' into
section headers with empty bodies — silent data loss the model cannot
detect. Count per-page text via poppler pdftotext and prepend an
EXTRACTION COVERAGE WARNING naming the empty pages and the recovery
path (pdftoppm + vision_analyze, or the ocr-and-documents skill).

Found on a 311-page HOA resale package where 198 scanned pages
(CC&Rs, Bylaws, Articles, insurance certs) vanished without a trace.
2026-08-08 04:25:27 -07:00
Teknium fe66596df3 feat(security): protected agent-instruction files always require write approval
write_file/patch targeting AGENTS.md, CLAUDE.md, SOUL.md, .cursorrules, or a
project-local .hermes config dir now ALWAYS prompt the human for approval —
even under --yolo/auto-approve — and fail closed when no human channel
exists. These files steer future agent behavior, so an injected write to
them is a prompt-injection persistence vector.

Design:
- New _check_protected_instruction_write() in tools/file_tools.py, a
  sibling of _check_sensitive_path that returns approval-required rather
  than a hard error. It realpaths before matching (symlink lesson from
  #41351), matches basenames case-insensitively in ANY directory, rejects
  './x/../AGENTS.md' traversal via normpath, and gates files whose
  immediate parent dir is `.hermes` (project-local config) while exempting
  the authoritative ~/.hermes home (governed by its own guards).
- Approval is ONE-OPERATION only: no session/permanent persistence, no
  yolo bypass — intentionally does not route through _run_approval_gate.
  Gateway sessions get the button round-trip with allow_permanent and
  allow_session both False; CLI uses the per-thread approval callback;
  no channel at all = BLOCKED (fail closed).
- Multi-file V4A patches: ONE protected file gates the ENTIRE patch (a
  single prompt lists all protected targets; deny applies nothing).
- Config: security.protected_instruction_files (default true) and
  security.protected_instruction_extra_patterns (fnmatch on basename).
  Config read failure keeps the gate ON.

Tests: 22 new cases in tests/tools/test_file_write_safety.py covering the
adversarial checklist — deny/approve/yolo-bypass attempt, symlink at a
protected target, case variants, relative traversal, arbitrary-directory
basenames, project-local .hermes, checkout-nested-under-~/.hermes
non-gating, patch replace + V4A multi-file atomicity, gateway round-trip,
fail-closed with no human, config off/extra patterns.

Ported from: RooCodeInc/Roo-Code RooProtectedController (Apache-2.0);
companion: #58631 (terminal vector), symlink lesson from #41351.
2026-08-07 08:58:38 -07:00
Adolanium ff3793fdff fix(read_file): stop promising anydoc conversion in the tool schema
The read_file description added in #79781 states that PDF, legacy
Office, OpenDocument, RTF, and EPUB convert via the optional anydoc
converter, unconditionally. Conversion actually depends on the lazy
install succeeding, security.allow_lazy_installs, and the file being
readable from the Hermes host, so the schema overpromises and the model
learns to expect conversion in environments that can never provide it.

The description now says these formats convert when the optional anydoc
converter is available, and that the auto-install applies where
installs are permitted.
2026-08-05 22:03:47 -07:00
Teknium b2598b41e1 feat(read_file): widen document extraction to PDF/legacy Office/ODF/RTF/EPUB via optional anydoc
read_file's auto-extraction covered only the stdlib trio (.ipynb/.docx/
.xlsx). firecrawl-anydoc (MIT, Rust core, imports as `anydoc`) converts
Word, PowerPoint, Excel — including legacy .doc/.ppt/.xls — OpenDocument,
RTF, EPUB, and PDF to clean Markdown through one shared document model.

Wiring follows the footprint ladder: no new tool, no hard dependency.
- tools/read_extract.py gains an ANYDOC_EXTENSIONS set that is active
  only when the converter imports; the stdlib extractors remain
  authoritative for their three formats so behavior is identical with
  or without the package.
- tools/lazy_deps.py adds tool.doc_extract (firecrawl-anydoc==0.1.6),
  installed on first read of such a file with prompt=False so read_file
  can never block. Lazy-only for now: the package's first release was
  2026-08-04, inside uv's 14-day exclude-newer quarantine, so the
  mirrored pyproject extra lands after it clears.
- Any anydoc ConvertError maps to ExtractionError, falling back to the
  existing path/binary handling instead of erroring the tool.

Tests: real-binding suite skips cleanly when the wheel is absent
(verified: 15 passed/3 skipped without it, 18 passed with it), plus an
absent-dep contract class that pins the fallback regardless of local
install state.
2026-08-05 17:07:47 -07:00
Teknium 2c8a932f80 feat(file): verify write_file content on disk and say so (verified: true)
write_file confirmed only SIZE (wc -c) after writing — never content.
Models compensated by re-reading files immediately after writing them
(154 verify-reads in a 400k-msg production window), and a corrupted
write (truncated pipe, backend FS oddity) could silently pass.

The write path now compares the on-disk sha256 against the intended
content (one shell call). Three outcomes:
- match -> result carries verified: true; the schema tells the model
  an explicit contract: do NOT re-read to check the write landed.
- mismatch -> hard error ('The write did not persist correctly'),
  mirroring patch_replace's existing post-write verification.
- backend can't hash (no sha256sum) -> flag omitted, write unaffected.

Hashes the shim-adjusted content (after CRLF/BOM preservation) so
Windows-line-ending and BOM round-trips verify correctly; surrogatepass
encoding matches the rest of the codebase's hashing of model text.
2026-08-02 15:12:23 -07:00
Teknium af27e60603 feat(file): raise read_file default limit from 500 to 2000 lines
Production mining (state.db, 28.5k read_file calls in the recent
window) shows 74.3% of reads truncated — nearly all by the 500-line
default, not the char budget (22,443 line-limit vs 13 char-budget
truncations). That churn produced 12,229 redundant re-reads, and after
a truncated result the most common next move was fleeing to terminal
cat/sed (4,608 times) — the pagination contract was not trusted.

Median truncated file is 2,422 total lines, so a 2000-line default
makes 44% of today's truncated first-reads complete in one call while
the unchanged ~100K-char budget still caps worst-case result size
(same ceiling as before: 500 lines x 2000-char line cap = the same
100K). Schema max was already 2000.

Touchpoints: DEFAULT_READ_LIMIT + both read_file signatures
(file_operations.py), read_file_tool + schema text (file_tools.py),
execute_code sandbox stub docs (code_execution_tool.py), 3 tests
pinning the old default.
2026-08-02 15:09:33 -07:00