2026-02-28 21:56:05 -08:00
|
|
|
"""Regex-based secret redaction for logs and tool output.
|
|
|
|
|
|
|
|
|
|
Applies pattern matching to mask API keys, tokens, and credentials
|
|
|
|
|
before they reach log files, verbose output, or gateway logs.
|
|
|
|
|
|
|
|
|
|
Short tokens (< 18 chars) are fully masked. Longer tokens preserve
|
|
|
|
|
the first 6 and last 4 characters for debuggability.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
2026-03-09 01:04:33 -07:00
|
|
|
import os
|
2026-02-28 21:56:05 -08:00
|
|
|
import re
|
2026-06-28 02:44:21 -07:00
|
|
|
import shlex
|
2026-02-28 21:56:05 -08:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
feat: replace kimi-k2.5 with kimi-k2.6 on OpenRouter and Nous Portal (#13148)
* feat(security): URL query param + userinfo + form body redaction
Port from nearai/ironclaw#2529.
Hermes already has broad value-shape coverage in agent/redact.py
(30+ vendor prefixes, JWTs, DB connstrs, etc.) but missed three
key-name-based patterns that catch opaque tokens without recognizable
prefixes:
1. URL query params - OAuth callback codes (?code=...),
access_token, refresh_token, signature, etc. These are opaque and
won't match any prefix regex. Now redacted by parameter NAME.
2. URL userinfo (https://user:pass@host) - for non-DB schemes. DB
schemes were already handled by _DB_CONNSTR_RE.
3. Form-urlencoded body (k=v pairs joined by ampersands) -
conservative, only triggers on clean pure-form inputs with no
other text.
Sensitive key allowlist matches ironclaw's (exact case-insensitive,
NOT substring - so token_count and session_id pass through).
Tests: +20 new test cases across 3 test classes. All 75 redact tests
pass; gateway/test_pii_redaction and tools/test_browser_secret_exfil
also green.
Known pre-existing limitation: _ENV_ASSIGN_RE greedy match swallows
whole all-caps ENV-style names + trailing text when followed by
another assignment. Left untouched here (out of scope); URL query
redaction handles the lowercase case.
* feat: replace kimi-k2.5 with kimi-k2.6 on OpenRouter and Nous Portal
Update model catalogs for OpenRouter (fallback snapshot), Nous Portal,
and NVIDIA NIM to reference moonshotai/kimi-k2.6. Add kimi-k2.6 to
the fixed-temperature frozenset in auxiliary_client.py so the 0.6
contract is enforced on aggregator routings.
Native Moonshot provider lists (kimi-coding, kimi-coding-cn, moonshot,
opencode-zen, opencode-go) are unchanged — those use Moonshot's own
model IDs which are unaffected.
2026-04-20 11:49:54 -07:00
|
|
|
# Sensitive query-string parameter names (case-insensitive exact match).
|
|
|
|
|
# Ported from nearai/ironclaw#2529 — catches tokens whose values don't match
|
|
|
|
|
# any known vendor prefix regex (e.g. opaque tokens, short OAuth codes).
|
|
|
|
|
_SENSITIVE_QUERY_PARAMS = frozenset({
|
|
|
|
|
"access_token",
|
|
|
|
|
"refresh_token",
|
|
|
|
|
"id_token",
|
|
|
|
|
"token",
|
|
|
|
|
"api_key",
|
|
|
|
|
"apikey",
|
|
|
|
|
"client_secret",
|
|
|
|
|
"password",
|
|
|
|
|
"auth",
|
|
|
|
|
"jwt",
|
|
|
|
|
"session",
|
|
|
|
|
"secret",
|
|
|
|
|
"key",
|
|
|
|
|
"code", # OAuth authorization codes
|
|
|
|
|
"signature", # pre-signed URL signatures
|
|
|
|
|
"x-amz-signature",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Sensitive form-urlencoded / JSON body key names (case-insensitive exact match).
|
|
|
|
|
# Exact match, NOT substring — "token_count" and "session_id" must NOT match.
|
|
|
|
|
# Ported from nearai/ironclaw#2529.
|
|
|
|
|
_SENSITIVE_BODY_KEYS = frozenset({
|
|
|
|
|
"access_token",
|
|
|
|
|
"refresh_token",
|
|
|
|
|
"id_token",
|
|
|
|
|
"token",
|
|
|
|
|
"api_key",
|
|
|
|
|
"apikey",
|
|
|
|
|
"client_secret",
|
|
|
|
|
"password",
|
|
|
|
|
"auth",
|
|
|
|
|
"jwt",
|
|
|
|
|
"secret",
|
|
|
|
|
"private_key",
|
|
|
|
|
"authorization",
|
|
|
|
|
"key",
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-30 17:54:55 +03:00
|
|
|
# Snapshot at import time so runtime env mutations (e.g. LLM-generated
|
2026-05-07 05:10:33 -07:00
|
|
|
# `export HERMES_REDACT_SECRETS=false`) cannot disable redaction
|
|
|
|
|
# mid-session. ON by default — secure default per issue #17691. Users who
|
|
|
|
|
# need raw credential values in tool output (e.g. working on the redactor
|
|
|
|
|
# itself) can opt out via `security.redact_secrets: false` in config.yaml
|
|
|
|
|
# (bridged to this env var in hermes_cli/main.py, gateway/run.py, and
|
|
|
|
|
# cli.py) or `HERMES_REDACT_SECRETS=false` in ~/.hermes/.env. An opt-out
|
|
|
|
|
# warning is logged at gateway and CLI startup so operators see the
|
|
|
|
|
# downgrade — see `_log_redaction_status()` in gateway/run.py and cli.py.
|
2026-05-11 11:13:25 -07:00
|
|
|
_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "true").lower() in {"1", "true", "yes", "on"}
|
2026-03-30 17:54:55 +03:00
|
|
|
|
2026-02-28 21:56:05 -08:00
|
|
|
# Known API key prefixes -- match the prefix + contiguous token chars
|
|
|
|
|
_PREFIX_PATTERNS = [
|
2026-03-09 01:28:27 -07:00
|
|
|
r"sk-[A-Za-z0-9_-]{10,}", # OpenAI / OpenRouter / Anthropic (sk-ant-*)
|
2026-02-28 21:56:05 -08:00
|
|
|
r"ghp_[A-Za-z0-9]{10,}", # GitHub PAT (classic)
|
|
|
|
|
r"github_pat_[A-Za-z0-9_]{10,}", # GitHub PAT (fine-grained)
|
2026-03-30 17:54:55 +03:00
|
|
|
r"gho_[A-Za-z0-9]{10,}", # GitHub OAuth access token
|
|
|
|
|
r"ghu_[A-Za-z0-9]{10,}", # GitHub user-to-server token
|
|
|
|
|
r"ghs_[A-Za-z0-9]{10,}", # GitHub server-to-server token
|
|
|
|
|
r"ghr_[A-Za-z0-9]{10,}", # GitHub refresh token
|
2026-02-28 21:56:05 -08:00
|
|
|
r"xox[baprs]-[A-Za-z0-9-]{10,}", # Slack tokens
|
|
|
|
|
r"AIza[A-Za-z0-9_-]{30,}", # Google API keys
|
|
|
|
|
r"pplx-[A-Za-z0-9]{10,}", # Perplexity
|
|
|
|
|
r"fal_[A-Za-z0-9_-]{10,}", # Fal.ai
|
|
|
|
|
r"fc-[A-Za-z0-9]{10,}", # Firecrawl
|
|
|
|
|
r"bb_live_[A-Za-z0-9_-]{10,}", # BrowserBase
|
|
|
|
|
r"gAAAA[A-Za-z0-9_=-]{20,}", # Codex encrypted tokens
|
2026-03-09 01:28:27 -07:00
|
|
|
r"AKIA[A-Z0-9]{16}", # AWS Access Key ID
|
|
|
|
|
r"sk_live_[A-Za-z0-9]{10,}", # Stripe secret key (live)
|
|
|
|
|
r"sk_test_[A-Za-z0-9]{10,}", # Stripe secret key (test)
|
|
|
|
|
r"rk_live_[A-Za-z0-9]{10,}", # Stripe restricted key
|
|
|
|
|
r"SG\.[A-Za-z0-9_-]{10,}", # SendGrid API key
|
|
|
|
|
r"hf_[A-Za-z0-9]{10,}", # HuggingFace token
|
|
|
|
|
r"r8_[A-Za-z0-9]{10,}", # Replicate API token
|
|
|
|
|
r"npm_[A-Za-z0-9]{10,}", # npm access token
|
|
|
|
|
r"pypi-[A-Za-z0-9_-]{10,}", # PyPI API token
|
|
|
|
|
r"dop_v1_[A-Za-z0-9]{10,}", # DigitalOcean PAT
|
|
|
|
|
r"doo_v1_[A-Za-z0-9]{10,}", # DigitalOcean OAuth
|
|
|
|
|
r"am_[A-Za-z0-9_-]{10,}", # AgentMail API key
|
2026-03-30 08:13:01 -07:00
|
|
|
r"sk_[A-Za-z0-9_]{10,}", # ElevenLabs TTS key (sk_ underscore, not sk- dash)
|
|
|
|
|
r"tvly-[A-Za-z0-9]{10,}", # Tavily search API key
|
|
|
|
|
r"exa_[A-Za-z0-9]{10,}", # Exa search API key
|
fix: mem0 API v2 compat, prefetch context fencing, secret redaction (#5423)
Consolidated salvage from PRs #5301 (qaqcvc), #5339 (lance0),
#5058 and #5098 (maymuneth).
Mem0 API v2 compatibility (#5301):
- All reads use filters={user_id: ...} instead of bare user_id= kwarg
- All writes use filters with user_id + agent_id for attribution
- Response unwrapping for v2 dict format {results: [...]}
- Split _read_filters() vs _write_filters() — reads are user-scoped
only for cross-session recall, writes include agent_id
- Preserved 'hermes-user' default (no breaking change for existing users)
- Omitted run_id scoping from #5301 — cross-session memory is Mem0's
core value, session-scoping reads would defeat that purpose
Memory prefetch context fencing (#5339):
- Wraps prefetched memory in <memory-context> fenced blocks with system
note marking content as recalled context, NOT user input
- Sanitizes provider output to strip fence-escape sequences, preventing
injection where memory content breaks out of the fence
- API-call-time only — never persisted to session history
Secret redaction (#5058, #5098):
- Added prefix patterns for Groq (gsk_), Matrix (syt_), RetainDB
(retaindb_), Hindsight (hsk-), Mem0 (mem0_), ByteRover (brv_)
2026-04-05 22:43:33 -07:00
|
|
|
r"gsk_[A-Za-z0-9]{10,}", # Groq Cloud API key
|
|
|
|
|
r"syt_[A-Za-z0-9]{10,}", # Matrix access token
|
|
|
|
|
r"retaindb_[A-Za-z0-9]{10,}", # RetainDB API key
|
|
|
|
|
r"hsk-[A-Za-z0-9]{10,}", # Hindsight API key
|
|
|
|
|
r"mem0_[A-Za-z0-9]{10,}", # Mem0 Platform API key
|
|
|
|
|
r"brv_[A-Za-z0-9]{10,}", # ByteRover API key
|
2026-05-17 09:14:27 +01:00
|
|
|
r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) API key
|
2026-06-15 20:39:55 +09:00
|
|
|
r"ntn_[A-Za-z0-9]{10,}", # Notion internal integration token
|
2026-05-17 12:14:32 +01:00
|
|
|
r"fw_[A-Za-z0-9]{30,}", # Fireworks AI API key
|
2026-02-28 21:56:05 -08:00
|
|
|
]
|
|
|
|
|
|
2026-06-27 04:43:28 -07:00
|
|
|
# ENV assignment patterns: KEY=value where KEY contains a secret-like name.
|
|
|
|
|
# Uppercase keys tolerate spaces around "=" (e.g. ``FOO_SECRET = bar``) because
|
|
|
|
|
# an all-caps key is almost never prose/code.
|
2026-02-28 21:56:05 -08:00
|
|
|
_SECRET_ENV_NAMES = r"(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)"
|
|
|
|
|
_ENV_ASSIGN_RE = re.compile(
|
fix: remove stale test skips, fix regex backtracking, file search bug, and test flakiness
Bug fixes:
- agent/redact.py: catastrophic regex backtracking in _ENV_ASSIGN_RE — removed
re.IGNORECASE and changed [A-Z_]* to [A-Z0-9_]* to restrict matching to actual
env var name chars. Without this, the pattern backtracks exponentially on large
strings (e.g. 100K tool output), causing test_file_read_guards to time out.
- tools/file_operations.py: over-escaped newline in find -printf format string
produced literal backslash-n instead of a real newline, breaking file search
result parsing (total_count always 1, paths concatenated).
Test fixes:
- Remove stale pytestmark.skip from 4 test modules that were blanket-skipped as
'Hangs in non-interactive environments' but actually run fine:
- test_413_compression.py (12 tests, 25s)
- test_file_tools_live.py (71 tests, 24s)
- test_code_execution.py (61 tests, 99s)
- test_agent_loop_tool_calling.py (has proper OPENROUTER_API_KEY skip already)
- test_413_compression.py: fix threshold values in 2 preflight compression tests
where context_length was too small for the compressed output to fit in one pass.
- test_mcp_probe.py: add missing _MCP_AVAILABLE mock so tests work without MCP SDK.
- test_mcp_tool_issue_948.py: inject MCP symbols (StdioServerParameters etc.) when
SDK is not installed so patch() targets exist.
- test_approve_deny_commands.py: replace time.sleep(0.3) with deterministic polling
of _gateway_queues — fixes race condition where resolve fires before threads
register their approval entries, causing the test to hang indefinitely.
Net effect: +256 tests recovered from skip, 8 real failures fixed.
2026-04-03 13:35:17 +02:00
|
|
|
rf"([A-Z0-9_]{{0,50}}{_SECRET_ENV_NAMES}[A-Z0-9_]{{0,50}})\s*=\s*(['\"]?)(\S+)\2",
|
2026-02-28 21:56:05 -08:00
|
|
|
)
|
|
|
|
|
|
2026-06-27 04:43:28 -07:00
|
|
|
# Lowercase / dotted / hyphenated config keys from config files
|
|
|
|
|
# (application.properties, .env, YAML-ish dumps): ``spring.datasource.password=secret``,
|
|
|
|
|
# ``app.api.key=xyz``, ``password=secret``. The uppercase _ENV_ASSIGN_RE above
|
|
|
|
|
# never matched these, so config-file passwords leaked verbatim (issue #16413).
|
|
|
|
|
#
|
|
|
|
|
# These run only in a config-file context, NOT in prose, code, or URLs — three
|
|
|
|
|
# carve-outs preserved from the original design (#4367 + the documented
|
|
|
|
|
# web-URL passthrough below):
|
|
|
|
|
# 1. The value is bounded by ``[^\s&]`` (stops at whitespace AND ``&``) so
|
|
|
|
|
# form-urlencoded bodies are handled pair-by-pair (by _redact_form_body),
|
|
|
|
|
# not greedily swallowed.
|
|
|
|
|
# 2. _CFG_DOTTED_RE only matches when the key is NAMESPACED (contains a dot),
|
|
|
|
|
# which is unambiguously a config key — never a prose word.
|
|
|
|
|
# 3. _CFG_ANCHORED_RE matches a bare secret-word key only at line start
|
|
|
|
|
# (optionally after ``export``), so conversational ``I have password=foo``
|
|
|
|
|
# mid-sentence is left alone.
|
|
|
|
|
# The colon-form URL guard (skip when ``://`` present) lives at the call site.
|
|
|
|
|
_SECRET_CFG_NAMES = r"(?:api[ _.\-]?key|token|secret|passwd|password|credential|auth)"
|
|
|
|
|
_CFG_VALUE = r"(['\"]?)([^\s&]+?)\2(?=[\s&]|$)"
|
|
|
|
|
# Namespaced (dotted) key: the secret word may sit anywhere in a dotted path.
|
|
|
|
|
_CFG_DOTTED_RE = re.compile(
|
|
|
|
|
rf"((?:[A-Za-z0-9_\-]+\.)+[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*"
|
|
|
|
|
rf"|[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*\.[A-Za-z0-9_.\-]+)"
|
|
|
|
|
rf"={_CFG_VALUE}",
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
# Line-anchored bare key: ``password=…`` / ``export api_key=…`` at start of line.
|
|
|
|
|
_CFG_ANCHORED_RE = re.compile(
|
|
|
|
|
rf"(^[ \t]*(?:export[ \t]+)?[A-Za-z0-9_\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_\-]*)={_CFG_VALUE}",
|
|
|
|
|
re.IGNORECASE | re.MULTILINE,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Unquoted YAML / colon config (e.g. ``password: secret``,
|
|
|
|
|
# ``spring.datasource.password: hunter2``). The secret keyword must be part of
|
|
|
|
|
# the KEY (anchored to the start of the line/indent), and the value is a single
|
|
|
|
|
# whitespace-free token — so prose like ``note: secret meeting`` (keyword in the
|
|
|
|
|
# value) and ``error: token expired`` are left alone. Bare ``auth`` is excluded
|
|
|
|
|
# from the key set so ``Authorization:`` / ``author:`` don't match (the former
|
|
|
|
|
# is masked by _AUTH_HEADER_RE); ``auth_token``/``auth-token`` still match via
|
|
|
|
|
# the ``token`` keyword. Quoted values defer to _JSON_FIELD_RE via the lookahead.
|
|
|
|
|
_YAML_CFG_NAMES = r"(?:api[ _.\-]?key|token|secret|passwd|password|credential)"
|
|
|
|
|
_YAML_ASSIGN_RE = re.compile(
|
|
|
|
|
rf"(^[ \t]*[A-Za-z0-9_.\-]*{_YAML_CFG_NAMES}[A-Za-z0-9_.\-]*)(:[ \t]*)(?!['\"])([^\s&]+)",
|
|
|
|
|
re.IGNORECASE | re.MULTILINE,
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-28 21:56:05 -08:00
|
|
|
# JSON field patterns: "apiKey": "value", "token": "value", etc.
|
2026-03-13 03:14:04 -07:00
|
|
|
_JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)"
|
2026-02-28 21:56:05 -08:00
|
|
|
_JSON_FIELD_RE = re.compile(
|
|
|
|
|
rf'("{_JSON_KEY_NAMES}")\s*:\s*"([^"]+)"',
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-21 23:20:01 +03:00
|
|
|
# Authorization headers — any scheme (Bearer, Basic, Token, Digest, …) plus the
|
|
|
|
|
# bare-credential form, and Proxy-Authorization. The credential token is masked
|
|
|
|
|
# while the header name and scheme word are preserved for debuggability. The
|
|
|
|
|
# previous rule only matched ``Bearer``, so ``Basic <base64 user:pass>`` and
|
|
|
|
|
# ``token <pat>`` leaked verbatim into logs/transcripts.
|
2026-06-28 02:25:05 -07:00
|
|
|
#
|
|
|
|
|
# The credential class excludes quote characters (``"`` / ``'``): a token sitting
|
|
|
|
|
# flush against a closing quote (``"Authorization: Bearer sk-..."``) must not pull
|
|
|
|
|
# that quote into the match, or masking turns value corruption into *syntax*
|
|
|
|
|
# corruption — the closing quote vanishes and the command/string no longer parses
|
|
|
|
|
# (unterminated quote → shell EOF / Python SyntaxError). Real credentials never
|
|
|
|
|
# contain ``"`` or ``'``, so excluding them is safe. See #43083.
|
2026-02-28 21:56:05 -08:00
|
|
|
_AUTH_HEADER_RE = re.compile(
|
2026-06-28 02:25:05 -07:00
|
|
|
r"((?:Proxy-)?Authorization:\s*)([A-Za-z][\w.+-]*\s+)?([^\s\"']+)",
|
2026-06-21 23:20:01 +03:00
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# API-key style auth headers carrying a single opaque value (no scheme word).
|
|
|
|
|
# Anthropic and many providers authenticate with ``x-api-key``; values without
|
|
|
|
|
# a known vendor prefix (custom/local backends) would otherwise leak when a
|
|
|
|
|
# request or curl command is logged or echoed into tool output / transcripts.
|
|
|
|
|
_SECRET_HEADER_NAMES = (
|
|
|
|
|
r"(?:x-api-key|x-goog-api-key|api-key|apikey|x-api-token|x-auth-token|x-access-token)"
|
|
|
|
|
)
|
|
|
|
|
_SECRET_HEADER_RE = re.compile(
|
|
|
|
|
rf"({_SECRET_HEADER_NAMES}\s*:\s*)(\S+)",
|
2026-02-28 21:56:05 -08:00
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-08 22:33:06 +02:00
|
|
|
# Telegram bot tokens: bot<digits>:<token> or <digits>:<token>,
|
|
|
|
|
# where token part is restricted to [-A-Za-z0-9_] and length >= 30
|
2026-02-28 21:56:05 -08:00
|
|
|
_TELEGRAM_RE = re.compile(
|
|
|
|
|
r"(bot)?(\d{8,}):([-A-Za-z0-9_]{30,})",
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-09 01:28:27 -07:00
|
|
|
# Private key blocks: -----BEGIN RSA PRIVATE KEY----- ... -----END RSA PRIVATE KEY-----
|
|
|
|
|
_PRIVATE_KEY_RE = re.compile(
|
|
|
|
|
r"-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Database connection strings: protocol://user:PASSWORD@host
|
fix(redact): stop DB-connstr redaction from corrupting code output (#33801) (#54061)
Secret redaction is display/output-scoped on main — write_file writes
content verbatim, terminal/execute_code redact only output not the
command/source. The real bug is in displayed tool OUTPUT (read_file,
terminal, execute_code):
_DB_CONNSTR_RE's password group [^@]+ was greedy across newlines, so on a
multi-line block it scanned past the DSN line to the next stray '@' (a
Python @decorator), replacing every intervening character — including line
breaks — with ***. That dropped lines and concatenated the next line onto
the f-string line, making read_file output look corrupted (the file on disk
was always correct). Reported in #33801.
Fix:
- Forbid whitespace in the userinfo/password groups ([^:\s]+ / [^@\s]+) so
the match can never span a line break. A real DSN password never contains
whitespace. This alone kills the catastrophic line-dropping.
- Under code_file=True, preserve a password group that is a pure {...} brace
expression — f"postgresql://{user}:{pass}@{host}" is an f-string template,
not a live credential. Literal passwords are still masked.
- Pass code_file=True at the terminal and execute_code output redaction call
sites (file_tools already did) so code-execution output isn't corrupted by
ENV/JSON/template false positives. Real prefixes, auth headers, JWTs, and
private keys are still redacted.
Verified E2E against the reporter's exact pydantic-settings module: file
written verbatim, read_file shows the DSN f-string + @model_validator intact
with zero *** corruption, while a literal postgresql://admin:pw@host DSN and
a real sk- key are still masked.
Reported-by: koishi70
Reported-by: pfrenssen
2026-06-28 01:15:39 -07:00
|
|
|
# Catches postgres, mysql, mongodb, redis, amqp URLs and redacts the password.
|
|
|
|
|
# The userinfo and password groups forbid whitespace ([^:\s]+ / [^@\s]+) so the
|
|
|
|
|
# match can never span a line break. A real DSN password never contains
|
|
|
|
|
# whitespace; without this bound the greedy [^@]+ would scan past the end of a
|
|
|
|
|
# code line to the next stray "@" (e.g. a Python decorator), swallowing
|
|
|
|
|
# intervening lines and corrupting tool OUTPUT for any source containing a
|
|
|
|
|
# postgresql:// f-string template. See issue #33801.
|
2026-03-09 01:28:27 -07:00
|
|
|
_DB_CONNSTR_RE = re.compile(
|
fix(redact): stop DB-connstr redaction from corrupting code output (#33801) (#54061)
Secret redaction is display/output-scoped on main — write_file writes
content verbatim, terminal/execute_code redact only output not the
command/source. The real bug is in displayed tool OUTPUT (read_file,
terminal, execute_code):
_DB_CONNSTR_RE's password group [^@]+ was greedy across newlines, so on a
multi-line block it scanned past the DSN line to the next stray '@' (a
Python @decorator), replacing every intervening character — including line
breaks — with ***. That dropped lines and concatenated the next line onto
the f-string line, making read_file output look corrupted (the file on disk
was always correct). Reported in #33801.
Fix:
- Forbid whitespace in the userinfo/password groups ([^:\s]+ / [^@\s]+) so
the match can never span a line break. A real DSN password never contains
whitespace. This alone kills the catastrophic line-dropping.
- Under code_file=True, preserve a password group that is a pure {...} brace
expression — f"postgresql://{user}:{pass}@{host}" is an f-string template,
not a live credential. Literal passwords are still masked.
- Pass code_file=True at the terminal and execute_code output redaction call
sites (file_tools already did) so code-execution output isn't corrupted by
ENV/JSON/template false positives. Real prefixes, auth headers, JWTs, and
private keys are still redacted.
Verified E2E against the reporter's exact pydantic-settings module: file
written verbatim, read_file shows the DSN f-string + @model_validator intact
with zero *** corruption, while a literal postgresql://admin:pw@host DSN and
a real sk- key are still masked.
Reported-by: koishi70
Reported-by: pfrenssen
2026-06-28 01:15:39 -07:00
|
|
|
r"((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^:\s]+:)([^@\s]+)(@)",
|
2026-03-09 01:28:27 -07:00
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-28 18:52:42 -07:00
|
|
|
# Bare-token credential in a web/transport URL: ``scheme://TOKEN@host``.
|
|
|
|
|
# This is the ``git remote set-url origin https://PASSWORD@github.com/...``
|
|
|
|
|
# shape from issue #6396 — a single opaque credential in the userinfo position
|
|
|
|
|
# with NO ``user:pass`` colon. It is unambiguously a secret: legitimate
|
|
|
|
|
# round-trip URLs (OAuth callbacks, magic links, pre-signed shares — see the
|
|
|
|
|
# "Web-URL redaction is intentionally OFF" note in redact_sensitive_text) carry
|
|
|
|
|
# their tokens in the QUERY STRING, never in bare userinfo. The colon form
|
|
|
|
|
# ``user:pass@`` is deliberately left to pass through (commit "pass web URLs
|
|
|
|
|
# through unchanged", #34029) and is NOT matched here — the token class forbids
|
|
|
|
|
# ``:``. DB schemes are handled by _DB_CONNSTR_RE above and excluded here.
|
|
|
|
|
#
|
|
|
|
|
# Guards against false positives:
|
|
|
|
|
# - 8+ char floor skips short usernames (git, admin, root, deploy, ubuntu).
|
|
|
|
|
# - The token class ``[^\s:@/]`` cannot cross ``/``, so an ``@`` sitting in a
|
|
|
|
|
# path or query (e.g. ``?q=user@example.com``) is never treated as userinfo.
|
|
|
|
|
_URL_BARE_TOKEN_RE = re.compile(
|
|
|
|
|
r"((?:https?|wss?|git|ssh|ftp|ftps|sftp)://)" # scheme
|
|
|
|
|
r"([^\s:@/]{8,})" # bare token (no colon/slash/@), 8+ chars
|
|
|
|
|
r"(@[^\s]+)", # @host...
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-15 16:08:52 -07:00
|
|
|
# JWT tokens: header.payload[.signature] — always start with "eyJ" (base64 for "{")
|
|
|
|
|
# Matches 1-part (header only), 2-part (header.payload), and full 3-part JWTs.
|
|
|
|
|
_JWT_RE = re.compile(
|
|
|
|
|
r"eyJ[A-Za-z0-9_-]{10,}" # Header (always starts with eyJ)
|
|
|
|
|
r"(?:\.[A-Za-z0-9_=-]{4,}){0,2}" # Optional payload and/or signature
|
|
|
|
|
)
|
|
|
|
|
|
feat: add Signal messenger gateway platform (#405)
Complete Signal adapter using signal-cli daemon HTTP API.
Based on PR #268 by ibhagwan, rebuilt on current main with bug fixes.
Architecture:
- SSE streaming for inbound messages with exponential backoff (2s→60s)
- JSON-RPC 2.0 for outbound (send, typing, attachments, contacts)
- Health monitor detects stale SSE connections (120s threshold)
- Phone number redaction in all logs and global redact.py
Features:
- DM and group message support with separate access policies
- DM policies: pairing (default), allowlist, open
- Group policies: disabled (default), allowlist, open
- Attachment download with magic-byte type detection
- Typing indicators (8s refresh interval)
- 100MB attachment size limit, 8000 char message limit
- E.164 phone + UUID allowlist support
Integration:
- Platform.SIGNAL enum in gateway/config.py
- Signal in _is_user_authorized() allowlist maps (gateway/run.py)
- Adapter factory in _create_adapter() (gateway/run.py)
- user_id_alt/chat_id_alt fields in SessionSource for UUIDs
- send_message tool support via httpx JSON-RPC (not aiohttp)
- Interactive setup wizard in 'hermes gateway setup'
- Connectivity testing during setup (pings /api/v1/check)
- signal-cli detection and install guidance
Bug fixes from PR #268:
- Timestamp reads from envelope_data (not outer wrapper)
- Uses httpx consistently (not aiohttp in send_message tool)
- SIGNAL_DEBUG scoped to signal logger (not root)
- extract_images regex NOT modified (preserves group numbering)
- pairing.py NOT modified (no cross-platform side effects)
- No dual authorization (adapter defers to run.py for user auth)
- Wildcard uses set membership ('*' in set, not list equality)
- .zip default for PK magic bytes (not .docx)
No new Python dependencies — uses httpx (already core).
External requirement: signal-cli daemon (user-installed).
Tests: 30 new tests covering config, init, helpers, session source,
phone redaction, authorization, and send_message integration.
Co-authored-by: ibhagwan <ibhagwan@users.noreply.github.com>
2026-03-08 20:20:35 -07:00
|
|
|
# E.164 phone numbers: +<country><number>, 7-15 digits
|
|
|
|
|
# Negative lookahead prevents matching hex strings or identifiers
|
|
|
|
|
_SIGNAL_PHONE_RE = re.compile(r"(\+[1-9]\d{6,14})(?![A-Za-z0-9])")
|
|
|
|
|
|
feat: replace kimi-k2.5 with kimi-k2.6 on OpenRouter and Nous Portal (#13148)
* feat(security): URL query param + userinfo + form body redaction
Port from nearai/ironclaw#2529.
Hermes already has broad value-shape coverage in agent/redact.py
(30+ vendor prefixes, JWTs, DB connstrs, etc.) but missed three
key-name-based patterns that catch opaque tokens without recognizable
prefixes:
1. URL query params - OAuth callback codes (?code=...),
access_token, refresh_token, signature, etc. These are opaque and
won't match any prefix regex. Now redacted by parameter NAME.
2. URL userinfo (https://user:pass@host) - for non-DB schemes. DB
schemes were already handled by _DB_CONNSTR_RE.
3. Form-urlencoded body (k=v pairs joined by ampersands) -
conservative, only triggers on clean pure-form inputs with no
other text.
Sensitive key allowlist matches ironclaw's (exact case-insensitive,
NOT substring - so token_count and session_id pass through).
Tests: +20 new test cases across 3 test classes. All 75 redact tests
pass; gateway/test_pii_redaction and tools/test_browser_secret_exfil
also green.
Known pre-existing limitation: _ENV_ASSIGN_RE greedy match swallows
whole all-caps ENV-style names + trailing text when followed by
another assignment. Left untouched here (out of scope); URL query
redaction handles the lowercase case.
* feat: replace kimi-k2.5 with kimi-k2.6 on OpenRouter and Nous Portal
Update model catalogs for OpenRouter (fallback snapshot), Nous Portal,
and NVIDIA NIM to reference moonshotai/kimi-k2.6. Add kimi-k2.6 to
the fixed-temperature frozenset in auxiliary_client.py so the 0.6
contract is enforced on aggregator routings.
Native Moonshot provider lists (kimi-coding, kimi-coding-cn, moonshot,
opencode-zen, opencode-go) are unchanged — those use Moonshot's own
model IDs which are unaffected.
2026-04-20 11:49:54 -07:00
|
|
|
# URLs containing query strings — matches `scheme://...?...[# or end]`.
|
|
|
|
|
# Used to scan text for URLs whose query params may contain secrets.
|
|
|
|
|
# Ported from nearai/ironclaw#2529.
|
|
|
|
|
_URL_WITH_QUERY_RE = re.compile(
|
|
|
|
|
r"(https?|wss?|ftp)://" # scheme
|
|
|
|
|
r"([^\s/?#]+)" # authority (may include userinfo)
|
|
|
|
|
r"([^\s?#]*)" # path
|
|
|
|
|
r"\?([^\s#]+)" # query (required)
|
|
|
|
|
r"(#\S*)?", # optional fragment
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# URLs containing userinfo — `scheme://user:password@host` for ANY scheme
|
|
|
|
|
# (not just DB protocols already covered by _DB_CONNSTR_RE above).
|
|
|
|
|
# Catches things like `https://user:token@api.example.com/v1/foo`.
|
|
|
|
|
_URL_USERINFO_RE = re.compile(
|
|
|
|
|
r"(https?|wss?|ftp)://([^/\s:@]+):([^/\s@]+)@",
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-24 16:04:39 -06:00
|
|
|
# HTTP access logs often use a relative request target rather than a full URL:
|
|
|
|
|
# `"POST /webhook?password=... HTTP/1.1"`. The full-URL redactor above only
|
|
|
|
|
# sees strings containing `://`, so handle request-target query strings too.
|
|
|
|
|
_HTTP_REQUEST_TARGET_QUERY_RE = re.compile(
|
|
|
|
|
r"\b((?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE|CONNECT)\s+[^ \t\r\n\"']*?)"
|
|
|
|
|
r"\?([^ \t\r\n\"']+)",
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
|
feat: replace kimi-k2.5 with kimi-k2.6 on OpenRouter and Nous Portal (#13148)
* feat(security): URL query param + userinfo + form body redaction
Port from nearai/ironclaw#2529.
Hermes already has broad value-shape coverage in agent/redact.py
(30+ vendor prefixes, JWTs, DB connstrs, etc.) but missed three
key-name-based patterns that catch opaque tokens without recognizable
prefixes:
1. URL query params - OAuth callback codes (?code=...),
access_token, refresh_token, signature, etc. These are opaque and
won't match any prefix regex. Now redacted by parameter NAME.
2. URL userinfo (https://user:pass@host) - for non-DB schemes. DB
schemes were already handled by _DB_CONNSTR_RE.
3. Form-urlencoded body (k=v pairs joined by ampersands) -
conservative, only triggers on clean pure-form inputs with no
other text.
Sensitive key allowlist matches ironclaw's (exact case-insensitive,
NOT substring - so token_count and session_id pass through).
Tests: +20 new test cases across 3 test classes. All 75 redact tests
pass; gateway/test_pii_redaction and tools/test_browser_secret_exfil
also green.
Known pre-existing limitation: _ENV_ASSIGN_RE greedy match swallows
whole all-caps ENV-style names + trailing text when followed by
another assignment. Left untouched here (out of scope); URL query
redaction handles the lowercase case.
* feat: replace kimi-k2.5 with kimi-k2.6 on OpenRouter and Nous Portal
Update model catalogs for OpenRouter (fallback snapshot), Nous Portal,
and NVIDIA NIM to reference moonshotai/kimi-k2.6. Add kimi-k2.6 to
the fixed-temperature frozenset in auxiliary_client.py so the 0.6
contract is enforced on aggregator routings.
Native Moonshot provider lists (kimi-coding, kimi-coding-cn, moonshot,
opencode-zen, opencode-go) are unchanged — those use Moonshot's own
model IDs which are unaffected.
2026-04-20 11:49:54 -07:00
|
|
|
# Form-urlencoded body detection: conservative — only applies when the entire
|
|
|
|
|
# text looks like a query string (k=v&k=v pattern with no newlines).
|
|
|
|
|
_FORM_BODY_RE = re.compile(
|
|
|
|
|
r"^[A-Za-z_][A-Za-z0-9_.-]*=[^&\s]*(?:&[A-Za-z_][A-Za-z0-9_.-]*=[^&\s]*)+$"
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-28 21:56:05 -08:00
|
|
|
# Compile known prefix patterns into one alternation
|
|
|
|
|
_PREFIX_RE = re.compile(
|
|
|
|
|
r"(?<![A-Za-z0-9_-])(" + "|".join(_PREFIX_PATTERNS) + r")(?![A-Za-z0-9_-])"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
refactor(redact): canonical mask_secret helper; fix status.py DIM drift (#17207)
Three modules independently implemented the same "preserve head+tail of
a secret, mask the middle" logic with slightly different behaviors that
had started to drift:
hermes_cli/config.py redact_key — 12-char floor, 4+4, DIM '(not set)'
hermes_cli/status.py redact_key — 12-char floor, 4+4, plain '(not set)' ← drift
hermes_cli/dump.py _redact — 12-char floor, 4+4, empty string
The visible bug: 'hermes status' displayed the '(not set)' placeholder
in plain text while 'hermes config' showed it in dim text. Same concept,
inconsistent UI.
Introduces mask_secret() in agent/redact.py as the canonical helper,
with head/tail/floor/placeholder/empty kwargs. The three call sites
become one-line wrappers that differ only in the 'empty' handling:
config.redact_key → mask_secret(k, empty=color('(not set)', Colors.DIM))
status.redact_key → mask_secret(k, empty=color('(not set)', Colors.DIM))
dump._redact → mask_secret(v) # empty → ''
agent.redact._mask_token (log redactor, different policy: 18-char floor,
6+4 visible, '***' on empty) also ports to mask_secret but retains its
own empty-case handling to preserve the historical '***' return.
Net: the three display-time redactors now agree on formatting, the
canonical helper lives in one place, and future tweaks (e.g. adding
bullet-point masking, changing the head/tail widths) happen once.
Verified:
- 3/3 tests/hermes_cli/test_web_server.py::TestRedactKey pass
- 89/89 agent/tests/test_redact.py + tests/tools/test_browser_secret_exfil.py
+ tests/hermes_cli/test_redact_config_bridge.py pass
- Live 'hermes status', 'hermes config', 'hermes dump' all render the
same way they did before (verified against actual env with real
keys: OpenRouter, Firecrawl, Browserbase, FAL, Tinker all show
'prefix...suffix'; Kimi shows '***' at <12 chars; unset shows
'(not set)' uniformly).
Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 21:04:35 -07:00
|
|
|
def mask_secret(
|
|
|
|
|
value: str,
|
|
|
|
|
*,
|
|
|
|
|
head: int = 4,
|
|
|
|
|
tail: int = 4,
|
|
|
|
|
floor: int = 12,
|
|
|
|
|
placeholder: str = "***",
|
|
|
|
|
empty: str = "",
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Mask a secret for display, preserving ``head`` and ``tail`` characters.
|
|
|
|
|
|
|
|
|
|
Canonical helper for display-time redaction across Hermes — used by
|
|
|
|
|
``hermes config``, ``hermes status``, ``hermes dump``, and anywhere
|
|
|
|
|
a secret needs to be shown truncated for debuggability while still
|
|
|
|
|
keeping the bulk hidden.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
value: The secret to mask. ``None``/empty returns ``empty``.
|
|
|
|
|
head: Leading characters to preserve. Default 4.
|
|
|
|
|
tail: Trailing characters to preserve. Default 4.
|
|
|
|
|
floor: Values shorter than ``head + tail + floor_margin`` are
|
|
|
|
|
fully masked (returns ``placeholder``). Default 12 —
|
|
|
|
|
matches the existing config/status/dump convention.
|
|
|
|
|
placeholder: Value returned for too-short inputs. Default ``"***"``.
|
|
|
|
|
empty: Value returned when ``value`` is falsy (None, ""). The
|
|
|
|
|
caller can override this to e.g. ``color("(not set)",
|
|
|
|
|
Colors.DIM)`` for user-facing display.
|
|
|
|
|
|
|
|
|
|
Examples:
|
|
|
|
|
>>> mask_secret("sk-proj-abcdef1234567890")
|
|
|
|
|
'sk-p...7890'
|
|
|
|
|
>>> mask_secret("short") # fully masked
|
|
|
|
|
'***'
|
|
|
|
|
>>> mask_secret("") # empty default
|
|
|
|
|
''
|
|
|
|
|
>>> mask_secret("", empty="(not set)") # empty override
|
|
|
|
|
'(not set)'
|
|
|
|
|
>>> mask_secret("long-token", head=6, tail=4, floor=18)
|
|
|
|
|
'***'
|
|
|
|
|
"""
|
|
|
|
|
if not value:
|
|
|
|
|
return empty
|
|
|
|
|
if len(value) < floor:
|
|
|
|
|
return placeholder
|
|
|
|
|
return f"{value[:head]}...{value[-tail:]}"
|
|
|
|
|
|
|
|
|
|
|
2026-02-28 21:56:05 -08:00
|
|
|
def _mask_token(token: str) -> str:
|
refactor(redact): canonical mask_secret helper; fix status.py DIM drift (#17207)
Three modules independently implemented the same "preserve head+tail of
a secret, mask the middle" logic with slightly different behaviors that
had started to drift:
hermes_cli/config.py redact_key — 12-char floor, 4+4, DIM '(not set)'
hermes_cli/status.py redact_key — 12-char floor, 4+4, plain '(not set)' ← drift
hermes_cli/dump.py _redact — 12-char floor, 4+4, empty string
The visible bug: 'hermes status' displayed the '(not set)' placeholder
in plain text while 'hermes config' showed it in dim text. Same concept,
inconsistent UI.
Introduces mask_secret() in agent/redact.py as the canonical helper,
with head/tail/floor/placeholder/empty kwargs. The three call sites
become one-line wrappers that differ only in the 'empty' handling:
config.redact_key → mask_secret(k, empty=color('(not set)', Colors.DIM))
status.redact_key → mask_secret(k, empty=color('(not set)', Colors.DIM))
dump._redact → mask_secret(v) # empty → ''
agent.redact._mask_token (log redactor, different policy: 18-char floor,
6+4 visible, '***' on empty) also ports to mask_secret but retains its
own empty-case handling to preserve the historical '***' return.
Net: the three display-time redactors now agree on formatting, the
canonical helper lives in one place, and future tweaks (e.g. adding
bullet-point masking, changing the head/tail widths) happen once.
Verified:
- 3/3 tests/hermes_cli/test_web_server.py::TestRedactKey pass
- 89/89 agent/tests/test_redact.py + tests/tools/test_browser_secret_exfil.py
+ tests/hermes_cli/test_redact_config_bridge.py pass
- Live 'hermes status', 'hermes config', 'hermes dump' all render the
same way they did before (verified against actual env with real
keys: OpenRouter, Firecrawl, Browserbase, FAL, Tinker all show
'prefix...suffix'; Kimi shows '***' at <12 chars; unset shows
'(not set)' uniformly).
Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 21:04:35 -07:00
|
|
|
"""Mask a log token — conservative 18-char floor, preserves 6 prefix / 4 suffix."""
|
|
|
|
|
# Empty input: historically this returned "***" rather than "". Preserve.
|
|
|
|
|
if not token:
|
2026-02-28 21:56:05 -08:00
|
|
|
return "***"
|
refactor(redact): canonical mask_secret helper; fix status.py DIM drift (#17207)
Three modules independently implemented the same "preserve head+tail of
a secret, mask the middle" logic with slightly different behaviors that
had started to drift:
hermes_cli/config.py redact_key — 12-char floor, 4+4, DIM '(not set)'
hermes_cli/status.py redact_key — 12-char floor, 4+4, plain '(not set)' ← drift
hermes_cli/dump.py _redact — 12-char floor, 4+4, empty string
The visible bug: 'hermes status' displayed the '(not set)' placeholder
in plain text while 'hermes config' showed it in dim text. Same concept,
inconsistent UI.
Introduces mask_secret() in agent/redact.py as the canonical helper,
with head/tail/floor/placeholder/empty kwargs. The three call sites
become one-line wrappers that differ only in the 'empty' handling:
config.redact_key → mask_secret(k, empty=color('(not set)', Colors.DIM))
status.redact_key → mask_secret(k, empty=color('(not set)', Colors.DIM))
dump._redact → mask_secret(v) # empty → ''
agent.redact._mask_token (log redactor, different policy: 18-char floor,
6+4 visible, '***' on empty) also ports to mask_secret but retains its
own empty-case handling to preserve the historical '***' return.
Net: the three display-time redactors now agree on formatting, the
canonical helper lives in one place, and future tweaks (e.g. adding
bullet-point masking, changing the head/tail widths) happen once.
Verified:
- 3/3 tests/hermes_cli/test_web_server.py::TestRedactKey pass
- 89/89 agent/tests/test_redact.py + tests/tools/test_browser_secret_exfil.py
+ tests/hermes_cli/test_redact_config_bridge.py pass
- Live 'hermes status', 'hermes config', 'hermes dump' all render the
same way they did before (verified against actual env with real
keys: OpenRouter, Firecrawl, Browserbase, FAL, Tinker all show
'prefix...suffix'; Kimi shows '***' at <12 chars; unset shows
'(not set)' uniformly).
Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 21:04:35 -07:00
|
|
|
return mask_secret(token, head=6, tail=4, floor=18)
|
2026-02-28 21:56:05 -08:00
|
|
|
|
|
|
|
|
|
feat: replace kimi-k2.5 with kimi-k2.6 on OpenRouter and Nous Portal (#13148)
* feat(security): URL query param + userinfo + form body redaction
Port from nearai/ironclaw#2529.
Hermes already has broad value-shape coverage in agent/redact.py
(30+ vendor prefixes, JWTs, DB connstrs, etc.) but missed three
key-name-based patterns that catch opaque tokens without recognizable
prefixes:
1. URL query params - OAuth callback codes (?code=...),
access_token, refresh_token, signature, etc. These are opaque and
won't match any prefix regex. Now redacted by parameter NAME.
2. URL userinfo (https://user:pass@host) - for non-DB schemes. DB
schemes were already handled by _DB_CONNSTR_RE.
3. Form-urlencoded body (k=v pairs joined by ampersands) -
conservative, only triggers on clean pure-form inputs with no
other text.
Sensitive key allowlist matches ironclaw's (exact case-insensitive,
NOT substring - so token_count and session_id pass through).
Tests: +20 new test cases across 3 test classes. All 75 redact tests
pass; gateway/test_pii_redaction and tools/test_browser_secret_exfil
also green.
Known pre-existing limitation: _ENV_ASSIGN_RE greedy match swallows
whole all-caps ENV-style names + trailing text when followed by
another assignment. Left untouched here (out of scope); URL query
redaction handles the lowercase case.
* feat: replace kimi-k2.5 with kimi-k2.6 on OpenRouter and Nous Portal
Update model catalogs for OpenRouter (fallback snapshot), Nous Portal,
and NVIDIA NIM to reference moonshotai/kimi-k2.6. Add kimi-k2.6 to
the fixed-temperature frozenset in auxiliary_client.py so the 0.6
contract is enforced on aggregator routings.
Native Moonshot provider lists (kimi-coding, kimi-coding-cn, moonshot,
opencode-zen, opencode-go) are unchanged — those use Moonshot's own
model IDs which are unaffected.
2026-04-20 11:49:54 -07:00
|
|
|
def _redact_query_string(query: str) -> str:
|
|
|
|
|
"""Redact sensitive parameter values in a URL query string.
|
|
|
|
|
|
|
|
|
|
Handles `k=v&k=v` format. Sensitive keys (case-insensitive) have values
|
|
|
|
|
replaced with `***`. Non-sensitive keys pass through unchanged.
|
|
|
|
|
Empty or malformed pairs are preserved as-is.
|
|
|
|
|
"""
|
|
|
|
|
if not query:
|
|
|
|
|
return query
|
|
|
|
|
parts = []
|
|
|
|
|
for pair in query.split("&"):
|
|
|
|
|
if "=" not in pair:
|
|
|
|
|
parts.append(pair)
|
|
|
|
|
continue
|
|
|
|
|
key, _, value = pair.partition("=")
|
|
|
|
|
if key.lower() in _SENSITIVE_QUERY_PARAMS:
|
|
|
|
|
parts.append(f"{key}=***")
|
|
|
|
|
else:
|
|
|
|
|
parts.append(pair)
|
|
|
|
|
return "&".join(parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _redact_url_query_params(text: str) -> str:
|
|
|
|
|
"""Scan text for URLs with query strings and redact sensitive params.
|
|
|
|
|
|
|
|
|
|
Catches opaque tokens that don't match vendor prefix regexes, e.g.
|
|
|
|
|
`https://example.com/cb?code=ABC123&state=xyz` → `...?code=***&state=xyz`.
|
|
|
|
|
"""
|
|
|
|
|
def _sub(m: re.Match) -> str:
|
|
|
|
|
scheme = m.group(1)
|
|
|
|
|
authority = m.group(2)
|
|
|
|
|
path = m.group(3)
|
|
|
|
|
query = _redact_query_string(m.group(4))
|
|
|
|
|
fragment = m.group(5) or ""
|
|
|
|
|
return f"{scheme}://{authority}{path}?{query}{fragment}"
|
|
|
|
|
return _URL_WITH_QUERY_RE.sub(_sub, text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _redact_url_userinfo(text: str) -> str:
|
|
|
|
|
"""Strip `user:password@` from HTTP/WS/FTP URLs.
|
|
|
|
|
|
|
|
|
|
DB protocols (postgres, mysql, mongodb, redis, amqp) are handled
|
|
|
|
|
separately by `_DB_CONNSTR_RE`.
|
|
|
|
|
"""
|
|
|
|
|
return _URL_USERINFO_RE.sub(
|
|
|
|
|
lambda m: f"{m.group(1)}://{m.group(2)}:***@",
|
|
|
|
|
text,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-24 16:04:39 -06:00
|
|
|
def _redact_http_request_target_query_params(text: str) -> str:
|
|
|
|
|
"""Redact sensitive query params in HTTP access-log request targets."""
|
|
|
|
|
def _sub(m: re.Match) -> str:
|
|
|
|
|
prefix = m.group(1)
|
|
|
|
|
query = _redact_query_string(m.group(2))
|
|
|
|
|
return f"{prefix}?{query}"
|
|
|
|
|
return _HTTP_REQUEST_TARGET_QUERY_RE.sub(_sub, text)
|
|
|
|
|
|
|
|
|
|
|
feat: replace kimi-k2.5 with kimi-k2.6 on OpenRouter and Nous Portal (#13148)
* feat(security): URL query param + userinfo + form body redaction
Port from nearai/ironclaw#2529.
Hermes already has broad value-shape coverage in agent/redact.py
(30+ vendor prefixes, JWTs, DB connstrs, etc.) but missed three
key-name-based patterns that catch opaque tokens without recognizable
prefixes:
1. URL query params - OAuth callback codes (?code=...),
access_token, refresh_token, signature, etc. These are opaque and
won't match any prefix regex. Now redacted by parameter NAME.
2. URL userinfo (https://user:pass@host) - for non-DB schemes. DB
schemes were already handled by _DB_CONNSTR_RE.
3. Form-urlencoded body (k=v pairs joined by ampersands) -
conservative, only triggers on clean pure-form inputs with no
other text.
Sensitive key allowlist matches ironclaw's (exact case-insensitive,
NOT substring - so token_count and session_id pass through).
Tests: +20 new test cases across 3 test classes. All 75 redact tests
pass; gateway/test_pii_redaction and tools/test_browser_secret_exfil
also green.
Known pre-existing limitation: _ENV_ASSIGN_RE greedy match swallows
whole all-caps ENV-style names + trailing text when followed by
another assignment. Left untouched here (out of scope); URL query
redaction handles the lowercase case.
* feat: replace kimi-k2.5 with kimi-k2.6 on OpenRouter and Nous Portal
Update model catalogs for OpenRouter (fallback snapshot), Nous Portal,
and NVIDIA NIM to reference moonshotai/kimi-k2.6. Add kimi-k2.6 to
the fixed-temperature frozenset in auxiliary_client.py so the 0.6
contract is enforced on aggregator routings.
Native Moonshot provider lists (kimi-coding, kimi-coding-cn, moonshot,
opencode-zen, opencode-go) are unchanged — those use Moonshot's own
model IDs which are unaffected.
2026-04-20 11:49:54 -07:00
|
|
|
def _redact_form_body(text: str) -> str:
|
|
|
|
|
"""Redact sensitive values in a form-urlencoded body.
|
|
|
|
|
|
|
|
|
|
Only applies when the entire input looks like a pure form body
|
|
|
|
|
(k=v&k=v with no newlines, no other text). Single-line non-form
|
|
|
|
|
text passes through unchanged. This is a conservative pass — the
|
|
|
|
|
`_redact_url_query_params` function handles embedded query strings.
|
|
|
|
|
"""
|
|
|
|
|
if not text or "\n" in text or "&" not in text:
|
|
|
|
|
return text
|
|
|
|
|
# The body-body form check is strict: only trigger on clean k=v&k=v.
|
|
|
|
|
if not _FORM_BODY_RE.match(text.strip()):
|
|
|
|
|
return text
|
|
|
|
|
return _redact_query_string(text.strip())
|
|
|
|
|
|
|
|
|
|
|
fix(redact): non-reusable sentinel for prefix secrets in file reads (#35519)
When security.redact_secrets is on (default), read_file/search_files/cat
applied redact_sensitive_text(code_file=True) to file content, which still
ran prefix masking. An API key in config.yaml (ghp_..., sk-..., xai-..., etc.)
came back as a head/tail mask like `ghp_S1...Pn2T` — a plausible-looking
truncated key. When an agent read that and wrote it back to config, the masked
value replaced the real credential, silently breaking auth (401). Production
evidence: a config.yaml found containing the exact 13-char masked GitHub PAT.
The two community PRs (#35529, #35534) fixed the corruption by NOT redacting
prefixes for config reads — but that exposes the user's real keys to the agent
context, model, and logs (a security regression). This takes the safer route:
keep redacting, but for file content emit a NON-REUSABLE sentinel.
- New `_mask_token_nonreusable`: prefix secrets -> `«redacted:ghp_…»` (vendor
label preserved for debuggability; zero secret bytes; angle-bracket/ellipsis
wrapper is syntactically invalid as a token so it can't be mistaken for or
written back as a usable key).
- New `redact_sensitive_text(file_read=True)` routes prefix matches through it
(implies code_file=True). Default/log/display mode is UNCHANGED — `_mask_token`
still keeps head/tail (fine for logs, never written back).
- Wired the 3 file_tools.py call sites (read_file / search_files / cat) to
file_read=True.
Fixes both the corruption AND avoids the secret-exposure of the un-redact
approach. 6 new tests (sentinel shape, no-leak, not-a-plausible-key, default
mode unchanged, file_read implies code_file, sk- prefix); 88 redact tests pass;
mutation-verified (reverting to the old mask fails the sentinel/leak tests).
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: adammatski1972 <289282750+adammatski1972@users.noreply.github.com>
Closes #35519. Supersedes #35529, #35534.
2026-06-26 21:33:39 +05:30
|
|
|
def _mask_token_nonreusable(token: str) -> str:
|
|
|
|
|
"""Redact a prefix-matched credential to a NON-REUSABLE sentinel.
|
|
|
|
|
|
|
|
|
|
Unlike :func:`_mask_token` (which keeps head/tail chars — fine for logs
|
|
|
|
|
that are never fed back into a config), this emits a marker that:
|
|
|
|
|
|
|
|
|
|
* cannot be mistaken for a usable-but-truncated key, so an agent that
|
|
|
|
|
reads it from a config file and writes it back does NOT corrupt the
|
|
|
|
|
stored credential into a dead 13-char string (issue #35519); and
|
|
|
|
|
* still does not leak the secret material (no head/tail chars).
|
|
|
|
|
|
|
|
|
|
The vendor prefix label is preserved for debuggability so the agent can
|
|
|
|
|
still tell *which* credential is present (e.g. a GitHub PAT vs an OpenAI
|
|
|
|
|
key) without seeing any of its bytes.
|
|
|
|
|
"""
|
|
|
|
|
if not token:
|
|
|
|
|
return "«redacted-secret»"
|
|
|
|
|
# Preserve only the recognizable vendor prefix label (e.g. "ghp_", "sk-"),
|
|
|
|
|
# never any of the random secret body.
|
|
|
|
|
label = ""
|
|
|
|
|
for sub in _PREFIX_SUBSTRINGS:
|
|
|
|
|
if token.startswith(sub):
|
|
|
|
|
label = sub
|
|
|
|
|
break
|
|
|
|
|
return f"«redacted:{label}…»" if label else "«redacted-secret»"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def redact_sensitive_text(
|
|
|
|
|
text: str,
|
|
|
|
|
*,
|
|
|
|
|
force: bool = False,
|
|
|
|
|
code_file: bool = False,
|
|
|
|
|
file_read: bool = False,
|
|
|
|
|
) -> str:
|
2026-02-28 21:56:05 -08:00
|
|
|
"""Apply all redaction patterns to a block of text.
|
|
|
|
|
|
|
|
|
|
Safe to call on any string -- non-matching text passes through unchanged.
|
2026-05-07 19:57:14 -06:00
|
|
|
Enabled by default. Disable via security.redact_secrets: false in config.yaml.
|
2026-04-29 23:18:55 -07:00
|
|
|
Set force=True for safety boundaries that must never return raw secrets
|
|
|
|
|
regardless of the user's global logging redaction preference.
|
2026-04-27 00:05:49 +08:00
|
|
|
|
|
|
|
|
Set code_file=True to skip the ENV-assignment and JSON-field regex
|
|
|
|
|
patterns when the text is known to be source code (e.g. MAX_TOKENS=***
|
|
|
|
|
constants, "apiKey": "test" fixtures). Prefix patterns, auth headers,
|
|
|
|
|
private keys, DB connstrings, JWTs, and URL secrets are still redacted.
|
perf(agent-loop): cut 47% of per-conversation function calls via 3 targeted hot-path optimizations (#28866)
* perf(config): add load_config_readonly() fast path for hot agent loop
`load_config()` is called from the agent loop's per-API-call hot path via
`get_provider_request_timeout()` and `get_provider_stale_timeout()` —
both invoked once per turn from `_resolved_api_call_timeout()` in
run_agent.py.
Profiling a synthetic 20-tool-call agent run revealed:
- 21 invocations of `load_config()` cumulating 56ms (~17% of agent loop)
- 34,398 deepcopy calls totaling 37ms (config defensive deepcopy + chain)
- 8,652 `_expand_env_vars` invocations (~412 per turn)
Microbench (cache-hit, real config.yaml present):
load_config() 265us/call (125us deepcopy + 140us infra)
load_config_readonly() 138us/call (~48% faster)
`load_config_readonly()` returns the cached dict directly without the
defensive deepcopy. Documented contract: caller must not mutate. Returns
plain dict (not MappingProxyType) so downstream `isinstance(x, dict)`
guards keep working — caught during initial implementation when
MappingProxyType broke get_provider_request_timeout's guard logic.
Wired into hermes_cli/timeouts.py (the two functions called per agent
turn). load_config() is unchanged for the 263 other call sites that
mutate the result before save_config(), are not in the hot path, or
where the safety guarantee matters more than the perf.
Profile A/B (cached config, 21-turn agent loop):
BEFORE AFTER delta
get_provider_request_timeout 55ms 16ms -71%
total function calls 399k 160k -60%
deepcopy calls (in hotspots) 34,398 ~0 ~elim
Verified:
- isinstance(load_config_readonly(), dict) is True
- timeout/stale resolutions correct
- load_config() still returns isolated mutable deepcopies
- tests/hermes_cli/test_config*.py / test_timeouts.py: 102/102 pass
- tests/cli/ + tests/agent/test_auxiliary_client.py: 883/883 pass
* perf(redact): substring pre-screens skip non-matching regex chains
Every log record passes through `RedactingFormatter.format` which calls
`redact_sensitive_text`, which historically ran ALL 13 secret-pattern
regexes against every line — including DB connection strings, JWTs,
Discord mentions, Signal phone numbers, etc. — even for typical clean
log records like 'INFO run_agent: API call completed'.
Add cheap substring pre-checks before each regex pass. False positives
still run the regex (which then matches nothing); false negatives are
impossible because every pattern requires the gated substring to match
its leading anchor:
- `_PREFIX_RE` gated on any of 33 known credential prefix substrings
- `_ENV_ASSIGN_RE` gated on `=` in text
- `_JSON_FIELD_RE` gated on `:` and `"` in text
- `_AUTH_HEADER_RE` gated on `uthorization`/`UTHORIZATION` in text
- `_TELEGRAM_RE` gated on `:` in text
- `_PRIVATE_KEY_RE` gated on `BEGIN` and `-----`
- `_DB_CONNSTR_RE` gated on `://` in text
- `_JWT_RE` gated on `eyJ` in text
- URL userinfo/query gated on `://`
- `_redact_form_body` gated on `&` and `=`
- `_DISCORD_MENTION_RE` gated on `<@`
- `_SIGNAL_PHONE_RE` gated on `+`
Microbench (5 typical log records, 20k iterations each):
BEFORE AFTER delta
redact_sensitive_text per call 5.63us 1.79us -68%
Real-world impact: ~244 log records emitted in a 30-turn agent loop, so
the chain saves ~1ms of CPU per conversation. Bigger win is the
reduction in regex execution and GC pressure during heavy logging
sessions (verbose logging, gateway message processing).
Security regression test: 30 secret-containing inputs (sk-/ghp_/JWT/DB
connstr/Auth-Bearer/private key/URL userinfo/Discord/Signal/etc.)
verified to produce identical redacted output before/after. All 75
existing tests/agent/test_redact.py cases pass.
The `?access_token=foo&code=bar` (bare query string, no scheme) case
that 'leaks' is pre-existing behavior — the URL query redaction
requires a well-formed URL with scheme+host. Not a regression.
* perf(run_agent): cache _needs_thinking_reasoning_pad result per (provider, model, base_url)
Profile of a 31-turn synthetic agent run shows `_needs_thinking_reasoning_pad`
fires 495 times (~16 per turn) and each call ran 3 helper methods, each
hitting `base_url_host_matches` 1-4 times via `urlparse`. Total cost:
3,342 base_url_host_matches calls + 3,373 urlparse calls accounting for
~36ms of agent-loop overhead (~7% of the entire post-network work).
Provider / model / base_url don't change during a conversation except via
`switch_model` and fallback activation — both of which already overwrite
those attributes atomically. Cache the result on a tuple key; since the
key is derived from the very fields that would change, the cache
auto-invalidates on the next read after a switch. No manual invalidation
needed in switch_model / _try_activate_fallback.
Profile A/B (31-turn cached-config agent run):
BEFORE AFTER delta
_needs_thinking_reasoning_pad cum 18ms 1ms -94%
_copy_reasoning_content_for_api cum 17ms 1ms -94%
base_url_host_matches calls 3,342 372 -89%
urlparse calls 3,373 403 -88%
total function calls 296k 223k -25%
Verified:
- tests/run_agent/test_deepseek_reasoning_content_echo.py: 36/36 pass
- tests/run_agent/ (full): 1383/1383 pass + 3 skipped
2026-05-19 14:25:10 -07:00
|
|
|
|
fix(redact): non-reusable sentinel for prefix secrets in file reads (#35519)
When security.redact_secrets is on (default), read_file/search_files/cat
applied redact_sensitive_text(code_file=True) to file content, which still
ran prefix masking. An API key in config.yaml (ghp_..., sk-..., xai-..., etc.)
came back as a head/tail mask like `ghp_S1...Pn2T` — a plausible-looking
truncated key. When an agent read that and wrote it back to config, the masked
value replaced the real credential, silently breaking auth (401). Production
evidence: a config.yaml found containing the exact 13-char masked GitHub PAT.
The two community PRs (#35529, #35534) fixed the corruption by NOT redacting
prefixes for config reads — but that exposes the user's real keys to the agent
context, model, and logs (a security regression). This takes the safer route:
keep redacting, but for file content emit a NON-REUSABLE sentinel.
- New `_mask_token_nonreusable`: prefix secrets -> `«redacted:ghp_…»` (vendor
label preserved for debuggability; zero secret bytes; angle-bracket/ellipsis
wrapper is syntactically invalid as a token so it can't be mistaken for or
written back as a usable key).
- New `redact_sensitive_text(file_read=True)` routes prefix matches through it
(implies code_file=True). Default/log/display mode is UNCHANGED — `_mask_token`
still keeps head/tail (fine for logs, never written back).
- Wired the 3 file_tools.py call sites (read_file / search_files / cat) to
file_read=True.
Fixes both the corruption AND avoids the secret-exposure of the un-redact
approach. 6 new tests (sentinel shape, no-leak, not-a-plausible-key, default
mode unchanged, file_read implies code_file, sk- prefix); 88 redact tests pass;
mutation-verified (reverting to the old mask fails the sentinel/leak tests).
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: adammatski1972 <289282750+adammatski1972@users.noreply.github.com>
Closes #35519. Supersedes #35529, #35534.
2026-06-26 21:33:39 +05:30
|
|
|
Set file_read=True for file *content* returned to the agent (read_file /
|
|
|
|
|
search_files / cat). Secrets are STILL redacted — they are never exposed —
|
|
|
|
|
but prefix-matched credentials are replaced with a non-reusable sentinel
|
|
|
|
|
(``«redacted:ghp_…»``) instead of a head/tail-preserving mask
|
|
|
|
|
(``ghp_S1...Pn2T``). The old mask looked like a real-but-truncated key, so
|
|
|
|
|
an agent reading it from config.yaml and writing it back silently corrupted
|
|
|
|
|
the stored credential into a dead 13-char value → 401 (issue #35519). The
|
|
|
|
|
sentinel is syntactically invalid as a token, so it can't be mistaken for a
|
|
|
|
|
usable key or written back as one. Implies code_file=True (config/data
|
|
|
|
|
files shouldn't trigger the source-code ENV/JSON false-positive paths).
|
|
|
|
|
|
perf(agent-loop): cut 47% of per-conversation function calls via 3 targeted hot-path optimizations (#28866)
* perf(config): add load_config_readonly() fast path for hot agent loop
`load_config()` is called from the agent loop's per-API-call hot path via
`get_provider_request_timeout()` and `get_provider_stale_timeout()` —
both invoked once per turn from `_resolved_api_call_timeout()` in
run_agent.py.
Profiling a synthetic 20-tool-call agent run revealed:
- 21 invocations of `load_config()` cumulating 56ms (~17% of agent loop)
- 34,398 deepcopy calls totaling 37ms (config defensive deepcopy + chain)
- 8,652 `_expand_env_vars` invocations (~412 per turn)
Microbench (cache-hit, real config.yaml present):
load_config() 265us/call (125us deepcopy + 140us infra)
load_config_readonly() 138us/call (~48% faster)
`load_config_readonly()` returns the cached dict directly without the
defensive deepcopy. Documented contract: caller must not mutate. Returns
plain dict (not MappingProxyType) so downstream `isinstance(x, dict)`
guards keep working — caught during initial implementation when
MappingProxyType broke get_provider_request_timeout's guard logic.
Wired into hermes_cli/timeouts.py (the two functions called per agent
turn). load_config() is unchanged for the 263 other call sites that
mutate the result before save_config(), are not in the hot path, or
where the safety guarantee matters more than the perf.
Profile A/B (cached config, 21-turn agent loop):
BEFORE AFTER delta
get_provider_request_timeout 55ms 16ms -71%
total function calls 399k 160k -60%
deepcopy calls (in hotspots) 34,398 ~0 ~elim
Verified:
- isinstance(load_config_readonly(), dict) is True
- timeout/stale resolutions correct
- load_config() still returns isolated mutable deepcopies
- tests/hermes_cli/test_config*.py / test_timeouts.py: 102/102 pass
- tests/cli/ + tests/agent/test_auxiliary_client.py: 883/883 pass
* perf(redact): substring pre-screens skip non-matching regex chains
Every log record passes through `RedactingFormatter.format` which calls
`redact_sensitive_text`, which historically ran ALL 13 secret-pattern
regexes against every line — including DB connection strings, JWTs,
Discord mentions, Signal phone numbers, etc. — even for typical clean
log records like 'INFO run_agent: API call completed'.
Add cheap substring pre-checks before each regex pass. False positives
still run the regex (which then matches nothing); false negatives are
impossible because every pattern requires the gated substring to match
its leading anchor:
- `_PREFIX_RE` gated on any of 33 known credential prefix substrings
- `_ENV_ASSIGN_RE` gated on `=` in text
- `_JSON_FIELD_RE` gated on `:` and `"` in text
- `_AUTH_HEADER_RE` gated on `uthorization`/`UTHORIZATION` in text
- `_TELEGRAM_RE` gated on `:` in text
- `_PRIVATE_KEY_RE` gated on `BEGIN` and `-----`
- `_DB_CONNSTR_RE` gated on `://` in text
- `_JWT_RE` gated on `eyJ` in text
- URL userinfo/query gated on `://`
- `_redact_form_body` gated on `&` and `=`
- `_DISCORD_MENTION_RE` gated on `<@`
- `_SIGNAL_PHONE_RE` gated on `+`
Microbench (5 typical log records, 20k iterations each):
BEFORE AFTER delta
redact_sensitive_text per call 5.63us 1.79us -68%
Real-world impact: ~244 log records emitted in a 30-turn agent loop, so
the chain saves ~1ms of CPU per conversation. Bigger win is the
reduction in regex execution and GC pressure during heavy logging
sessions (verbose logging, gateway message processing).
Security regression test: 30 secret-containing inputs (sk-/ghp_/JWT/DB
connstr/Auth-Bearer/private key/URL userinfo/Discord/Signal/etc.)
verified to produce identical redacted output before/after. All 75
existing tests/agent/test_redact.py cases pass.
The `?access_token=foo&code=bar` (bare query string, no scheme) case
that 'leaks' is pre-existing behavior — the URL query redaction
requires a well-formed URL with scheme+host. Not a regression.
* perf(run_agent): cache _needs_thinking_reasoning_pad result per (provider, model, base_url)
Profile of a 31-turn synthetic agent run shows `_needs_thinking_reasoning_pad`
fires 495 times (~16 per turn) and each call ran 3 helper methods, each
hitting `base_url_host_matches` 1-4 times via `urlparse`. Total cost:
3,342 base_url_host_matches calls + 3,373 urlparse calls accounting for
~36ms of agent-loop overhead (~7% of the entire post-network work).
Provider / model / base_url don't change during a conversation except via
`switch_model` and fallback activation — both of which already overwrite
those attributes atomically. Cache the result on a tuple key; since the
key is derived from the very fields that would change, the cache
auto-invalidates on the next read after a switch. No manual invalidation
needed in switch_model / _try_activate_fallback.
Profile A/B (31-turn cached-config agent run):
BEFORE AFTER delta
_needs_thinking_reasoning_pad cum 18ms 1ms -94%
_copy_reasoning_content_for_api cum 17ms 1ms -94%
base_url_host_matches calls 3,342 372 -89%
urlparse calls 3,373 403 -88%
total function calls 296k 223k -25%
Verified:
- tests/run_agent/test_deepseek_reasoning_content_echo.py: 36/36 pass
- tests/run_agent/ (full): 1383/1383 pass + 3 skipped
2026-05-19 14:25:10 -07:00
|
|
|
Performance: each regex pattern is gated behind a cheap substring
|
|
|
|
|
pre-check (e.g. ``"=" in text`` for ENV assignments, ``"://" in text``
|
|
|
|
|
for URLs, ``"eyJ" in text`` for JWTs). On a typical hermes log line
|
|
|
|
|
(no secrets) this drops the 13-pattern scan from ~5.6us to ~1.8us per
|
|
|
|
|
record (-68%). The pre-checks are conservative — false positives
|
|
|
|
|
still run the full regex, which then doesn't match. False negatives
|
|
|
|
|
are impossible because every regex requires the gated substring to
|
|
|
|
|
match.
|
2026-02-28 21:56:05 -08:00
|
|
|
"""
|
2026-03-21 16:55:02 -07:00
|
|
|
if text is None:
|
|
|
|
|
return None
|
|
|
|
|
if not isinstance(text, str):
|
|
|
|
|
text = str(text)
|
2026-02-28 21:56:05 -08:00
|
|
|
if not text:
|
|
|
|
|
return text
|
2026-04-29 23:18:55 -07:00
|
|
|
if not (force or _REDACT_ENABLED):
|
2026-03-09 01:04:33 -07:00
|
|
|
return text
|
2026-02-28 21:56:05 -08:00
|
|
|
|
fix(redact): non-reusable sentinel for prefix secrets in file reads (#35519)
When security.redact_secrets is on (default), read_file/search_files/cat
applied redact_sensitive_text(code_file=True) to file content, which still
ran prefix masking. An API key in config.yaml (ghp_..., sk-..., xai-..., etc.)
came back as a head/tail mask like `ghp_S1...Pn2T` — a plausible-looking
truncated key. When an agent read that and wrote it back to config, the masked
value replaced the real credential, silently breaking auth (401). Production
evidence: a config.yaml found containing the exact 13-char masked GitHub PAT.
The two community PRs (#35529, #35534) fixed the corruption by NOT redacting
prefixes for config reads — but that exposes the user's real keys to the agent
context, model, and logs (a security regression). This takes the safer route:
keep redacting, but for file content emit a NON-REUSABLE sentinel.
- New `_mask_token_nonreusable`: prefix secrets -> `«redacted:ghp_…»` (vendor
label preserved for debuggability; zero secret bytes; angle-bracket/ellipsis
wrapper is syntactically invalid as a token so it can't be mistaken for or
written back as a usable key).
- New `redact_sensitive_text(file_read=True)` routes prefix matches through it
(implies code_file=True). Default/log/display mode is UNCHANGED — `_mask_token`
still keeps head/tail (fine for logs, never written back).
- Wired the 3 file_tools.py call sites (read_file / search_files / cat) to
file_read=True.
Fixes both the corruption AND avoids the secret-exposure of the un-redact
approach. 6 new tests (sentinel shape, no-leak, not-a-plausible-key, default
mode unchanged, file_read implies code_file, sk- prefix); 88 redact tests pass;
mutation-verified (reverting to the old mask fails the sentinel/leak tests).
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: adammatski1972 <289282750+adammatski1972@users.noreply.github.com>
Closes #35519. Supersedes #35529, #35534.
2026-06-26 21:33:39 +05:30
|
|
|
# file_read content shouldn't hit the source-code ENV/JSON false-positive
|
|
|
|
|
# paths either (it's config/data, not log lines).
|
|
|
|
|
if file_read:
|
|
|
|
|
code_file = True
|
|
|
|
|
|
perf(agent-loop): cut 47% of per-conversation function calls via 3 targeted hot-path optimizations (#28866)
* perf(config): add load_config_readonly() fast path for hot agent loop
`load_config()` is called from the agent loop's per-API-call hot path via
`get_provider_request_timeout()` and `get_provider_stale_timeout()` —
both invoked once per turn from `_resolved_api_call_timeout()` in
run_agent.py.
Profiling a synthetic 20-tool-call agent run revealed:
- 21 invocations of `load_config()` cumulating 56ms (~17% of agent loop)
- 34,398 deepcopy calls totaling 37ms (config defensive deepcopy + chain)
- 8,652 `_expand_env_vars` invocations (~412 per turn)
Microbench (cache-hit, real config.yaml present):
load_config() 265us/call (125us deepcopy + 140us infra)
load_config_readonly() 138us/call (~48% faster)
`load_config_readonly()` returns the cached dict directly without the
defensive deepcopy. Documented contract: caller must not mutate. Returns
plain dict (not MappingProxyType) so downstream `isinstance(x, dict)`
guards keep working — caught during initial implementation when
MappingProxyType broke get_provider_request_timeout's guard logic.
Wired into hermes_cli/timeouts.py (the two functions called per agent
turn). load_config() is unchanged for the 263 other call sites that
mutate the result before save_config(), are not in the hot path, or
where the safety guarantee matters more than the perf.
Profile A/B (cached config, 21-turn agent loop):
BEFORE AFTER delta
get_provider_request_timeout 55ms 16ms -71%
total function calls 399k 160k -60%
deepcopy calls (in hotspots) 34,398 ~0 ~elim
Verified:
- isinstance(load_config_readonly(), dict) is True
- timeout/stale resolutions correct
- load_config() still returns isolated mutable deepcopies
- tests/hermes_cli/test_config*.py / test_timeouts.py: 102/102 pass
- tests/cli/ + tests/agent/test_auxiliary_client.py: 883/883 pass
* perf(redact): substring pre-screens skip non-matching regex chains
Every log record passes through `RedactingFormatter.format` which calls
`redact_sensitive_text`, which historically ran ALL 13 secret-pattern
regexes against every line — including DB connection strings, JWTs,
Discord mentions, Signal phone numbers, etc. — even for typical clean
log records like 'INFO run_agent: API call completed'.
Add cheap substring pre-checks before each regex pass. False positives
still run the regex (which then matches nothing); false negatives are
impossible because every pattern requires the gated substring to match
its leading anchor:
- `_PREFIX_RE` gated on any of 33 known credential prefix substrings
- `_ENV_ASSIGN_RE` gated on `=` in text
- `_JSON_FIELD_RE` gated on `:` and `"` in text
- `_AUTH_HEADER_RE` gated on `uthorization`/`UTHORIZATION` in text
- `_TELEGRAM_RE` gated on `:` in text
- `_PRIVATE_KEY_RE` gated on `BEGIN` and `-----`
- `_DB_CONNSTR_RE` gated on `://` in text
- `_JWT_RE` gated on `eyJ` in text
- URL userinfo/query gated on `://`
- `_redact_form_body` gated on `&` and `=`
- `_DISCORD_MENTION_RE` gated on `<@`
- `_SIGNAL_PHONE_RE` gated on `+`
Microbench (5 typical log records, 20k iterations each):
BEFORE AFTER delta
redact_sensitive_text per call 5.63us 1.79us -68%
Real-world impact: ~244 log records emitted in a 30-turn agent loop, so
the chain saves ~1ms of CPU per conversation. Bigger win is the
reduction in regex execution and GC pressure during heavy logging
sessions (verbose logging, gateway message processing).
Security regression test: 30 secret-containing inputs (sk-/ghp_/JWT/DB
connstr/Auth-Bearer/private key/URL userinfo/Discord/Signal/etc.)
verified to produce identical redacted output before/after. All 75
existing tests/agent/test_redact.py cases pass.
The `?access_token=foo&code=bar` (bare query string, no scheme) case
that 'leaks' is pre-existing behavior — the URL query redaction
requires a well-formed URL with scheme+host. Not a regression.
* perf(run_agent): cache _needs_thinking_reasoning_pad result per (provider, model, base_url)
Profile of a 31-turn synthetic agent run shows `_needs_thinking_reasoning_pad`
fires 495 times (~16 per turn) and each call ran 3 helper methods, each
hitting `base_url_host_matches` 1-4 times via `urlparse`. Total cost:
3,342 base_url_host_matches calls + 3,373 urlparse calls accounting for
~36ms of agent-loop overhead (~7% of the entire post-network work).
Provider / model / base_url don't change during a conversation except via
`switch_model` and fallback activation — both of which already overwrite
those attributes atomically. Cache the result on a tuple key; since the
key is derived from the very fields that would change, the cache
auto-invalidates on the next read after a switch. No manual invalidation
needed in switch_model / _try_activate_fallback.
Profile A/B (31-turn cached-config agent run):
BEFORE AFTER delta
_needs_thinking_reasoning_pad cum 18ms 1ms -94%
_copy_reasoning_content_for_api cum 17ms 1ms -94%
base_url_host_matches calls 3,342 372 -89%
urlparse calls 3,373 403 -88%
total function calls 296k 223k -25%
Verified:
- tests/run_agent/test_deepseek_reasoning_content_echo.py: 36/36 pass
- tests/run_agent/ (full): 1383/1383 pass + 3 skipped
2026-05-19 14:25:10 -07:00
|
|
|
# Known prefixes (sk-, ghp_, etc.) — gate on substring presence
|
|
|
|
|
if _has_known_prefix_substring(text):
|
fix(redact): non-reusable sentinel for prefix secrets in file reads (#35519)
When security.redact_secrets is on (default), read_file/search_files/cat
applied redact_sensitive_text(code_file=True) to file content, which still
ran prefix masking. An API key in config.yaml (ghp_..., sk-..., xai-..., etc.)
came back as a head/tail mask like `ghp_S1...Pn2T` — a plausible-looking
truncated key. When an agent read that and wrote it back to config, the masked
value replaced the real credential, silently breaking auth (401). Production
evidence: a config.yaml found containing the exact 13-char masked GitHub PAT.
The two community PRs (#35529, #35534) fixed the corruption by NOT redacting
prefixes for config reads — but that exposes the user's real keys to the agent
context, model, and logs (a security regression). This takes the safer route:
keep redacting, but for file content emit a NON-REUSABLE sentinel.
- New `_mask_token_nonreusable`: prefix secrets -> `«redacted:ghp_…»` (vendor
label preserved for debuggability; zero secret bytes; angle-bracket/ellipsis
wrapper is syntactically invalid as a token so it can't be mistaken for or
written back as a usable key).
- New `redact_sensitive_text(file_read=True)` routes prefix matches through it
(implies code_file=True). Default/log/display mode is UNCHANGED — `_mask_token`
still keeps head/tail (fine for logs, never written back).
- Wired the 3 file_tools.py call sites (read_file / search_files / cat) to
file_read=True.
Fixes both the corruption AND avoids the secret-exposure of the un-redact
approach. 6 new tests (sentinel shape, no-leak, not-a-plausible-key, default
mode unchanged, file_read implies code_file, sk- prefix); 88 redact tests pass;
mutation-verified (reverting to the old mask fails the sentinel/leak tests).
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: adammatski1972 <289282750+adammatski1972@users.noreply.github.com>
Closes #35519. Supersedes #35529, #35534.
2026-06-26 21:33:39 +05:30
|
|
|
_prefix_sub = _mask_token_nonreusable if file_read else _mask_token
|
|
|
|
|
text = _PREFIX_RE.sub(lambda m: _prefix_sub(m.group(1)), text)
|
2026-02-28 21:56:05 -08:00
|
|
|
|
2026-04-27 00:05:49 +08:00
|
|
|
# ENV assignments: OPENAI_API_KEY=*** (skip for code files — false positives)
|
|
|
|
|
if not code_file:
|
perf(agent-loop): cut 47% of per-conversation function calls via 3 targeted hot-path optimizations (#28866)
* perf(config): add load_config_readonly() fast path for hot agent loop
`load_config()` is called from the agent loop's per-API-call hot path via
`get_provider_request_timeout()` and `get_provider_stale_timeout()` —
both invoked once per turn from `_resolved_api_call_timeout()` in
run_agent.py.
Profiling a synthetic 20-tool-call agent run revealed:
- 21 invocations of `load_config()` cumulating 56ms (~17% of agent loop)
- 34,398 deepcopy calls totaling 37ms (config defensive deepcopy + chain)
- 8,652 `_expand_env_vars` invocations (~412 per turn)
Microbench (cache-hit, real config.yaml present):
load_config() 265us/call (125us deepcopy + 140us infra)
load_config_readonly() 138us/call (~48% faster)
`load_config_readonly()` returns the cached dict directly without the
defensive deepcopy. Documented contract: caller must not mutate. Returns
plain dict (not MappingProxyType) so downstream `isinstance(x, dict)`
guards keep working — caught during initial implementation when
MappingProxyType broke get_provider_request_timeout's guard logic.
Wired into hermes_cli/timeouts.py (the two functions called per agent
turn). load_config() is unchanged for the 263 other call sites that
mutate the result before save_config(), are not in the hot path, or
where the safety guarantee matters more than the perf.
Profile A/B (cached config, 21-turn agent loop):
BEFORE AFTER delta
get_provider_request_timeout 55ms 16ms -71%
total function calls 399k 160k -60%
deepcopy calls (in hotspots) 34,398 ~0 ~elim
Verified:
- isinstance(load_config_readonly(), dict) is True
- timeout/stale resolutions correct
- load_config() still returns isolated mutable deepcopies
- tests/hermes_cli/test_config*.py / test_timeouts.py: 102/102 pass
- tests/cli/ + tests/agent/test_auxiliary_client.py: 883/883 pass
* perf(redact): substring pre-screens skip non-matching regex chains
Every log record passes through `RedactingFormatter.format` which calls
`redact_sensitive_text`, which historically ran ALL 13 secret-pattern
regexes against every line — including DB connection strings, JWTs,
Discord mentions, Signal phone numbers, etc. — even for typical clean
log records like 'INFO run_agent: API call completed'.
Add cheap substring pre-checks before each regex pass. False positives
still run the regex (which then matches nothing); false negatives are
impossible because every pattern requires the gated substring to match
its leading anchor:
- `_PREFIX_RE` gated on any of 33 known credential prefix substrings
- `_ENV_ASSIGN_RE` gated on `=` in text
- `_JSON_FIELD_RE` gated on `:` and `"` in text
- `_AUTH_HEADER_RE` gated on `uthorization`/`UTHORIZATION` in text
- `_TELEGRAM_RE` gated on `:` in text
- `_PRIVATE_KEY_RE` gated on `BEGIN` and `-----`
- `_DB_CONNSTR_RE` gated on `://` in text
- `_JWT_RE` gated on `eyJ` in text
- URL userinfo/query gated on `://`
- `_redact_form_body` gated on `&` and `=`
- `_DISCORD_MENTION_RE` gated on `<@`
- `_SIGNAL_PHONE_RE` gated on `+`
Microbench (5 typical log records, 20k iterations each):
BEFORE AFTER delta
redact_sensitive_text per call 5.63us 1.79us -68%
Real-world impact: ~244 log records emitted in a 30-turn agent loop, so
the chain saves ~1ms of CPU per conversation. Bigger win is the
reduction in regex execution and GC pressure during heavy logging
sessions (verbose logging, gateway message processing).
Security regression test: 30 secret-containing inputs (sk-/ghp_/JWT/DB
connstr/Auth-Bearer/private key/URL userinfo/Discord/Signal/etc.)
verified to produce identical redacted output before/after. All 75
existing tests/agent/test_redact.py cases pass.
The `?access_token=foo&code=bar` (bare query string, no scheme) case
that 'leaks' is pre-existing behavior — the URL query redaction
requires a well-formed URL with scheme+host. Not a regression.
* perf(run_agent): cache _needs_thinking_reasoning_pad result per (provider, model, base_url)
Profile of a 31-turn synthetic agent run shows `_needs_thinking_reasoning_pad`
fires 495 times (~16 per turn) and each call ran 3 helper methods, each
hitting `base_url_host_matches` 1-4 times via `urlparse`. Total cost:
3,342 base_url_host_matches calls + 3,373 urlparse calls accounting for
~36ms of agent-loop overhead (~7% of the entire post-network work).
Provider / model / base_url don't change during a conversation except via
`switch_model` and fallback activation — both of which already overwrite
those attributes atomically. Cache the result on a tuple key; since the
key is derived from the very fields that would change, the cache
auto-invalidates on the next read after a switch. No manual invalidation
needed in switch_model / _try_activate_fallback.
Profile A/B (31-turn cached-config agent run):
BEFORE AFTER delta
_needs_thinking_reasoning_pad cum 18ms 1ms -94%
_copy_reasoning_content_for_api cum 17ms 1ms -94%
base_url_host_matches calls 3,342 372 -89%
urlparse calls 3,373 403 -88%
total function calls 296k 223k -25%
Verified:
- tests/run_agent/test_deepseek_reasoning_content_echo.py: 36/36 pass
- tests/run_agent/ (full): 1383/1383 pass + 3 skipped
2026-05-19 14:25:10 -07:00
|
|
|
if "=" in text:
|
|
|
|
|
def _redact_env(m):
|
|
|
|
|
name, quote, value = m.group(1), m.group(2), m.group(3)
|
|
|
|
|
return f"{name}={quote}{_mask_token(value)}{quote}"
|
|
|
|
|
text = _ENV_ASSIGN_RE.sub(_redact_env, text)
|
2026-06-27 04:43:28 -07:00
|
|
|
# Lowercase/dotted config keys (issue #16413). Skip URLs entirely —
|
|
|
|
|
# web-URL query params are intentionally passed through (see note
|
|
|
|
|
# near the bottom of this function); _DB_CONNSTR_RE still guards
|
|
|
|
|
# connection-string passwords.
|
|
|
|
|
if "://" not in text:
|
|
|
|
|
text = _CFG_DOTTED_RE.sub(_redact_env, text)
|
|
|
|
|
text = _CFG_ANCHORED_RE.sub(_redact_env, text)
|
2026-04-27 00:05:49 +08:00
|
|
|
|
|
|
|
|
# JSON fields: "apiKey": "***" (skip for code files — false positives)
|
perf(agent-loop): cut 47% of per-conversation function calls via 3 targeted hot-path optimizations (#28866)
* perf(config): add load_config_readonly() fast path for hot agent loop
`load_config()` is called from the agent loop's per-API-call hot path via
`get_provider_request_timeout()` and `get_provider_stale_timeout()` —
both invoked once per turn from `_resolved_api_call_timeout()` in
run_agent.py.
Profiling a synthetic 20-tool-call agent run revealed:
- 21 invocations of `load_config()` cumulating 56ms (~17% of agent loop)
- 34,398 deepcopy calls totaling 37ms (config defensive deepcopy + chain)
- 8,652 `_expand_env_vars` invocations (~412 per turn)
Microbench (cache-hit, real config.yaml present):
load_config() 265us/call (125us deepcopy + 140us infra)
load_config_readonly() 138us/call (~48% faster)
`load_config_readonly()` returns the cached dict directly without the
defensive deepcopy. Documented contract: caller must not mutate. Returns
plain dict (not MappingProxyType) so downstream `isinstance(x, dict)`
guards keep working — caught during initial implementation when
MappingProxyType broke get_provider_request_timeout's guard logic.
Wired into hermes_cli/timeouts.py (the two functions called per agent
turn). load_config() is unchanged for the 263 other call sites that
mutate the result before save_config(), are not in the hot path, or
where the safety guarantee matters more than the perf.
Profile A/B (cached config, 21-turn agent loop):
BEFORE AFTER delta
get_provider_request_timeout 55ms 16ms -71%
total function calls 399k 160k -60%
deepcopy calls (in hotspots) 34,398 ~0 ~elim
Verified:
- isinstance(load_config_readonly(), dict) is True
- timeout/stale resolutions correct
- load_config() still returns isolated mutable deepcopies
- tests/hermes_cli/test_config*.py / test_timeouts.py: 102/102 pass
- tests/cli/ + tests/agent/test_auxiliary_client.py: 883/883 pass
* perf(redact): substring pre-screens skip non-matching regex chains
Every log record passes through `RedactingFormatter.format` which calls
`redact_sensitive_text`, which historically ran ALL 13 secret-pattern
regexes against every line — including DB connection strings, JWTs,
Discord mentions, Signal phone numbers, etc. — even for typical clean
log records like 'INFO run_agent: API call completed'.
Add cheap substring pre-checks before each regex pass. False positives
still run the regex (which then matches nothing); false negatives are
impossible because every pattern requires the gated substring to match
its leading anchor:
- `_PREFIX_RE` gated on any of 33 known credential prefix substrings
- `_ENV_ASSIGN_RE` gated on `=` in text
- `_JSON_FIELD_RE` gated on `:` and `"` in text
- `_AUTH_HEADER_RE` gated on `uthorization`/`UTHORIZATION` in text
- `_TELEGRAM_RE` gated on `:` in text
- `_PRIVATE_KEY_RE` gated on `BEGIN` and `-----`
- `_DB_CONNSTR_RE` gated on `://` in text
- `_JWT_RE` gated on `eyJ` in text
- URL userinfo/query gated on `://`
- `_redact_form_body` gated on `&` and `=`
- `_DISCORD_MENTION_RE` gated on `<@`
- `_SIGNAL_PHONE_RE` gated on `+`
Microbench (5 typical log records, 20k iterations each):
BEFORE AFTER delta
redact_sensitive_text per call 5.63us 1.79us -68%
Real-world impact: ~244 log records emitted in a 30-turn agent loop, so
the chain saves ~1ms of CPU per conversation. Bigger win is the
reduction in regex execution and GC pressure during heavy logging
sessions (verbose logging, gateway message processing).
Security regression test: 30 secret-containing inputs (sk-/ghp_/JWT/DB
connstr/Auth-Bearer/private key/URL userinfo/Discord/Signal/etc.)
verified to produce identical redacted output before/after. All 75
existing tests/agent/test_redact.py cases pass.
The `?access_token=foo&code=bar` (bare query string, no scheme) case
that 'leaks' is pre-existing behavior — the URL query redaction
requires a well-formed URL with scheme+host. Not a regression.
* perf(run_agent): cache _needs_thinking_reasoning_pad result per (provider, model, base_url)
Profile of a 31-turn synthetic agent run shows `_needs_thinking_reasoning_pad`
fires 495 times (~16 per turn) and each call ran 3 helper methods, each
hitting `base_url_host_matches` 1-4 times via `urlparse`. Total cost:
3,342 base_url_host_matches calls + 3,373 urlparse calls accounting for
~36ms of agent-loop overhead (~7% of the entire post-network work).
Provider / model / base_url don't change during a conversation except via
`switch_model` and fallback activation — both of which already overwrite
those attributes atomically. Cache the result on a tuple key; since the
key is derived from the very fields that would change, the cache
auto-invalidates on the next read after a switch. No manual invalidation
needed in switch_model / _try_activate_fallback.
Profile A/B (31-turn cached-config agent run):
BEFORE AFTER delta
_needs_thinking_reasoning_pad cum 18ms 1ms -94%
_copy_reasoning_content_for_api cum 17ms 1ms -94%
base_url_host_matches calls 3,342 372 -89%
urlparse calls 3,373 403 -88%
total function calls 296k 223k -25%
Verified:
- tests/run_agent/test_deepseek_reasoning_content_echo.py: 36/36 pass
- tests/run_agent/ (full): 1383/1383 pass + 3 skipped
2026-05-19 14:25:10 -07:00
|
|
|
if ":" in text and '"' in text:
|
|
|
|
|
def _redact_json(m):
|
|
|
|
|
key, value = m.group(1), m.group(2)
|
|
|
|
|
return f'{key}: "{_mask_token(value)}"'
|
|
|
|
|
text = _JSON_FIELD_RE.sub(_redact_json, text)
|
|
|
|
|
|
2026-06-27 04:43:28 -07:00
|
|
|
# Unquoted YAML / colon config: password: *** (after JSON so quoted
|
|
|
|
|
# values are handled there; the lookahead in _YAML_ASSIGN_RE skips
|
|
|
|
|
# quotes). Skip URLs — web-URL query params pass through by design.
|
|
|
|
|
if ":" in text and "://" not in text:
|
|
|
|
|
def _redact_yaml(m):
|
|
|
|
|
key, sep, value = m.group(1), m.group(2), m.group(3)
|
|
|
|
|
return f"{key}{sep}{_mask_token(value)}"
|
|
|
|
|
text = _YAML_ASSIGN_RE.sub(_redact_yaml, text)
|
|
|
|
|
|
2026-06-21 23:20:01 +03:00
|
|
|
# Authorization headers — _AUTH_HEADER_RE matches any scheme after
|
|
|
|
|
# "[Proxy-]Authorization:" case-insensitively, so "uthorization" is the
|
|
|
|
|
# cheapest substring gate that covers every casing without a casefold().
|
perf(agent-loop): cut 47% of per-conversation function calls via 3 targeted hot-path optimizations (#28866)
* perf(config): add load_config_readonly() fast path for hot agent loop
`load_config()` is called from the agent loop's per-API-call hot path via
`get_provider_request_timeout()` and `get_provider_stale_timeout()` —
both invoked once per turn from `_resolved_api_call_timeout()` in
run_agent.py.
Profiling a synthetic 20-tool-call agent run revealed:
- 21 invocations of `load_config()` cumulating 56ms (~17% of agent loop)
- 34,398 deepcopy calls totaling 37ms (config defensive deepcopy + chain)
- 8,652 `_expand_env_vars` invocations (~412 per turn)
Microbench (cache-hit, real config.yaml present):
load_config() 265us/call (125us deepcopy + 140us infra)
load_config_readonly() 138us/call (~48% faster)
`load_config_readonly()` returns the cached dict directly without the
defensive deepcopy. Documented contract: caller must not mutate. Returns
plain dict (not MappingProxyType) so downstream `isinstance(x, dict)`
guards keep working — caught during initial implementation when
MappingProxyType broke get_provider_request_timeout's guard logic.
Wired into hermes_cli/timeouts.py (the two functions called per agent
turn). load_config() is unchanged for the 263 other call sites that
mutate the result before save_config(), are not in the hot path, or
where the safety guarantee matters more than the perf.
Profile A/B (cached config, 21-turn agent loop):
BEFORE AFTER delta
get_provider_request_timeout 55ms 16ms -71%
total function calls 399k 160k -60%
deepcopy calls (in hotspots) 34,398 ~0 ~elim
Verified:
- isinstance(load_config_readonly(), dict) is True
- timeout/stale resolutions correct
- load_config() still returns isolated mutable deepcopies
- tests/hermes_cli/test_config*.py / test_timeouts.py: 102/102 pass
- tests/cli/ + tests/agent/test_auxiliary_client.py: 883/883 pass
* perf(redact): substring pre-screens skip non-matching regex chains
Every log record passes through `RedactingFormatter.format` which calls
`redact_sensitive_text`, which historically ran ALL 13 secret-pattern
regexes against every line — including DB connection strings, JWTs,
Discord mentions, Signal phone numbers, etc. — even for typical clean
log records like 'INFO run_agent: API call completed'.
Add cheap substring pre-checks before each regex pass. False positives
still run the regex (which then matches nothing); false negatives are
impossible because every pattern requires the gated substring to match
its leading anchor:
- `_PREFIX_RE` gated on any of 33 known credential prefix substrings
- `_ENV_ASSIGN_RE` gated on `=` in text
- `_JSON_FIELD_RE` gated on `:` and `"` in text
- `_AUTH_HEADER_RE` gated on `uthorization`/`UTHORIZATION` in text
- `_TELEGRAM_RE` gated on `:` in text
- `_PRIVATE_KEY_RE` gated on `BEGIN` and `-----`
- `_DB_CONNSTR_RE` gated on `://` in text
- `_JWT_RE` gated on `eyJ` in text
- URL userinfo/query gated on `://`
- `_redact_form_body` gated on `&` and `=`
- `_DISCORD_MENTION_RE` gated on `<@`
- `_SIGNAL_PHONE_RE` gated on `+`
Microbench (5 typical log records, 20k iterations each):
BEFORE AFTER delta
redact_sensitive_text per call 5.63us 1.79us -68%
Real-world impact: ~244 log records emitted in a 30-turn agent loop, so
the chain saves ~1ms of CPU per conversation. Bigger win is the
reduction in regex execution and GC pressure during heavy logging
sessions (verbose logging, gateway message processing).
Security regression test: 30 secret-containing inputs (sk-/ghp_/JWT/DB
connstr/Auth-Bearer/private key/URL userinfo/Discord/Signal/etc.)
verified to produce identical redacted output before/after. All 75
existing tests/agent/test_redact.py cases pass.
The `?access_token=foo&code=bar` (bare query string, no scheme) case
that 'leaks' is pre-existing behavior — the URL query redaction
requires a well-formed URL with scheme+host. Not a regression.
* perf(run_agent): cache _needs_thinking_reasoning_pad result per (provider, model, base_url)
Profile of a 31-turn synthetic agent run shows `_needs_thinking_reasoning_pad`
fires 495 times (~16 per turn) and each call ran 3 helper methods, each
hitting `base_url_host_matches` 1-4 times via `urlparse`. Total cost:
3,342 base_url_host_matches calls + 3,373 urlparse calls accounting for
~36ms of agent-loop overhead (~7% of the entire post-network work).
Provider / model / base_url don't change during a conversation except via
`switch_model` and fallback activation — both of which already overwrite
those attributes atomically. Cache the result on a tuple key; since the
key is derived from the very fields that would change, the cache
auto-invalidates on the next read after a switch. No manual invalidation
needed in switch_model / _try_activate_fallback.
Profile A/B (31-turn cached-config agent run):
BEFORE AFTER delta
_needs_thinking_reasoning_pad cum 18ms 1ms -94%
_copy_reasoning_content_for_api cum 17ms 1ms -94%
base_url_host_matches calls 3,342 372 -89%
urlparse calls 3,373 403 -88%
total function calls 296k 223k -25%
Verified:
- tests/run_agent/test_deepseek_reasoning_content_echo.py: 36/36 pass
- tests/run_agent/ (full): 1383/1383 pass + 3 skipped
2026-05-19 14:25:10 -07:00
|
|
|
if "uthorization" in text or "UTHORIZATION" in text:
|
|
|
|
|
text = _AUTH_HEADER_RE.sub(
|
2026-06-21 23:20:01 +03:00
|
|
|
lambda m: m.group(1) + (m.group(2) or "") + _mask_token(m.group(3)),
|
|
|
|
|
text,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# API-key style headers (x-api-key, api-key, …). Header values are
|
|
|
|
|
# colon-separated, so gate on ":" — the regex itself is the precise filter.
|
|
|
|
|
if ":" in text:
|
|
|
|
|
text = _SECRET_HEADER_RE.sub(
|
perf(agent-loop): cut 47% of per-conversation function calls via 3 targeted hot-path optimizations (#28866)
* perf(config): add load_config_readonly() fast path for hot agent loop
`load_config()` is called from the agent loop's per-API-call hot path via
`get_provider_request_timeout()` and `get_provider_stale_timeout()` —
both invoked once per turn from `_resolved_api_call_timeout()` in
run_agent.py.
Profiling a synthetic 20-tool-call agent run revealed:
- 21 invocations of `load_config()` cumulating 56ms (~17% of agent loop)
- 34,398 deepcopy calls totaling 37ms (config defensive deepcopy + chain)
- 8,652 `_expand_env_vars` invocations (~412 per turn)
Microbench (cache-hit, real config.yaml present):
load_config() 265us/call (125us deepcopy + 140us infra)
load_config_readonly() 138us/call (~48% faster)
`load_config_readonly()` returns the cached dict directly without the
defensive deepcopy. Documented contract: caller must not mutate. Returns
plain dict (not MappingProxyType) so downstream `isinstance(x, dict)`
guards keep working — caught during initial implementation when
MappingProxyType broke get_provider_request_timeout's guard logic.
Wired into hermes_cli/timeouts.py (the two functions called per agent
turn). load_config() is unchanged for the 263 other call sites that
mutate the result before save_config(), are not in the hot path, or
where the safety guarantee matters more than the perf.
Profile A/B (cached config, 21-turn agent loop):
BEFORE AFTER delta
get_provider_request_timeout 55ms 16ms -71%
total function calls 399k 160k -60%
deepcopy calls (in hotspots) 34,398 ~0 ~elim
Verified:
- isinstance(load_config_readonly(), dict) is True
- timeout/stale resolutions correct
- load_config() still returns isolated mutable deepcopies
- tests/hermes_cli/test_config*.py / test_timeouts.py: 102/102 pass
- tests/cli/ + tests/agent/test_auxiliary_client.py: 883/883 pass
* perf(redact): substring pre-screens skip non-matching regex chains
Every log record passes through `RedactingFormatter.format` which calls
`redact_sensitive_text`, which historically ran ALL 13 secret-pattern
regexes against every line — including DB connection strings, JWTs,
Discord mentions, Signal phone numbers, etc. — even for typical clean
log records like 'INFO run_agent: API call completed'.
Add cheap substring pre-checks before each regex pass. False positives
still run the regex (which then matches nothing); false negatives are
impossible because every pattern requires the gated substring to match
its leading anchor:
- `_PREFIX_RE` gated on any of 33 known credential prefix substrings
- `_ENV_ASSIGN_RE` gated on `=` in text
- `_JSON_FIELD_RE` gated on `:` and `"` in text
- `_AUTH_HEADER_RE` gated on `uthorization`/`UTHORIZATION` in text
- `_TELEGRAM_RE` gated on `:` in text
- `_PRIVATE_KEY_RE` gated on `BEGIN` and `-----`
- `_DB_CONNSTR_RE` gated on `://` in text
- `_JWT_RE` gated on `eyJ` in text
- URL userinfo/query gated on `://`
- `_redact_form_body` gated on `&` and `=`
- `_DISCORD_MENTION_RE` gated on `<@`
- `_SIGNAL_PHONE_RE` gated on `+`
Microbench (5 typical log records, 20k iterations each):
BEFORE AFTER delta
redact_sensitive_text per call 5.63us 1.79us -68%
Real-world impact: ~244 log records emitted in a 30-turn agent loop, so
the chain saves ~1ms of CPU per conversation. Bigger win is the
reduction in regex execution and GC pressure during heavy logging
sessions (verbose logging, gateway message processing).
Security regression test: 30 secret-containing inputs (sk-/ghp_/JWT/DB
connstr/Auth-Bearer/private key/URL userinfo/Discord/Signal/etc.)
verified to produce identical redacted output before/after. All 75
existing tests/agent/test_redact.py cases pass.
The `?access_token=foo&code=bar` (bare query string, no scheme) case
that 'leaks' is pre-existing behavior — the URL query redaction
requires a well-formed URL with scheme+host. Not a regression.
* perf(run_agent): cache _needs_thinking_reasoning_pad result per (provider, model, base_url)
Profile of a 31-turn synthetic agent run shows `_needs_thinking_reasoning_pad`
fires 495 times (~16 per turn) and each call ran 3 helper methods, each
hitting `base_url_host_matches` 1-4 times via `urlparse`. Total cost:
3,342 base_url_host_matches calls + 3,373 urlparse calls accounting for
~36ms of agent-loop overhead (~7% of the entire post-network work).
Provider / model / base_url don't change during a conversation except via
`switch_model` and fallback activation — both of which already overwrite
those attributes atomically. Cache the result on a tuple key; since the
key is derived from the very fields that would change, the cache
auto-invalidates on the next read after a switch. No manual invalidation
needed in switch_model / _try_activate_fallback.
Profile A/B (31-turn cached-config agent run):
BEFORE AFTER delta
_needs_thinking_reasoning_pad cum 18ms 1ms -94%
_copy_reasoning_content_for_api cum 17ms 1ms -94%
base_url_host_matches calls 3,342 372 -89%
urlparse calls 3,373 403 -88%
total function calls 296k 223k -25%
Verified:
- tests/run_agent/test_deepseek_reasoning_content_echo.py: 36/36 pass
- tests/run_agent/ (full): 1383/1383 pass + 3 skipped
2026-05-19 14:25:10 -07:00
|
|
|
lambda m: m.group(1) + _mask_token(m.group(2)),
|
|
|
|
|
text,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Telegram bot tokens — pattern requires ":<token>" with digits prefix
|
|
|
|
|
if ":" in text:
|
|
|
|
|
def _redact_telegram(m):
|
|
|
|
|
prefix = m.group(1) or ""
|
|
|
|
|
digits = m.group(2)
|
|
|
|
|
return f"{prefix}{digits}:***"
|
|
|
|
|
text = _TELEGRAM_RE.sub(_redact_telegram, text)
|
2026-02-28 21:56:05 -08:00
|
|
|
|
2026-03-09 01:28:27 -07:00
|
|
|
# Private key blocks
|
perf(agent-loop): cut 47% of per-conversation function calls via 3 targeted hot-path optimizations (#28866)
* perf(config): add load_config_readonly() fast path for hot agent loop
`load_config()` is called from the agent loop's per-API-call hot path via
`get_provider_request_timeout()` and `get_provider_stale_timeout()` —
both invoked once per turn from `_resolved_api_call_timeout()` in
run_agent.py.
Profiling a synthetic 20-tool-call agent run revealed:
- 21 invocations of `load_config()` cumulating 56ms (~17% of agent loop)
- 34,398 deepcopy calls totaling 37ms (config defensive deepcopy + chain)
- 8,652 `_expand_env_vars` invocations (~412 per turn)
Microbench (cache-hit, real config.yaml present):
load_config() 265us/call (125us deepcopy + 140us infra)
load_config_readonly() 138us/call (~48% faster)
`load_config_readonly()` returns the cached dict directly without the
defensive deepcopy. Documented contract: caller must not mutate. Returns
plain dict (not MappingProxyType) so downstream `isinstance(x, dict)`
guards keep working — caught during initial implementation when
MappingProxyType broke get_provider_request_timeout's guard logic.
Wired into hermes_cli/timeouts.py (the two functions called per agent
turn). load_config() is unchanged for the 263 other call sites that
mutate the result before save_config(), are not in the hot path, or
where the safety guarantee matters more than the perf.
Profile A/B (cached config, 21-turn agent loop):
BEFORE AFTER delta
get_provider_request_timeout 55ms 16ms -71%
total function calls 399k 160k -60%
deepcopy calls (in hotspots) 34,398 ~0 ~elim
Verified:
- isinstance(load_config_readonly(), dict) is True
- timeout/stale resolutions correct
- load_config() still returns isolated mutable deepcopies
- tests/hermes_cli/test_config*.py / test_timeouts.py: 102/102 pass
- tests/cli/ + tests/agent/test_auxiliary_client.py: 883/883 pass
* perf(redact): substring pre-screens skip non-matching regex chains
Every log record passes through `RedactingFormatter.format` which calls
`redact_sensitive_text`, which historically ran ALL 13 secret-pattern
regexes against every line — including DB connection strings, JWTs,
Discord mentions, Signal phone numbers, etc. — even for typical clean
log records like 'INFO run_agent: API call completed'.
Add cheap substring pre-checks before each regex pass. False positives
still run the regex (which then matches nothing); false negatives are
impossible because every pattern requires the gated substring to match
its leading anchor:
- `_PREFIX_RE` gated on any of 33 known credential prefix substrings
- `_ENV_ASSIGN_RE` gated on `=` in text
- `_JSON_FIELD_RE` gated on `:` and `"` in text
- `_AUTH_HEADER_RE` gated on `uthorization`/`UTHORIZATION` in text
- `_TELEGRAM_RE` gated on `:` in text
- `_PRIVATE_KEY_RE` gated on `BEGIN` and `-----`
- `_DB_CONNSTR_RE` gated on `://` in text
- `_JWT_RE` gated on `eyJ` in text
- URL userinfo/query gated on `://`
- `_redact_form_body` gated on `&` and `=`
- `_DISCORD_MENTION_RE` gated on `<@`
- `_SIGNAL_PHONE_RE` gated on `+`
Microbench (5 typical log records, 20k iterations each):
BEFORE AFTER delta
redact_sensitive_text per call 5.63us 1.79us -68%
Real-world impact: ~244 log records emitted in a 30-turn agent loop, so
the chain saves ~1ms of CPU per conversation. Bigger win is the
reduction in regex execution and GC pressure during heavy logging
sessions (verbose logging, gateway message processing).
Security regression test: 30 secret-containing inputs (sk-/ghp_/JWT/DB
connstr/Auth-Bearer/private key/URL userinfo/Discord/Signal/etc.)
verified to produce identical redacted output before/after. All 75
existing tests/agent/test_redact.py cases pass.
The `?access_token=foo&code=bar` (bare query string, no scheme) case
that 'leaks' is pre-existing behavior — the URL query redaction
requires a well-formed URL with scheme+host. Not a regression.
* perf(run_agent): cache _needs_thinking_reasoning_pad result per (provider, model, base_url)
Profile of a 31-turn synthetic agent run shows `_needs_thinking_reasoning_pad`
fires 495 times (~16 per turn) and each call ran 3 helper methods, each
hitting `base_url_host_matches` 1-4 times via `urlparse`. Total cost:
3,342 base_url_host_matches calls + 3,373 urlparse calls accounting for
~36ms of agent-loop overhead (~7% of the entire post-network work).
Provider / model / base_url don't change during a conversation except via
`switch_model` and fallback activation — both of which already overwrite
those attributes atomically. Cache the result on a tuple key; since the
key is derived from the very fields that would change, the cache
auto-invalidates on the next read after a switch. No manual invalidation
needed in switch_model / _try_activate_fallback.
Profile A/B (31-turn cached-config agent run):
BEFORE AFTER delta
_needs_thinking_reasoning_pad cum 18ms 1ms -94%
_copy_reasoning_content_for_api cum 17ms 1ms -94%
base_url_host_matches calls 3,342 372 -89%
urlparse calls 3,373 403 -88%
total function calls 296k 223k -25%
Verified:
- tests/run_agent/test_deepseek_reasoning_content_echo.py: 36/36 pass
- tests/run_agent/ (full): 1383/1383 pass + 3 skipped
2026-05-19 14:25:10 -07:00
|
|
|
if "BEGIN" in text and "-----" in text:
|
|
|
|
|
text = _PRIVATE_KEY_RE.sub("[REDACTED PRIVATE KEY]", text)
|
2026-03-09 01:28:27 -07:00
|
|
|
|
fix(redact): stop DB-connstr redaction from corrupting code output (#33801) (#54061)
Secret redaction is display/output-scoped on main — write_file writes
content verbatim, terminal/execute_code redact only output not the
command/source. The real bug is in displayed tool OUTPUT (read_file,
terminal, execute_code):
_DB_CONNSTR_RE's password group [^@]+ was greedy across newlines, so on a
multi-line block it scanned past the DSN line to the next stray '@' (a
Python @decorator), replacing every intervening character — including line
breaks — with ***. That dropped lines and concatenated the next line onto
the f-string line, making read_file output look corrupted (the file on disk
was always correct). Reported in #33801.
Fix:
- Forbid whitespace in the userinfo/password groups ([^:\s]+ / [^@\s]+) so
the match can never span a line break. A real DSN password never contains
whitespace. This alone kills the catastrophic line-dropping.
- Under code_file=True, preserve a password group that is a pure {...} brace
expression — f"postgresql://{user}:{pass}@{host}" is an f-string template,
not a live credential. Literal passwords are still masked.
- Pass code_file=True at the terminal and execute_code output redaction call
sites (file_tools already did) so code-execution output isn't corrupted by
ENV/JSON/template false positives. Real prefixes, auth headers, JWTs, and
private keys are still redacted.
Verified E2E against the reporter's exact pydantic-settings module: file
written verbatim, read_file shows the DSN f-string + @model_validator intact
with zero *** corruption, while a literal postgresql://admin:pw@host DSN and
a real sk- key are still masked.
Reported-by: koishi70
Reported-by: pfrenssen
2026-06-28 01:15:39 -07:00
|
|
|
# Database connection string passwords. With code_file=True, a password
|
|
|
|
|
# group that is a pure ``{...}`` brace expression is an f-string template
|
|
|
|
|
# reference (e.g. f"postgresql://{user}:{pass}@{host}"), not a literal
|
|
|
|
|
# credential — preserve it. Literal passwords are still redacted. The regex
|
|
|
|
|
# forbids whitespace in the password group, so a single-line template's
|
|
|
|
|
# group(2) is exactly the brace expression. See issue #33801.
|
perf(agent-loop): cut 47% of per-conversation function calls via 3 targeted hot-path optimizations (#28866)
* perf(config): add load_config_readonly() fast path for hot agent loop
`load_config()` is called from the agent loop's per-API-call hot path via
`get_provider_request_timeout()` and `get_provider_stale_timeout()` —
both invoked once per turn from `_resolved_api_call_timeout()` in
run_agent.py.
Profiling a synthetic 20-tool-call agent run revealed:
- 21 invocations of `load_config()` cumulating 56ms (~17% of agent loop)
- 34,398 deepcopy calls totaling 37ms (config defensive deepcopy + chain)
- 8,652 `_expand_env_vars` invocations (~412 per turn)
Microbench (cache-hit, real config.yaml present):
load_config() 265us/call (125us deepcopy + 140us infra)
load_config_readonly() 138us/call (~48% faster)
`load_config_readonly()` returns the cached dict directly without the
defensive deepcopy. Documented contract: caller must not mutate. Returns
plain dict (not MappingProxyType) so downstream `isinstance(x, dict)`
guards keep working — caught during initial implementation when
MappingProxyType broke get_provider_request_timeout's guard logic.
Wired into hermes_cli/timeouts.py (the two functions called per agent
turn). load_config() is unchanged for the 263 other call sites that
mutate the result before save_config(), are not in the hot path, or
where the safety guarantee matters more than the perf.
Profile A/B (cached config, 21-turn agent loop):
BEFORE AFTER delta
get_provider_request_timeout 55ms 16ms -71%
total function calls 399k 160k -60%
deepcopy calls (in hotspots) 34,398 ~0 ~elim
Verified:
- isinstance(load_config_readonly(), dict) is True
- timeout/stale resolutions correct
- load_config() still returns isolated mutable deepcopies
- tests/hermes_cli/test_config*.py / test_timeouts.py: 102/102 pass
- tests/cli/ + tests/agent/test_auxiliary_client.py: 883/883 pass
* perf(redact): substring pre-screens skip non-matching regex chains
Every log record passes through `RedactingFormatter.format` which calls
`redact_sensitive_text`, which historically ran ALL 13 secret-pattern
regexes against every line — including DB connection strings, JWTs,
Discord mentions, Signal phone numbers, etc. — even for typical clean
log records like 'INFO run_agent: API call completed'.
Add cheap substring pre-checks before each regex pass. False positives
still run the regex (which then matches nothing); false negatives are
impossible because every pattern requires the gated substring to match
its leading anchor:
- `_PREFIX_RE` gated on any of 33 known credential prefix substrings
- `_ENV_ASSIGN_RE` gated on `=` in text
- `_JSON_FIELD_RE` gated on `:` and `"` in text
- `_AUTH_HEADER_RE` gated on `uthorization`/`UTHORIZATION` in text
- `_TELEGRAM_RE` gated on `:` in text
- `_PRIVATE_KEY_RE` gated on `BEGIN` and `-----`
- `_DB_CONNSTR_RE` gated on `://` in text
- `_JWT_RE` gated on `eyJ` in text
- URL userinfo/query gated on `://`
- `_redact_form_body` gated on `&` and `=`
- `_DISCORD_MENTION_RE` gated on `<@`
- `_SIGNAL_PHONE_RE` gated on `+`
Microbench (5 typical log records, 20k iterations each):
BEFORE AFTER delta
redact_sensitive_text per call 5.63us 1.79us -68%
Real-world impact: ~244 log records emitted in a 30-turn agent loop, so
the chain saves ~1ms of CPU per conversation. Bigger win is the
reduction in regex execution and GC pressure during heavy logging
sessions (verbose logging, gateway message processing).
Security regression test: 30 secret-containing inputs (sk-/ghp_/JWT/DB
connstr/Auth-Bearer/private key/URL userinfo/Discord/Signal/etc.)
verified to produce identical redacted output before/after. All 75
existing tests/agent/test_redact.py cases pass.
The `?access_token=foo&code=bar` (bare query string, no scheme) case
that 'leaks' is pre-existing behavior — the URL query redaction
requires a well-formed URL with scheme+host. Not a regression.
* perf(run_agent): cache _needs_thinking_reasoning_pad result per (provider, model, base_url)
Profile of a 31-turn synthetic agent run shows `_needs_thinking_reasoning_pad`
fires 495 times (~16 per turn) and each call ran 3 helper methods, each
hitting `base_url_host_matches` 1-4 times via `urlparse`. Total cost:
3,342 base_url_host_matches calls + 3,373 urlparse calls accounting for
~36ms of agent-loop overhead (~7% of the entire post-network work).
Provider / model / base_url don't change during a conversation except via
`switch_model` and fallback activation — both of which already overwrite
those attributes atomically. Cache the result on a tuple key; since the
key is derived from the very fields that would change, the cache
auto-invalidates on the next read after a switch. No manual invalidation
needed in switch_model / _try_activate_fallback.
Profile A/B (31-turn cached-config agent run):
BEFORE AFTER delta
_needs_thinking_reasoning_pad cum 18ms 1ms -94%
_copy_reasoning_content_for_api cum 17ms 1ms -94%
base_url_host_matches calls 3,342 372 -89%
urlparse calls 3,373 403 -88%
total function calls 296k 223k -25%
Verified:
- tests/run_agent/test_deepseek_reasoning_content_echo.py: 36/36 pass
- tests/run_agent/ (full): 1383/1383 pass + 3 skipped
2026-05-19 14:25:10 -07:00
|
|
|
if "://" in text:
|
fix(redact): stop DB-connstr redaction from corrupting code output (#33801) (#54061)
Secret redaction is display/output-scoped on main — write_file writes
content verbatim, terminal/execute_code redact only output not the
command/source. The real bug is in displayed tool OUTPUT (read_file,
terminal, execute_code):
_DB_CONNSTR_RE's password group [^@]+ was greedy across newlines, so on a
multi-line block it scanned past the DSN line to the next stray '@' (a
Python @decorator), replacing every intervening character — including line
breaks — with ***. That dropped lines and concatenated the next line onto
the f-string line, making read_file output look corrupted (the file on disk
was always correct). Reported in #33801.
Fix:
- Forbid whitespace in the userinfo/password groups ([^:\s]+ / [^@\s]+) so
the match can never span a line break. A real DSN password never contains
whitespace. This alone kills the catastrophic line-dropping.
- Under code_file=True, preserve a password group that is a pure {...} brace
expression — f"postgresql://{user}:{pass}@{host}" is an f-string template,
not a live credential. Literal passwords are still masked.
- Pass code_file=True at the terminal and execute_code output redaction call
sites (file_tools already did) so code-execution output isn't corrupted by
ENV/JSON/template false positives. Real prefixes, auth headers, JWTs, and
private keys are still redacted.
Verified E2E against the reporter's exact pydantic-settings module: file
written verbatim, read_file shows the DSN f-string + @model_validator intact
with zero *** corruption, while a literal postgresql://admin:pw@host DSN and
a real sk- key are still masked.
Reported-by: koishi70
Reported-by: pfrenssen
2026-06-28 01:15:39 -07:00
|
|
|
if code_file:
|
|
|
|
|
def _redact_db(m):
|
|
|
|
|
pw = m.group(2)
|
|
|
|
|
if pw.startswith("{") and pw.endswith("}"):
|
|
|
|
|
return m.group(0)
|
|
|
|
|
return f"{m.group(1)}***{m.group(3)}"
|
|
|
|
|
text = _DB_CONNSTR_RE.sub(_redact_db, text)
|
|
|
|
|
else:
|
|
|
|
|
text = _DB_CONNSTR_RE.sub(lambda m: f"{m.group(1)}***{m.group(3)}", text)
|
2026-03-09 01:28:27 -07:00
|
|
|
|
2026-06-28 18:52:42 -07:00
|
|
|
# Bare-token userinfo in web/transport URLs: ``scheme://TOKEN@host``.
|
|
|
|
|
# The git-remote-with-embedded-password shape from #6396. Only the
|
|
|
|
|
# colon-less bare-token form is redacted — ``user:pass@`` and
|
|
|
|
|
# query-string tokens are left to pass through (see the web-URL note
|
|
|
|
|
# below). See _URL_BARE_TOKEN_RE for the false-positive guards.
|
|
|
|
|
text = _URL_BARE_TOKEN_RE.sub(
|
|
|
|
|
lambda m: f"{m.group(1)}{_mask_token(m.group(2))}{m.group(3)}",
|
|
|
|
|
text,
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-15 16:08:52 -07:00
|
|
|
# JWT tokens (eyJ... — base64-encoded JSON headers)
|
perf(agent-loop): cut 47% of per-conversation function calls via 3 targeted hot-path optimizations (#28866)
* perf(config): add load_config_readonly() fast path for hot agent loop
`load_config()` is called from the agent loop's per-API-call hot path via
`get_provider_request_timeout()` and `get_provider_stale_timeout()` —
both invoked once per turn from `_resolved_api_call_timeout()` in
run_agent.py.
Profiling a synthetic 20-tool-call agent run revealed:
- 21 invocations of `load_config()` cumulating 56ms (~17% of agent loop)
- 34,398 deepcopy calls totaling 37ms (config defensive deepcopy + chain)
- 8,652 `_expand_env_vars` invocations (~412 per turn)
Microbench (cache-hit, real config.yaml present):
load_config() 265us/call (125us deepcopy + 140us infra)
load_config_readonly() 138us/call (~48% faster)
`load_config_readonly()` returns the cached dict directly without the
defensive deepcopy. Documented contract: caller must not mutate. Returns
plain dict (not MappingProxyType) so downstream `isinstance(x, dict)`
guards keep working — caught during initial implementation when
MappingProxyType broke get_provider_request_timeout's guard logic.
Wired into hermes_cli/timeouts.py (the two functions called per agent
turn). load_config() is unchanged for the 263 other call sites that
mutate the result before save_config(), are not in the hot path, or
where the safety guarantee matters more than the perf.
Profile A/B (cached config, 21-turn agent loop):
BEFORE AFTER delta
get_provider_request_timeout 55ms 16ms -71%
total function calls 399k 160k -60%
deepcopy calls (in hotspots) 34,398 ~0 ~elim
Verified:
- isinstance(load_config_readonly(), dict) is True
- timeout/stale resolutions correct
- load_config() still returns isolated mutable deepcopies
- tests/hermes_cli/test_config*.py / test_timeouts.py: 102/102 pass
- tests/cli/ + tests/agent/test_auxiliary_client.py: 883/883 pass
* perf(redact): substring pre-screens skip non-matching regex chains
Every log record passes through `RedactingFormatter.format` which calls
`redact_sensitive_text`, which historically ran ALL 13 secret-pattern
regexes against every line — including DB connection strings, JWTs,
Discord mentions, Signal phone numbers, etc. — even for typical clean
log records like 'INFO run_agent: API call completed'.
Add cheap substring pre-checks before each regex pass. False positives
still run the regex (which then matches nothing); false negatives are
impossible because every pattern requires the gated substring to match
its leading anchor:
- `_PREFIX_RE` gated on any of 33 known credential prefix substrings
- `_ENV_ASSIGN_RE` gated on `=` in text
- `_JSON_FIELD_RE` gated on `:` and `"` in text
- `_AUTH_HEADER_RE` gated on `uthorization`/`UTHORIZATION` in text
- `_TELEGRAM_RE` gated on `:` in text
- `_PRIVATE_KEY_RE` gated on `BEGIN` and `-----`
- `_DB_CONNSTR_RE` gated on `://` in text
- `_JWT_RE` gated on `eyJ` in text
- URL userinfo/query gated on `://`
- `_redact_form_body` gated on `&` and `=`
- `_DISCORD_MENTION_RE` gated on `<@`
- `_SIGNAL_PHONE_RE` gated on `+`
Microbench (5 typical log records, 20k iterations each):
BEFORE AFTER delta
redact_sensitive_text per call 5.63us 1.79us -68%
Real-world impact: ~244 log records emitted in a 30-turn agent loop, so
the chain saves ~1ms of CPU per conversation. Bigger win is the
reduction in regex execution and GC pressure during heavy logging
sessions (verbose logging, gateway message processing).
Security regression test: 30 secret-containing inputs (sk-/ghp_/JWT/DB
connstr/Auth-Bearer/private key/URL userinfo/Discord/Signal/etc.)
verified to produce identical redacted output before/after. All 75
existing tests/agent/test_redact.py cases pass.
The `?access_token=foo&code=bar` (bare query string, no scheme) case
that 'leaks' is pre-existing behavior — the URL query redaction
requires a well-formed URL with scheme+host. Not a regression.
* perf(run_agent): cache _needs_thinking_reasoning_pad result per (provider, model, base_url)
Profile of a 31-turn synthetic agent run shows `_needs_thinking_reasoning_pad`
fires 495 times (~16 per turn) and each call ran 3 helper methods, each
hitting `base_url_host_matches` 1-4 times via `urlparse`. Total cost:
3,342 base_url_host_matches calls + 3,373 urlparse calls accounting for
~36ms of agent-loop overhead (~7% of the entire post-network work).
Provider / model / base_url don't change during a conversation except via
`switch_model` and fallback activation — both of which already overwrite
those attributes atomically. Cache the result on a tuple key; since the
key is derived from the very fields that would change, the cache
auto-invalidates on the next read after a switch. No manual invalidation
needed in switch_model / _try_activate_fallback.
Profile A/B (31-turn cached-config agent run):
BEFORE AFTER delta
_needs_thinking_reasoning_pad cum 18ms 1ms -94%
_copy_reasoning_content_for_api cum 17ms 1ms -94%
base_url_host_matches calls 3,342 372 -89%
urlparse calls 3,373 403 -88%
total function calls 296k 223k -25%
Verified:
- tests/run_agent/test_deepseek_reasoning_content_echo.py: 36/36 pass
- tests/run_agent/ (full): 1383/1383 pass + 3 skipped
2026-05-19 14:25:10 -07:00
|
|
|
if "eyJ" in text:
|
|
|
|
|
text = _JWT_RE.sub(lambda m: _mask_token(m.group(0)), text)
|
2026-04-15 16:08:52 -07:00
|
|
|
|
fix(redact): pass web URLs through unchanged (#34029)
* fix(redact): pass web URLs through unchanged
Magic-link checkout URLs, OAuth callbacks the agent is meant to follow,
and pre-signed share URLs were getting `?token=***` / `?code=***` /
`?signature=***` blanket-redacted by parameter NAME, which breaks any
skill that has to round-trip a URL through history (the model's tool
call arguments get sanitized before persistence — the live call fires
with the real URL, but the next turn sees `***`).
Joe Rinaldi Johnson hit this with a checkout-acceleration skill that
uses magic links in URLs.
Drops three call sites from `redact_sensitive_text`:
- `_redact_url_query_params` (was redacting `access_token`, `token`,
`api_key`, `code`, `signature`, `key`, `auth`, etc.)
- `_redact_url_userinfo` (was redacting `https://user:pass@host`)
- `_redact_http_request_target_query_params` (was redacting access-log
request targets like `"POST /hook?password=... HTTP/1.1"`)
The helpers themselves are kept in the module — still importable by
anything that wants to opt in explicitly.
Still redacted (unchanged):
- Vendor-prefix credential shapes (sk-, ghp_, AKIA, gAAAA, etc.)
anywhere they appear, including inside URLs — see the
`test_known_prefix_inside_url_still_redacted` case.
- JWTs (`eyJ...`)
- DB connection-string passwords (`postgres://admin:pw@host`) —
these are connection strings, not web URLs the agent navigates to.
- Authorization headers, ENV assignments, JSON `apiKey`/`token` fields,
Telegram bot tokens, private key blocks, Discord mentions, E.164
phone numbers, and form-urlencoded bodies (request bodies, not URLs).
Tests: replaces `TestUrlQueryParamRedaction` + `TestUrlUserinfoRedaction`
with `TestWebUrlsNotRedacted`, asserting representative URLs (OAuth
callback, magic link, S3 pre-signed, websocket, userinfo, access log)
pass through unchanged. Adds positive cases proving the prefix and DB
connstr nets still fire. 74 redact tests + 10 browser-exfil + 16 PII
redaction tests all pass.
* test(codex_app_server): drop URL-query assertion from stderr-tail redaction test
The test bundled (a) sk-live-* credential-prefix redaction with (b)
URL query-param redaction. (a) is still in effect via _PREFIX_RE;
(b) was the contract we just removed in the parent commit so the
'querysecret12345' assertion stopped holding. Keep the credential-shape
assertion, drop the URL-query one.
Send-message tool's local _URL_SECRET_QUERY_RE in tools/send_message_tool.py
is independent of agent/redact.py and unchanged — its tests
(test_top_level_send_failure_redacts_query_token,
test_http_error_redacts_access_token_in_exception_text) still pass.
2026-05-28 11:32:39 -07:00
|
|
|
# NOTE: Web-URL redaction (query params + userinfo + HTTP access-log
|
|
|
|
|
# request targets) is intentionally OFF. Many legitimate workflows pass
|
|
|
|
|
# opaque tokens through query strings — magic-link checkouts, OAuth
|
|
|
|
|
# callbacks the agent is meant to follow, pre-signed share URLs — and
|
|
|
|
|
# blanket-redacting param values by name breaks those skills mid-flow.
|
|
|
|
|
# Known credential shapes (sk-, ghp_, JWTs, etc.) inside URLs are still
|
|
|
|
|
# caught by _PREFIX_RE and _JWT_RE above. DB connection-string passwords
|
2026-06-28 18:52:42 -07:00
|
|
|
# are still caught by _DB_CONNSTR_RE. The ONE userinfo case still redacted
|
|
|
|
|
# is the colon-less bare-token form ``scheme://TOKEN@host`` (#6396, handled
|
|
|
|
|
# by _URL_BARE_TOKEN_RE in the ``://`` block above): a bare credential in
|
|
|
|
|
# userinfo is never a round-trip workflow token (those live in the query
|
|
|
|
|
# string), so masking it can't break a skill. The ``user:pass@`` form is
|
|
|
|
|
# left to pass through per #34029.
|
2026-05-24 16:04:39 -06:00
|
|
|
|
feat: replace kimi-k2.5 with kimi-k2.6 on OpenRouter and Nous Portal (#13148)
* feat(security): URL query param + userinfo + form body redaction
Port from nearai/ironclaw#2529.
Hermes already has broad value-shape coverage in agent/redact.py
(30+ vendor prefixes, JWTs, DB connstrs, etc.) but missed three
key-name-based patterns that catch opaque tokens without recognizable
prefixes:
1. URL query params - OAuth callback codes (?code=...),
access_token, refresh_token, signature, etc. These are opaque and
won't match any prefix regex. Now redacted by parameter NAME.
2. URL userinfo (https://user:pass@host) - for non-DB schemes. DB
schemes were already handled by _DB_CONNSTR_RE.
3. Form-urlencoded body (k=v pairs joined by ampersands) -
conservative, only triggers on clean pure-form inputs with no
other text.
Sensitive key allowlist matches ironclaw's (exact case-insensitive,
NOT substring - so token_count and session_id pass through).
Tests: +20 new test cases across 3 test classes. All 75 redact tests
pass; gateway/test_pii_redaction and tools/test_browser_secret_exfil
also green.
Known pre-existing limitation: _ENV_ASSIGN_RE greedy match swallows
whole all-caps ENV-style names + trailing text when followed by
another assignment. Left untouched here (out of scope); URL query
redaction handles the lowercase case.
* feat: replace kimi-k2.5 with kimi-k2.6 on OpenRouter and Nous Portal
Update model catalogs for OpenRouter (fallback snapshot), Nous Portal,
and NVIDIA NIM to reference moonshotai/kimi-k2.6. Add kimi-k2.6 to
the fixed-temperature frozenset in auxiliary_client.py so the 0.6
contract is enforced on aggregator routings.
Native Moonshot provider lists (kimi-coding, kimi-coding-cn, moonshot,
opencode-zen, opencode-go) are unchanged — those use Moonshot's own
model IDs which are unaffected.
2026-04-20 11:49:54 -07:00
|
|
|
# Form-urlencoded bodies (only triggers on clean k=v&k=v inputs).
|
perf(agent-loop): cut 47% of per-conversation function calls via 3 targeted hot-path optimizations (#28866)
* perf(config): add load_config_readonly() fast path for hot agent loop
`load_config()` is called from the agent loop's per-API-call hot path via
`get_provider_request_timeout()` and `get_provider_stale_timeout()` —
both invoked once per turn from `_resolved_api_call_timeout()` in
run_agent.py.
Profiling a synthetic 20-tool-call agent run revealed:
- 21 invocations of `load_config()` cumulating 56ms (~17% of agent loop)
- 34,398 deepcopy calls totaling 37ms (config defensive deepcopy + chain)
- 8,652 `_expand_env_vars` invocations (~412 per turn)
Microbench (cache-hit, real config.yaml present):
load_config() 265us/call (125us deepcopy + 140us infra)
load_config_readonly() 138us/call (~48% faster)
`load_config_readonly()` returns the cached dict directly without the
defensive deepcopy. Documented contract: caller must not mutate. Returns
plain dict (not MappingProxyType) so downstream `isinstance(x, dict)`
guards keep working — caught during initial implementation when
MappingProxyType broke get_provider_request_timeout's guard logic.
Wired into hermes_cli/timeouts.py (the two functions called per agent
turn). load_config() is unchanged for the 263 other call sites that
mutate the result before save_config(), are not in the hot path, or
where the safety guarantee matters more than the perf.
Profile A/B (cached config, 21-turn agent loop):
BEFORE AFTER delta
get_provider_request_timeout 55ms 16ms -71%
total function calls 399k 160k -60%
deepcopy calls (in hotspots) 34,398 ~0 ~elim
Verified:
- isinstance(load_config_readonly(), dict) is True
- timeout/stale resolutions correct
- load_config() still returns isolated mutable deepcopies
- tests/hermes_cli/test_config*.py / test_timeouts.py: 102/102 pass
- tests/cli/ + tests/agent/test_auxiliary_client.py: 883/883 pass
* perf(redact): substring pre-screens skip non-matching regex chains
Every log record passes through `RedactingFormatter.format` which calls
`redact_sensitive_text`, which historically ran ALL 13 secret-pattern
regexes against every line — including DB connection strings, JWTs,
Discord mentions, Signal phone numbers, etc. — even for typical clean
log records like 'INFO run_agent: API call completed'.
Add cheap substring pre-checks before each regex pass. False positives
still run the regex (which then matches nothing); false negatives are
impossible because every pattern requires the gated substring to match
its leading anchor:
- `_PREFIX_RE` gated on any of 33 known credential prefix substrings
- `_ENV_ASSIGN_RE` gated on `=` in text
- `_JSON_FIELD_RE` gated on `:` and `"` in text
- `_AUTH_HEADER_RE` gated on `uthorization`/`UTHORIZATION` in text
- `_TELEGRAM_RE` gated on `:` in text
- `_PRIVATE_KEY_RE` gated on `BEGIN` and `-----`
- `_DB_CONNSTR_RE` gated on `://` in text
- `_JWT_RE` gated on `eyJ` in text
- URL userinfo/query gated on `://`
- `_redact_form_body` gated on `&` and `=`
- `_DISCORD_MENTION_RE` gated on `<@`
- `_SIGNAL_PHONE_RE` gated on `+`
Microbench (5 typical log records, 20k iterations each):
BEFORE AFTER delta
redact_sensitive_text per call 5.63us 1.79us -68%
Real-world impact: ~244 log records emitted in a 30-turn agent loop, so
the chain saves ~1ms of CPU per conversation. Bigger win is the
reduction in regex execution and GC pressure during heavy logging
sessions (verbose logging, gateway message processing).
Security regression test: 30 secret-containing inputs (sk-/ghp_/JWT/DB
connstr/Auth-Bearer/private key/URL userinfo/Discord/Signal/etc.)
verified to produce identical redacted output before/after. All 75
existing tests/agent/test_redact.py cases pass.
The `?access_token=foo&code=bar` (bare query string, no scheme) case
that 'leaks' is pre-existing behavior — the URL query redaction
requires a well-formed URL with scheme+host. Not a regression.
* perf(run_agent): cache _needs_thinking_reasoning_pad result per (provider, model, base_url)
Profile of a 31-turn synthetic agent run shows `_needs_thinking_reasoning_pad`
fires 495 times (~16 per turn) and each call ran 3 helper methods, each
hitting `base_url_host_matches` 1-4 times via `urlparse`. Total cost:
3,342 base_url_host_matches calls + 3,373 urlparse calls accounting for
~36ms of agent-loop overhead (~7% of the entire post-network work).
Provider / model / base_url don't change during a conversation except via
`switch_model` and fallback activation — both of which already overwrite
those attributes atomically. Cache the result on a tuple key; since the
key is derived from the very fields that would change, the cache
auto-invalidates on the next read after a switch. No manual invalidation
needed in switch_model / _try_activate_fallback.
Profile A/B (31-turn cached-config agent run):
BEFORE AFTER delta
_needs_thinking_reasoning_pad cum 18ms 1ms -94%
_copy_reasoning_content_for_api cum 17ms 1ms -94%
base_url_host_matches calls 3,342 372 -89%
urlparse calls 3,373 403 -88%
total function calls 296k 223k -25%
Verified:
- tests/run_agent/test_deepseek_reasoning_content_echo.py: 36/36 pass
- tests/run_agent/ (full): 1383/1383 pass + 3 skipped
2026-05-19 14:25:10 -07:00
|
|
|
if "&" in text and "=" in text:
|
|
|
|
|
text = _redact_form_body(text)
|
feat: replace kimi-k2.5 with kimi-k2.6 on OpenRouter and Nous Portal (#13148)
* feat(security): URL query param + userinfo + form body redaction
Port from nearai/ironclaw#2529.
Hermes already has broad value-shape coverage in agent/redact.py
(30+ vendor prefixes, JWTs, DB connstrs, etc.) but missed three
key-name-based patterns that catch opaque tokens without recognizable
prefixes:
1. URL query params - OAuth callback codes (?code=...),
access_token, refresh_token, signature, etc. These are opaque and
won't match any prefix regex. Now redacted by parameter NAME.
2. URL userinfo (https://user:pass@host) - for non-DB schemes. DB
schemes were already handled by _DB_CONNSTR_RE.
3. Form-urlencoded body (k=v pairs joined by ampersands) -
conservative, only triggers on clean pure-form inputs with no
other text.
Sensitive key allowlist matches ironclaw's (exact case-insensitive,
NOT substring - so token_count and session_id pass through).
Tests: +20 new test cases across 3 test classes. All 75 redact tests
pass; gateway/test_pii_redaction and tools/test_browser_secret_exfil
also green.
Known pre-existing limitation: _ENV_ASSIGN_RE greedy match swallows
whole all-caps ENV-style names + trailing text when followed by
another assignment. Left untouched here (out of scope); URL query
redaction handles the lowercase case.
* feat: replace kimi-k2.5 with kimi-k2.6 on OpenRouter and Nous Portal
Update model catalogs for OpenRouter (fallback snapshot), Nous Portal,
and NVIDIA NIM to reference moonshotai/kimi-k2.6. Add kimi-k2.6 to
the fixed-temperature frozenset in auxiliary_client.py so the 0.6
contract is enforced on aggregator routings.
Native Moonshot provider lists (kimi-coding, kimi-coding-cn, moonshot,
opencode-zen, opencode-go) are unchanged — those use Moonshot's own
model IDs which are unaffected.
2026-04-20 11:49:54 -07:00
|
|
|
|
feat: add Signal messenger gateway platform (#405)
Complete Signal adapter using signal-cli daemon HTTP API.
Based on PR #268 by ibhagwan, rebuilt on current main with bug fixes.
Architecture:
- SSE streaming for inbound messages with exponential backoff (2s→60s)
- JSON-RPC 2.0 for outbound (send, typing, attachments, contacts)
- Health monitor detects stale SSE connections (120s threshold)
- Phone number redaction in all logs and global redact.py
Features:
- DM and group message support with separate access policies
- DM policies: pairing (default), allowlist, open
- Group policies: disabled (default), allowlist, open
- Attachment download with magic-byte type detection
- Typing indicators (8s refresh interval)
- 100MB attachment size limit, 8000 char message limit
- E.164 phone + UUID allowlist support
Integration:
- Platform.SIGNAL enum in gateway/config.py
- Signal in _is_user_authorized() allowlist maps (gateway/run.py)
- Adapter factory in _create_adapter() (gateway/run.py)
- user_id_alt/chat_id_alt fields in SessionSource for UUIDs
- send_message tool support via httpx JSON-RPC (not aiohttp)
- Interactive setup wizard in 'hermes gateway setup'
- Connectivity testing during setup (pings /api/v1/check)
- signal-cli detection and install guidance
Bug fixes from PR #268:
- Timestamp reads from envelope_data (not outer wrapper)
- Uses httpx consistently (not aiohttp in send_message tool)
- SIGNAL_DEBUG scoped to signal logger (not root)
- extract_images regex NOT modified (preserves group numbering)
- pairing.py NOT modified (no cross-platform side effects)
- No dual authorization (adapter defers to run.py for user auth)
- Wildcard uses set membership ('*' in set, not list equality)
- .zip default for PK magic bytes (not .docx)
No new Python dependencies — uses httpx (already core).
External requirement: signal-cli daemon (user-installed).
Tests: 30 new tests covering config, init, helpers, session source,
phone redaction, authorization, and send_message integration.
Co-authored-by: ibhagwan <ibhagwan@users.noreply.github.com>
2026-03-08 20:20:35 -07:00
|
|
|
# E.164 phone numbers (Signal, WhatsApp)
|
perf(agent-loop): cut 47% of per-conversation function calls via 3 targeted hot-path optimizations (#28866)
* perf(config): add load_config_readonly() fast path for hot agent loop
`load_config()` is called from the agent loop's per-API-call hot path via
`get_provider_request_timeout()` and `get_provider_stale_timeout()` —
both invoked once per turn from `_resolved_api_call_timeout()` in
run_agent.py.
Profiling a synthetic 20-tool-call agent run revealed:
- 21 invocations of `load_config()` cumulating 56ms (~17% of agent loop)
- 34,398 deepcopy calls totaling 37ms (config defensive deepcopy + chain)
- 8,652 `_expand_env_vars` invocations (~412 per turn)
Microbench (cache-hit, real config.yaml present):
load_config() 265us/call (125us deepcopy + 140us infra)
load_config_readonly() 138us/call (~48% faster)
`load_config_readonly()` returns the cached dict directly without the
defensive deepcopy. Documented contract: caller must not mutate. Returns
plain dict (not MappingProxyType) so downstream `isinstance(x, dict)`
guards keep working — caught during initial implementation when
MappingProxyType broke get_provider_request_timeout's guard logic.
Wired into hermes_cli/timeouts.py (the two functions called per agent
turn). load_config() is unchanged for the 263 other call sites that
mutate the result before save_config(), are not in the hot path, or
where the safety guarantee matters more than the perf.
Profile A/B (cached config, 21-turn agent loop):
BEFORE AFTER delta
get_provider_request_timeout 55ms 16ms -71%
total function calls 399k 160k -60%
deepcopy calls (in hotspots) 34,398 ~0 ~elim
Verified:
- isinstance(load_config_readonly(), dict) is True
- timeout/stale resolutions correct
- load_config() still returns isolated mutable deepcopies
- tests/hermes_cli/test_config*.py / test_timeouts.py: 102/102 pass
- tests/cli/ + tests/agent/test_auxiliary_client.py: 883/883 pass
* perf(redact): substring pre-screens skip non-matching regex chains
Every log record passes through `RedactingFormatter.format` which calls
`redact_sensitive_text`, which historically ran ALL 13 secret-pattern
regexes against every line — including DB connection strings, JWTs,
Discord mentions, Signal phone numbers, etc. — even for typical clean
log records like 'INFO run_agent: API call completed'.
Add cheap substring pre-checks before each regex pass. False positives
still run the regex (which then matches nothing); false negatives are
impossible because every pattern requires the gated substring to match
its leading anchor:
- `_PREFIX_RE` gated on any of 33 known credential prefix substrings
- `_ENV_ASSIGN_RE` gated on `=` in text
- `_JSON_FIELD_RE` gated on `:` and `"` in text
- `_AUTH_HEADER_RE` gated on `uthorization`/`UTHORIZATION` in text
- `_TELEGRAM_RE` gated on `:` in text
- `_PRIVATE_KEY_RE` gated on `BEGIN` and `-----`
- `_DB_CONNSTR_RE` gated on `://` in text
- `_JWT_RE` gated on `eyJ` in text
- URL userinfo/query gated on `://`
- `_redact_form_body` gated on `&` and `=`
- `_DISCORD_MENTION_RE` gated on `<@`
- `_SIGNAL_PHONE_RE` gated on `+`
Microbench (5 typical log records, 20k iterations each):
BEFORE AFTER delta
redact_sensitive_text per call 5.63us 1.79us -68%
Real-world impact: ~244 log records emitted in a 30-turn agent loop, so
the chain saves ~1ms of CPU per conversation. Bigger win is the
reduction in regex execution and GC pressure during heavy logging
sessions (verbose logging, gateway message processing).
Security regression test: 30 secret-containing inputs (sk-/ghp_/JWT/DB
connstr/Auth-Bearer/private key/URL userinfo/Discord/Signal/etc.)
verified to produce identical redacted output before/after. All 75
existing tests/agent/test_redact.py cases pass.
The `?access_token=foo&code=bar` (bare query string, no scheme) case
that 'leaks' is pre-existing behavior — the URL query redaction
requires a well-formed URL with scheme+host. Not a regression.
* perf(run_agent): cache _needs_thinking_reasoning_pad result per (provider, model, base_url)
Profile of a 31-turn synthetic agent run shows `_needs_thinking_reasoning_pad`
fires 495 times (~16 per turn) and each call ran 3 helper methods, each
hitting `base_url_host_matches` 1-4 times via `urlparse`. Total cost:
3,342 base_url_host_matches calls + 3,373 urlparse calls accounting for
~36ms of agent-loop overhead (~7% of the entire post-network work).
Provider / model / base_url don't change during a conversation except via
`switch_model` and fallback activation — both of which already overwrite
those attributes atomically. Cache the result on a tuple key; since the
key is derived from the very fields that would change, the cache
auto-invalidates on the next read after a switch. No manual invalidation
needed in switch_model / _try_activate_fallback.
Profile A/B (31-turn cached-config agent run):
BEFORE AFTER delta
_needs_thinking_reasoning_pad cum 18ms 1ms -94%
_copy_reasoning_content_for_api cum 17ms 1ms -94%
base_url_host_matches calls 3,342 372 -89%
urlparse calls 3,373 403 -88%
total function calls 296k 223k -25%
Verified:
- tests/run_agent/test_deepseek_reasoning_content_echo.py: 36/36 pass
- tests/run_agent/ (full): 1383/1383 pass + 3 skipped
2026-05-19 14:25:10 -07:00
|
|
|
if "+" in text:
|
|
|
|
|
def _redact_phone(m):
|
|
|
|
|
phone = m.group(1)
|
|
|
|
|
if len(phone) <= 8:
|
|
|
|
|
return phone[:2] + "****" + phone[-2:]
|
|
|
|
|
return phone[:4] + "****" + phone[-4:]
|
|
|
|
|
text = _SIGNAL_PHONE_RE.sub(_redact_phone, text)
|
feat: add Signal messenger gateway platform (#405)
Complete Signal adapter using signal-cli daemon HTTP API.
Based on PR #268 by ibhagwan, rebuilt on current main with bug fixes.
Architecture:
- SSE streaming for inbound messages with exponential backoff (2s→60s)
- JSON-RPC 2.0 for outbound (send, typing, attachments, contacts)
- Health monitor detects stale SSE connections (120s threshold)
- Phone number redaction in all logs and global redact.py
Features:
- DM and group message support with separate access policies
- DM policies: pairing (default), allowlist, open
- Group policies: disabled (default), allowlist, open
- Attachment download with magic-byte type detection
- Typing indicators (8s refresh interval)
- 100MB attachment size limit, 8000 char message limit
- E.164 phone + UUID allowlist support
Integration:
- Platform.SIGNAL enum in gateway/config.py
- Signal in _is_user_authorized() allowlist maps (gateway/run.py)
- Adapter factory in _create_adapter() (gateway/run.py)
- user_id_alt/chat_id_alt fields in SessionSource for UUIDs
- send_message tool support via httpx JSON-RPC (not aiohttp)
- Interactive setup wizard in 'hermes gateway setup'
- Connectivity testing during setup (pings /api/v1/check)
- signal-cli detection and install guidance
Bug fixes from PR #268:
- Timestamp reads from envelope_data (not outer wrapper)
- Uses httpx consistently (not aiohttp in send_message tool)
- SIGNAL_DEBUG scoped to signal logger (not root)
- extract_images regex NOT modified (preserves group numbering)
- pairing.py NOT modified (no cross-platform side effects)
- No dual authorization (adapter defers to run.py for user auth)
- Wildcard uses set membership ('*' in set, not list equality)
- .zip default for PK magic bytes (not .docx)
No new Python dependencies — uses httpx (already core).
External requirement: signal-cli daemon (user-installed).
Tests: 30 new tests covering config, init, helpers, session source,
phone redaction, authorization, and send_message integration.
Co-authored-by: ibhagwan <ibhagwan@users.noreply.github.com>
2026-03-08 20:20:35 -07:00
|
|
|
|
2026-02-28 21:56:05 -08:00
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
2026-06-28 02:44:21 -07:00
|
|
|
# Commands whose stdout is an environment-variable dump (KEY=value lines),
|
|
|
|
|
# NOT source code. For these, terminal-output redaction must run the
|
|
|
|
|
# ENV-assignment pass (code_file=False) so opaque tokens with no recognized
|
|
|
|
|
# vendor prefix (e.g. ``MY_SERVICE_TOKEN=abc123randomstring``) are still
|
|
|
|
|
# masked. For all other commands, code_file=True is used to avoid mangling
|
|
|
|
|
# legitimate source/config dumps (``MAX_TOKENS=100``, ``"apiKey": "x"``
|
|
|
|
|
# fixtures, ``postgresql://{user}`` f-string templates). See issue #43025.
|
|
|
|
|
_ENV_DUMP_COMMANDS = frozenset({"env", "printenv", "set", "export", "declare"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_env_dump_command(command: str | None) -> bool:
|
|
|
|
|
"""Return True if ``command`` dumps environment variables to stdout.
|
|
|
|
|
|
|
|
|
|
Detects ``env`` / ``printenv`` / ``set`` / ``export`` / ``declare`` as the
|
|
|
|
|
first token of any segment in a pipeline or sequence (``;`` / ``&&`` /
|
|
|
|
|
``||`` / ``|``). Conservative: a parse failure or anything unrecognized
|
|
|
|
|
returns False (callers then fall back to the safer code_file=True path,
|
|
|
|
|
which still masks prefix-shaped keys).
|
|
|
|
|
"""
|
|
|
|
|
if not command or not isinstance(command, str):
|
|
|
|
|
return False
|
|
|
|
|
# Split on shell separators, then inspect the first token of each segment.
|
|
|
|
|
segments = re.split(r"[|;&]+", command)
|
|
|
|
|
for seg in segments:
|
|
|
|
|
seg = seg.strip()
|
|
|
|
|
if not seg:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
tokens = shlex.split(seg)
|
|
|
|
|
except ValueError:
|
|
|
|
|
tokens = seg.split()
|
|
|
|
|
if tokens and tokens[0] in _ENV_DUMP_COMMANDS:
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def redact_terminal_output(
|
|
|
|
|
output: str, command: str | None = None, *, force: bool = False
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Redact secrets from terminal/process stdout.
|
|
|
|
|
|
|
|
|
|
Single redaction policy for ALL terminal-output surfaces — foreground
|
|
|
|
|
``terminal`` results AND background ``process(action=poll/log/wait)``
|
|
|
|
|
output — so they can't diverge. Picks ``code_file`` based on whether
|
|
|
|
|
``command`` is an environment dump:
|
|
|
|
|
|
|
|
|
|
- env-dump command (``env``/``printenv``/``set``/``export``/``declare``)
|
|
|
|
|
→ ``code_file=False`` so the ENV-assignment pass masks opaque tokens.
|
|
|
|
|
- anything else (or unknown command) → ``code_file=True`` to avoid
|
|
|
|
|
false positives on source/config dumps.
|
|
|
|
|
|
|
|
|
|
``force=True`` bypasses the global ``security.redact_secrets`` preference
|
|
|
|
|
for safety boundaries that must never emit raw credentials.
|
|
|
|
|
"""
|
|
|
|
|
if not output:
|
|
|
|
|
return output
|
|
|
|
|
code_file = not is_env_dump_command(command or "")
|
|
|
|
|
return redact_sensitive_text(output, force=force, code_file=code_file)
|
|
|
|
|
|
|
|
|
|
|
perf(agent-loop): cut 47% of per-conversation function calls via 3 targeted hot-path optimizations (#28866)
* perf(config): add load_config_readonly() fast path for hot agent loop
`load_config()` is called from the agent loop's per-API-call hot path via
`get_provider_request_timeout()` and `get_provider_stale_timeout()` —
both invoked once per turn from `_resolved_api_call_timeout()` in
run_agent.py.
Profiling a synthetic 20-tool-call agent run revealed:
- 21 invocations of `load_config()` cumulating 56ms (~17% of agent loop)
- 34,398 deepcopy calls totaling 37ms (config defensive deepcopy + chain)
- 8,652 `_expand_env_vars` invocations (~412 per turn)
Microbench (cache-hit, real config.yaml present):
load_config() 265us/call (125us deepcopy + 140us infra)
load_config_readonly() 138us/call (~48% faster)
`load_config_readonly()` returns the cached dict directly without the
defensive deepcopy. Documented contract: caller must not mutate. Returns
plain dict (not MappingProxyType) so downstream `isinstance(x, dict)`
guards keep working — caught during initial implementation when
MappingProxyType broke get_provider_request_timeout's guard logic.
Wired into hermes_cli/timeouts.py (the two functions called per agent
turn). load_config() is unchanged for the 263 other call sites that
mutate the result before save_config(), are not in the hot path, or
where the safety guarantee matters more than the perf.
Profile A/B (cached config, 21-turn agent loop):
BEFORE AFTER delta
get_provider_request_timeout 55ms 16ms -71%
total function calls 399k 160k -60%
deepcopy calls (in hotspots) 34,398 ~0 ~elim
Verified:
- isinstance(load_config_readonly(), dict) is True
- timeout/stale resolutions correct
- load_config() still returns isolated mutable deepcopies
- tests/hermes_cli/test_config*.py / test_timeouts.py: 102/102 pass
- tests/cli/ + tests/agent/test_auxiliary_client.py: 883/883 pass
* perf(redact): substring pre-screens skip non-matching regex chains
Every log record passes through `RedactingFormatter.format` which calls
`redact_sensitive_text`, which historically ran ALL 13 secret-pattern
regexes against every line — including DB connection strings, JWTs,
Discord mentions, Signal phone numbers, etc. — even for typical clean
log records like 'INFO run_agent: API call completed'.
Add cheap substring pre-checks before each regex pass. False positives
still run the regex (which then matches nothing); false negatives are
impossible because every pattern requires the gated substring to match
its leading anchor:
- `_PREFIX_RE` gated on any of 33 known credential prefix substrings
- `_ENV_ASSIGN_RE` gated on `=` in text
- `_JSON_FIELD_RE` gated on `:` and `"` in text
- `_AUTH_HEADER_RE` gated on `uthorization`/`UTHORIZATION` in text
- `_TELEGRAM_RE` gated on `:` in text
- `_PRIVATE_KEY_RE` gated on `BEGIN` and `-----`
- `_DB_CONNSTR_RE` gated on `://` in text
- `_JWT_RE` gated on `eyJ` in text
- URL userinfo/query gated on `://`
- `_redact_form_body` gated on `&` and `=`
- `_DISCORD_MENTION_RE` gated on `<@`
- `_SIGNAL_PHONE_RE` gated on `+`
Microbench (5 typical log records, 20k iterations each):
BEFORE AFTER delta
redact_sensitive_text per call 5.63us 1.79us -68%
Real-world impact: ~244 log records emitted in a 30-turn agent loop, so
the chain saves ~1ms of CPU per conversation. Bigger win is the
reduction in regex execution and GC pressure during heavy logging
sessions (verbose logging, gateway message processing).
Security regression test: 30 secret-containing inputs (sk-/ghp_/JWT/DB
connstr/Auth-Bearer/private key/URL userinfo/Discord/Signal/etc.)
verified to produce identical redacted output before/after. All 75
existing tests/agent/test_redact.py cases pass.
The `?access_token=foo&code=bar` (bare query string, no scheme) case
that 'leaks' is pre-existing behavior — the URL query redaction
requires a well-formed URL with scheme+host. Not a regression.
* perf(run_agent): cache _needs_thinking_reasoning_pad result per (provider, model, base_url)
Profile of a 31-turn synthetic agent run shows `_needs_thinking_reasoning_pad`
fires 495 times (~16 per turn) and each call ran 3 helper methods, each
hitting `base_url_host_matches` 1-4 times via `urlparse`. Total cost:
3,342 base_url_host_matches calls + 3,373 urlparse calls accounting for
~36ms of agent-loop overhead (~7% of the entire post-network work).
Provider / model / base_url don't change during a conversation except via
`switch_model` and fallback activation — both of which already overwrite
those attributes atomically. Cache the result on a tuple key; since the
key is derived from the very fields that would change, the cache
auto-invalidates on the next read after a switch. No manual invalidation
needed in switch_model / _try_activate_fallback.
Profile A/B (31-turn cached-config agent run):
BEFORE AFTER delta
_needs_thinking_reasoning_pad cum 18ms 1ms -94%
_copy_reasoning_content_for_api cum 17ms 1ms -94%
base_url_host_matches calls 3,342 372 -89%
urlparse calls 3,373 403 -88%
total function calls 296k 223k -25%
Verified:
- tests/run_agent/test_deepseek_reasoning_content_echo.py: 36/36 pass
- tests/run_agent/ (full): 1383/1383 pass + 3 skipped
2026-05-19 14:25:10 -07:00
|
|
|
# Substrings used to gate ``_PREFIX_RE`` execution. If none of these appear in
|
|
|
|
|
# the input string, the prefix regex cannot match anything, so we skip it.
|
|
|
|
|
# False positives are fine (they just run the regex, which then matches
|
|
|
|
|
# nothing) — the bound is "no false negatives" and that holds because every
|
|
|
|
|
# pattern in ``_PREFIX_PATTERNS`` has at least one of these as a literal
|
|
|
|
|
# substring of its leading characters.
|
|
|
|
|
#
|
|
|
|
|
# Derived automatically from ``_PREFIX_PATTERNS`` at module load time so a
|
|
|
|
|
# future PR that adds a new prefix to the regex list can't silently break
|
|
|
|
|
# the screen.
|
|
|
|
|
|
|
|
|
|
def _extract_literal_prefix(pattern: str) -> str:
|
|
|
|
|
"""Return the leading literal characters of a regex pattern.
|
|
|
|
|
|
|
|
|
|
Stops at the first regex metacharacter (``[``, ``(``, ``\\``, ``.``,
|
|
|
|
|
``?``, ``*``, ``+``, ``|``, ``{``, ``^``, ``$``). Returns the literal
|
|
|
|
|
that any match of the pattern MUST contain as a substring, so the
|
|
|
|
|
pre-screen never produces false negatives.
|
|
|
|
|
"""
|
|
|
|
|
meta = "[(\\.?*+|{^$"
|
|
|
|
|
for i, ch in enumerate(pattern):
|
|
|
|
|
if ch in meta:
|
|
|
|
|
return pattern[:i]
|
|
|
|
|
return pattern
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_PREFIX_SUBSTRINGS = tuple(
|
|
|
|
|
_extract_literal_prefix(p) for p in _PREFIX_PATTERNS
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _has_known_prefix_substring(text: str) -> bool:
|
|
|
|
|
"""Return True if ``text`` contains any known credential prefix substring.
|
|
|
|
|
|
|
|
|
|
Used as a cheap pre-check before invoking the expensive ``_PREFIX_RE``.
|
|
|
|
|
"""
|
|
|
|
|
return any(p in text for p in _PREFIX_SUBSTRINGS)
|
|
|
|
|
|
|
|
|
|
|
2026-05-24 16:04:39 -06:00
|
|
|
_HTTP_METHOD_SUBSTRINGS = (
|
|
|
|
|
"GET ",
|
|
|
|
|
"POST ",
|
|
|
|
|
"PUT ",
|
|
|
|
|
"PATCH ",
|
|
|
|
|
"DELETE ",
|
|
|
|
|
"HEAD ",
|
|
|
|
|
"OPTIONS ",
|
|
|
|
|
"TRACE ",
|
|
|
|
|
"CONNECT ",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _has_http_method_substring(text: str) -> bool:
|
|
|
|
|
"""Cheap pre-check before scanning for access-log request targets."""
|
|
|
|
|
upper = text.upper()
|
|
|
|
|
return any(method in upper for method in _HTTP_METHOD_SUBSTRINGS)
|
|
|
|
|
|
|
|
|
|
|
2026-02-28 21:56:05 -08:00
|
|
|
class RedactingFormatter(logging.Formatter):
|
|
|
|
|
"""Log formatter that redacts secrets from all log messages."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, fmt=None, datefmt=None, style='%', **kwargs):
|
|
|
|
|
super().__init__(fmt, datefmt, style, **kwargs)
|
|
|
|
|
|
|
|
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
|
|
|
original = super().format(record)
|
|
|
|
|
return redact_sensitive_text(original)
|