Skip to content

feat: credential proxy daemon Phase 1 — HTTP-only broker (#4656) - #4691

Open
kenantan32 wants to merge 1 commit into
NousResearch:mainfrom
kenantan32:feat/credential-proxy-phase1
Open

feat: credential proxy daemon Phase 1 — HTTP-only broker (#4656)#4691
kenantan32 wants to merge 1 commit into
NousResearch:mainfrom
kenantan32:feat/credential-proxy-phase1

Conversation

@kenantan32

Copy link
Copy Markdown

Summary

Implements Phase 1 of the zero-knowledge credential proxy daemon proposed in #4656.

Tool subprocesses receive hermes-proxy://<name> placeholders instead of real credentials. A lightweight HTTP proxy running on a Unix domain socket intercepts outbound requests, substitutes placeholders with real values from an in-memory store, and forwards to the upstream server. The agent process structurally cannot read credential values — not as a matter of policy, but by design.

Architecture

Agent process          Credential Proxy           Upstream API
    │                      │                          │
    │ Authorization:       │                          │
    │ hermes-proxy://tok1  │                          │
    │─────────────────────>│                          │
    │                      │  Authorization:          │
    │                      │  sk-real-secret-123      │
    │                      │─────────────────────────>│
    │                      │                          │
    │    <response>        │    <response>            │
    │<─────────────────────│<─────────────────────────│

Changes (15 files, +1147 lines)

New files

File Description
proxy/store.py Thread-safe, write-only credential store. External API: store/rotate/delete/list. Internal _resolve() for proxy server only. No read/get API — this is the security invariant.
proxy/server.py asyncio HTTP proxy on Unix socket. Rewrites hermes-proxy://<name> placeholders in request headers and bodies. CONNECT returns 501 (Phase 2).
proxy/config.py Configuration helpers. Reads credential_proxy section from config.yaml. Profile-aware socket paths (uses HERMES_HOME).
proxy/daemon.py Daemon entry point spawned by hermes cred-proxy start. Registers proxy credentials in passthrough allowlist, binds store to secrets tool.
tools/secrets_tool.py Write-only secrets tool registered with tools/registry.py. Actions: store, rotate, delete, list. No read/get action exists.
hermes_cli/cred_proxy.py CLI lifecycle: hermes cred-proxy start|stop|status. Mirrors gateway.py pattern.

Modified files

File Change
tools/env_passthrough.py Added register_proxy_credentials() — reads credential_proxy.proxy_credentials from config and registers those var names so _sanitize_subprocess_env() doesn't strip placeholder values. Addresses #4429.
tools/environments/local.py _make_run_env() now injects http_proxy/HTTP_PROXY pointing at the proxy Unix socket when the socket file exists.
hermes_cli/main.py Wired hermes cred-proxy start|stop|status subcommand.

Configuration

credential_proxy:
  enabled: true
  socket: ~/.hermes/state/cred-proxy.sock   # optional, defaults to {HERMES_HOME}/state/cred-proxy.sock
  proxy_credentials:                          # env var names to bypass the blocklist
    - CLOUDFLARE_API_TOKEN
    - SLACK_BOT_TOKEN

Then in .env:

CLOUDFLARE_API_TOKEN=hermes-proxy://cf_dns_token
SLACK_BOT_TOKEN=hermes-proxy://slack_main

The agent sees the placeholder. The proxy substitutes the real value at the HTTP transport layer.

Security invariant

There is no secrets(action="read") or secrets(action="get"). The store has no external read API. The only code path that resolves a credential name to its value is inside the proxy server process — never exposed to the agent.

Tests

37 new tests across 4 test files:

  • test_store.py: CRUD, thread safety, concurrent access
  • test_server.py: placeholder regex, header/body substitution, mixed resolved/unresolved
  • test_passthrough.py: env var registration, blocklist bypass
  • test_secrets_tool.py: all actions, no-read invariant, error cases

All 4665 existing tests pass (2 pre-existing failures in test_api_key_providers and test_codex_execution_paths unrelated to this PR).

What's NOT in this PR (future phases)

  • Phase 2: HTTPS MITM with local CA (CONNECT tunnelling, cert generation, SSL_CERT_FILE injection)
  • Phase 3: Encrypted persistence (AES-256-GCM at rest, Argon2id key derivation)

Dependencies

This PR includes the env passthrough mechanism needed for proxy-brokered credentials (#4429), making #4592 unnecessary.

Refs #4656, addresses #4429

…lder substitution (NousResearch#4656)

Implements Phase 1 of the zero-knowledge credential proxy daemon.
Tool subprocesses receive hermes-proxy://<name> placeholders instead of
real credentials. The proxy intercepts HTTP requests on a Unix socket,
substitutes placeholders with real values, and forwards to upstream.
The agent process structurally cannot read credential values.

New files:
- proxy/store.py: Thread-safe, write-only credential store
- proxy/server.py: asyncio HTTP proxy with placeholder substitution
- proxy/config.py: Config helpers (socket path, enabled, proxy_credentials)
- proxy/daemon.py: Daemon entry point (spawned by CLI)
- tools/secrets_tool.py: Write-only secrets tool (store/rotate/delete/list)
- hermes_cli/cred_proxy.py: CLI lifecycle (hermes cred-proxy start|stop|status)

Modified files:
- tools/env_passthrough.py: register_proxy_credentials() for blocklist bypass
- tools/environments/local.py: inject http_proxy when proxy socket exists
- hermes_cli/main.py: wire cred-proxy subcommand

Tests: 37 new tests (store, server, passthrough, secrets tool)
All 4665 existing tests pass (2 pre-existing failures unrelated).

Phase 2 (HTTPS MITM + local CA) and Phase 3 (encrypted persistence)
are left for follow-up PRs.

Addresses NousResearch#4429 (env passthrough for proxy-brokered credentials).
Refs NousResearch#4656

@dsr-restyn dsr-restyn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking this up — the secrets tool integration and profile-aware config are solid design choices that my earlier PR (#4695, now corrected in dsr-restyn#10) didn't include. A few things I noticed while reading through:

Duplicate function definition

register_proxy_credentials() is defined twice in tools/env_passthrough.py — once at line 93 and again at line 115. Looks like a copy-paste artifact; only the second version has the ImportError guard.

CONNECT returns 501 — breaks all HTTPS tools

Returning 501 for CONNECT means any tool making HTTPS requests through the proxy gets a hard failure. Since most API traffic is HTTPS, this effectively blocks the proxy from being usable in practice.

A blind TCP relay (pass-through without inspection) is a better Phase 1 choice:

if method.upper() == "CONNECT":
    host, _, port_str = url.rpartition(":")
    port = int(port_str)
    # Establish upstream connection
    up_reader, up_writer = await asyncio.open_connection(host, port)
    # Tell client the tunnel is established
    transport.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
    # Blind bidirectional relay — no credential substitution
    await asyncio.gather(pipe(client, upstream), pipe(upstream, client))

HTTPS tools work normally, they just don't get credential substitution — which is the correct Phase 1 limitation. Phase 2 (MITM CA) adds substitution inside the tunnel.

_start() doesn't wait for daemon readiness

proc = subprocess.Popen([sys.executable, "-m", "proxy.daemon", ...], ...)
print(f"Credential proxy started (PID {proc.pid}).")

This prints "started" immediately after spawning the subprocess, before the daemon has bound the socket or written its PID file. If you run hermes cred-proxy start && hermes cred-proxy status quickly, the status check may not find the socket yet.

A readiness poll (wait up to N seconds for the PID file + socket to appear) would fix this.

No CLI path for credential ingestion

hermes cred-proxy only supports start|stop|status — there's no add or list subcommand. Credentials can only be added through the secrets tool from within a running agent session.

This means you can't pre-load credentials before starting the agent (e.g., hermes cred-proxy add cf_dns_token from a setup script). The issue spec mentions hermes cred-proxy add <name> as a CLI entry point, and it's useful for bootstrapping.

This requires some IPC mechanism from the CLI process to the daemon's store — either a management endpoint on the Unix socket or a secondary channel. I implemented this in my fork as a /_cred/ HTTP API on the same socket (management requests use relative paths like /_cred/add, proxy requests use absolute URLs like http://host/..., so they never collide).

Incomplete body handling

In _ProxyProtocol._handle_request():

if content_length is not None and len(body) < content_length:
    remaining = content_length - len(body)
    if remaining > 0:
        logger.debug("body incomplete: have %d, need %d more", len(body), remaining)

This logs that the body is incomplete but doesn't actually read the remaining data — it continues with a truncated body. For typical small API payloads this probably works (the full request often arrives in one TCP read), but it will silently corrupt larger POST bodies.

Missing hermes-proxy:// blocklist bypass in _sanitize_subprocess_env

The local.py changes only inject http_proxy in _make_run_env(). But _sanitize_subprocess_env() still strips env vars like CLOUDFLARE_API_TOKEN from the blocklist — even when the value is just a hermes-proxy:// placeholder.

The passthrough registration handles this when the daemon is running, but if someone configures CLOUDFLARE_API_TOKEN=hermes-proxy://cf_token in .env without the daemon active, the placeholder gets stripped silently. Adding or value.startswith("hermes-proxy://") as a bypass in _sanitize_subprocess_env makes placeholder values safe regardless of daemon state.


Happy to contribute fixes for any of these if you'd like — I've already implemented the CONNECT relay, readiness polling, management API, and blocklist bypass in my fork. Let me know if you'd prefer a co-authored approach or separate follow-up PRs.

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/tools Tool registry, model_tools, toolsets area/auth Authentication, OAuth, credential pools labels May 1, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Likely duplicate of #9704 — same Phase 1 credential proxy implementation for #4656. #9704 is the stdlib-only version and appears to be the canonical PR.

1 similar comment
@alt-glitch

Copy link
Copy Markdown
Collaborator

Likely duplicate of #9704 — same Phase 1 credential proxy implementation for #4656. #9704 is the stdlib-only version and appears to be the canonical PR.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the focused stdlib-only Phase 1 contribution. The security boundary needs substantial rework before this version can be salvaged.

Problems

  • proxy/daemon.py:61 calls set_store(store) only in the detached daemon. tools/secrets_tool.py:22-30 reads a process-local _store, so the agent process cannot access or mutate the daemon’s store. tests/proxy/test_secrets_tool.py sets that global directly in its own process and does not cover the daemon boundary.
  • The daemon registers passthrough names only in its own process (proxy/daemon.py:43-51), but current main reads the ContextVar-backed allowlist while constructing agent subprocess environments (tools/environments/local.py:346-372, 794-812). The configured placeholder variables will still be stripped. The PR also defines register_proxy_credentials twice in tools/env_passthrough.py; the latter overwrites the former.
  • tools/secrets_tool.py uses toolset="secrets", but this PR does not add that tool or toolset to toolsets.py, so discovery does not expose it to the agent.
  • proxy/server.py:64 dispatches at header completion; proxy/server.py:143-151 logs an incomplete body but forwards it anyway.

Suggested changes

  • Add daemon IPC plus an agent-side client, register passthrough in the agent process, wire a service-gated toolset, and add daemon/socket integration coverage for storage, env filtering, and segmented request bodies.

Automated hermes-sweeper review.

Comment thread tools/secrets_tool.py

def _check_available() -> bool:
"""Return True if the credential proxy store is available."""
return _store is not None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_store is process-local. The only production set_store() call is in the detached daemon (proxy/daemon.py), so the agent process keeps _store is None and this tool is unavailable. Please replace this with an IPC client to the daemon and add a cross-process integration test.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/tools Tool registry, model_tools, toolsets P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants