{"repo":"NousResearch/hermes-agent","public_id":"6396","display_label":"Redacting credentials from URLs in output","summary":"These issues and pull requests concern identifying and redacting embedded credentials (tokens, passwords) from URLs before they appear in surfaced output. The work spans generic URLs, bare-token userinfo fields, and Git URLs specifically.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":54475,"merged_at":"2026-06-29T01:52:42+00:00"},"resolution":{"state":"resolved","by":"pr-54475"},"freshness":{"state":"verified","last_verified_at":"2026-06-29T01:52:42+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":6396,"pr_number":54475,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-29T01:52:42+00:00"}],"members":[{"kind":"issue","number":6396,"title":"[Bug]: Security","url":null,"state":"closed"},{"kind":"pr","number":6454,"title":"fix(security): redact credentials embedded in generic URLs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"acd05c609","number":0},{"kind":"pr","value":"#6396","number":6396},{"kind":"pr","value":"#34029","number":34029},{"kind":"pr","value":"#54475","number":54475}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":6469,"title":"[codex] Redact Git URL credentials from surfaced output","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":54475,"title":"fix(security): redact bare-token credentials in URL userinfo (#6396)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/redact.py","tests/agent/test_redact.py"],"fix_hunks":[{"file":"agent/redact.py","hunks":["@@ -222,6 +222,28 @@\n     re.IGNORECASE,\n )\n \n+# Bare-token credential in a web/transport URL: ``scheme://TOKEN@host``.\n+# This is the ``git remote set-url origin https://[redacted:email]/...``\n+# shape from issue #6396 — a single opaque credential in the userinfo position\n+# with NO ``user:pass`` colon. It is unambiguously a secret: legitimate\n+# round-trip URLs (OAuth callbacks, magic links, pre-signed shares — see the\n+# \"Web-URL redaction is intentionally OFF\" note in redact_sensitive_text) carry\n+# their tokens in the QUERY STRING, never in bare userinfo. The colon form\n+# ``user:pass@`` is deliberately left to pass through (commit \"pass web URLs\n+# through unchanged\", #34029) and is NOT matched here — the token class forbids\n+# ``:``. DB schemes are handled by _DB_CONNSTR_RE above and excluded here.\n+#\n+# Guards against false positives:\n+#   - 8+ char floor skips short usernames (git, admin, root, deploy, ubuntu).\n+#   - The token class ``[^\\s:@/]`` cannot cross ``/``, so an ``@`` sitting in a\n+#     path or query (e.g. ``?q=[redacted:email]``) is never treated as userinfo.\n+_URL_BARE_TOKEN_RE = re.compile(\n+    r\"((?:https?|wss?|git|ssh|ftp|ftps|sftp)://)\"  # scheme\n+    r\"([^\\s:@/]{8,})\"                               # bare token (no colon/slash/@), 8+ chars\n+    r\"(@[^\\s]+)\",                                   # @host...\n+    re.IGNORECASE,\n+)\n+\n # JWT tokens: [redacted:secret-assignment] — always start with \"eyJ\" (base64 for \"{\")\n # Matches 1-part (header only), 2-part (header.payload), and full 3-part JWTs.\n _JWT_RE = re.compile(","@@ -564,6 +586,16 @@ def _redact_db(m):\n         else:\n             text = _DB_CONNSTR_RE.sub(lambda m: f\"{m.group(1)}***{m.group(3)}\", text)\n \n+        # Bare-token userinfo in web/transport URLs: ``scheme://TOKEN@host``.\n+        # The git-remote-with-embedded-password shape from #6396. Only the\n+        # colon-less bare-token form is redacted — ``user:pass@`` and\n+        # query-string tokens are left to pass through (see the web-URL note\n+        # below). See _URL_BARE_TOKEN_RE for the false-positive guards.\n+        text = _URL_BARE_TOKEN_RE.sub(\n+            lambda m: f\"{m.group(1)}{_mask_token(m.group(2))}{m.group(3)}\",\n+            text,\n+        )\n+\n     # JWT tokens (eyJ... — base64-encoded JSON headers)\n     if \"eyJ\" in text:\n         text = _JWT_RE.sub(lambda m: _mask_token(m.group(0)), text)","@@ -575,7 +607,12 @@ def _redact_db(m):\n     # blanket-redacting param values by name breaks those skills mid-flow.\n     # Known credential shapes (sk-, ghp_, JWTs, etc.) inside URLs are still\n     # caught by _PREFIX_RE and _JWT_RE above. DB connection-string passwords\n-    # are still caught by _DB_CONNSTR_RE.\n+    # are still caught by _DB_CONNSTR_RE. The ONE userinfo case still redacted\n+    # is the colon-less bare-token form ``scheme://TOKEN@host`` (#6396, handled\n+    # by _URL_BARE_TOKEN_RE in the ``://`` block above): a bare credential in\n+    # userinfo is never a round-trip workflow token (those live in the query\n+    # string), so masking it can't break a skill. The ``user:pass@`` form is\n+    # left to pass through per #34029.\n \n     # Form-urlencoded bodies (only triggers on clean k=v&k=v inputs).\n     if \"&\" in text and \"=\" in text:"]},{"file":"tests/agent/test_redact.py","hunks":["@@ -492,6 +492,79 @@ def test_db_connstr_password_still_redacted(self):\n         assert \"dbpass\" not in result\n \n \n+class TestBareTokenUserinfoRedaction:\n+    \"\"\"Regression tests for #6396 — a bare credential in URL userinfo\n+    (``scheme://TOKEN@host``, no ``user:pass`` colon) is redacted. This is the\n+    git-remote-with-embedded-password shape. The colon form ``user:pass@`` and\n+    query-string tokens are deliberately left to pass through (#34029) so\n+    magic-link / OAuth round-trip skills keep working — see\n+    TestWebUrlsNotRedacted for those invariants.\n+    \"\"\"\n+\n+    def test_git_remote_bare_password_redacted(self):\n+        \"\"\"Exact bug scenario: password in a git remote URL.\"\"\"\n+        text = (\n+            \"git remote set-url origin \"\n+            \"https://[redacted:email]/unclehowell/FCUK.git\"\n+        )\n+        result = redact_sensitive_text(text)\n+        assert \"MYPASSWORDWASDISLAYEDHERE\" not in result\n+        assert \"@github.com\" in result\n+        assert \"unclehowell/FCUK.git\" in result\n+\n+    def test_ssh_bare_token_redacted(self):\n+        text = \"ssh://[redacted:email]/project.git\"\n+        result = redact_sensitive_text(text)\n+        assert \"longtoken1234567\" not in result\n+        assert \"@gitlab.com\" in result\n+\n+    def test_ftp_bare_token_redacted(self):\n+        text = \"ftp://[redacted:email]/files\"\n+        result = redact_sensitive_text(text)\n+        assert \"ftptoken123456\" not in result\n+\n+    def test_bare_token_with_query_redacts_token_only(self):\n+        text = \"https://[redacted:email]/path?foo=bar\"\n+        result = redact_sensitive_text(text)\n+        assert \"abcdef1234567\" not in result\n+        assert \"?foo=bar\" in result\n+\n+    def test_user_pass_form_still_passes_through(self):\n+        \"\"\"The ``user:pass@`` colon form must NOT be redacted (#34029).\"\"\"\n+        text = \"URL: https://[redacted-credentials]@host.example.com/path\"\n+        assert redact_sensitive_text(text) == text\n+\n+    def test_short_username_not_redacted(self):\n+        \"\"\"Short userinfo (git, admin, deploy) below the 8-char floor passes.\"\"\"\n+        for text in (\n+            \"https://[redacted:email]/user/repo.git\",\n+            \"https://[redacted:email]/x\",\n+            \"https://[redacted:email]/y\",\n+        ):\n+            assert redact_sensitive_text(text) == text\n+\n+    def test_email_in_path_not_redacted(self):\n+        \"\"\"An ``@`` in a path/query is not userinfo — the token class stops at\n+        ``/``, so emails after the first slash are never treated as a credential.\"\"\"\n+        for text in (\n+            \"https://example.com/search?q=[redacted:email]\",\n+            \"https://example.com/users/[redacted:email]/profile\",\n+        ):\n+            assert redact_sensitive_text(text) == text\n+\n+    def test_plain_url_unchanged(self):\n+        text = \"https://github.com/user/repo.git\"\n+        assert redact_sensitive_text(text) == text\n+\n+    def test_long_bare_token_preserves_head_tail(self):\n+        token = \"[redacted:secret-assignment]\" + \"x\" * 20 + \"wxyz\"\n+        text = f\"https://{token}@github.com/u/r.git\"\n+        result = redact_sensitive_text(text)\n+        assert token not in result\n+        assert \"abcdef\" in result  # head preserved\n+        assert \"wxyz\" in result    # tail preserved\n+\n+\n class TestFormBodyRedaction:\n     \"\"\"Form-urlencoded body redaction (k=v&k=v with no other text).\"\"\"\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}