Skip to content

[security] fix(process): guard stdin submissions - #22557

Open
Hinotoi-agent wants to merge 1 commit into
NousResearch:mainfrom
Hinotoi-agent:fix/process-stdin-approval-guard
Open

[security] fix(process): guard stdin submissions#22557
Hinotoi-agent wants to merge 1 commit into
NousResearch:mainfrom
Hinotoi-agent:fix/process-stdin-approval-guard

Conversation

@Hinotoi-agent

@Hinotoi-agent Hinotoi-agent commented May 9, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR hardens the background-process stdin boundary so process.write and process.submit cannot be used as a second-stage command execution channel that bypasses terminal approval.

Hermes already runs dangerous-command and hardline checks before starting a command through terminal(). Before this PR, those checks covered only the initial process command. A model/tool flow could start an innocuous interactive process such as bash, then submit dangerous command text through the process tool's stdin path without reusing the same approval guard.

This PR:

  • applies the terminal command guard to stdin payloads before they reach a running process;
  • blocks hardline stdin payloads before PTY/pipe writes occur;
  • preserves safe stdin writes for ordinary interactive use;
  • adds regression tests for write_stdin() and submit_stdin().

Security issues covered

Issue Impact Severity
process.write / process.submit could feed dangerous commands into an already-running shell without terminal approval Prompt-controlled or lower-trust tool flow could bypass dangerous-command approval and hardline blocking after starting a benign process High

Before this PR

  • terminal() checked _check_all_guards(command, env_type) only for the initial command.
  • terminal(command="bash", background=True) was allowed because the launcher itself is not dangerous.
  • process(action="submit", data="rm -rf $HOME") wrote directly to the shell's stdin.
  • ProcessRegistry.write_stdin() sent data to session._pty.write(...) or session.process.stdin.write(...) without calling the approval guard.
  • Tests covered process polling/checkpoint behavior, but not second-stage stdin approval.

After this PR

  • ProcessRegistry.write_stdin() calls a shared stdin guard before writing any data to the process.
  • The guard reuses tools.approval.check_all_command_guards(..., "local"), preserving the existing terminal approval semantics.
  • Hardline stdin payloads are blocked before they reach the PTY/stdin sink.
  • Safe stdin payloads still work as before.
  • Non-UTF-8 byte payloads are rejected because they cannot be safety-scanned.
  • Regression tests assert blocked stdin data is not written or flushed.

Why this matters

Background process stdin is an execution channel when the target process is a shell or interpreter. Treating only the process launcher as approval-relevant leaves a gap: the safe-looking launcher can be approved while the dangerous command arrives one tool call later.

That breaks the user's expectation that catastrophic local commands go through Hermes' dangerous-command and hardline approval layer before execution.

How this differs from related issue/PR

Several public items already touch terminal approval, but this patch fixes a distinct second-stage channel:

Those items focus on the initial command, UI/transport behavior, or individual detection patterns. This PR covers the later ProcessRegistry.write_stdin() / submit_stdin() path, where command text can reach an already-running process without passing through the same guard.

Attack flow

Prompt-controlled agent/tool flow
    -> terminal(command="bash", background=True)
        -> initial command is allowed
            -> process(action="submit", session_id=<bash>, data="rm -rf $HOME")
                -> vulnerable code writes data directly to shell stdin
                    -> dangerous/hardline command executes without approval

Affected code

Issue Files
Process stdin approval bypass tools/process_registry.py, tools/terminal_tool.py, tools/approval.py
Regression coverage tests/tools/test_process_registry.py

Root cause

Process stdin approval bypass:

  • Direct cause: ProcessRegistry.write_stdin() wrote stdin data directly to PTY or pipe sinks without applying approval checks.
  • Boundary failure: the terminal approval boundary was enforced only at process creation, not at later process input channels that can carry executable command text.

CVSS assessment

Issue CVSS v3.1 Vector
Process stdin approval bypass 7.3 High CVSS:3.1/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H

Rationale:

  • This is local/agent-session scoped and requires a path that can steer tool use or invoke the terminal/process tool pair.
  • It is not unauthenticated remote code execution.
  • Once reachable, it can bypass an explicit command-approval boundary and execute as the Hermes user, with confidentiality, integrity, and availability impact depending on the submitted command.

Safe reproduction steps

  1. Start a benign background shell through the normal terminal path:
start = json.loads(
    terminal_tool(
        command="bash",
        background=True,
        pty=False,
        workdir="/tmp",
        task_id="stdin-bypass-repro",
    )
)
sid = start["session_id"]
  1. Submit a safe proof payload that still matches the hardline detector. The payload shadows rm so it writes a marker instead of deleting anything:
payload = "rm(){ echo proof > /tmp/hermes-process-stdin-approval-bypass; }\nrm -rf $HOME\nexit"
submitted = _handle_process(
    {"action": "submit", "session_id": sid, "data": payload},
    task_id="stdin-bypass-repro",
)
  1. On vulnerable code, the marker is created even though the payload text is hardline-detected.
  2. With this PR, submit returns status: blocked, and the marker is not created.

Expected vulnerable behavior

On vulnerable code:

payload_hardline= (True, 'recursive delete of home directory')
start_status= 0 None
submit_result= {'status': 'ok', 'bytes_written': 82}
terminal_guard_calls= 0
hardline_guard_calls= 0
wait_status= exited 0
marker_exists= True
marker_content= proof

With this PR:

payload_hardline= (True, 'recursive delete of home directory')
start_status= 0 None
submit_result= {'status': 'blocked', 'error': 'BLOCKED (hardline): recursive delete of home directory. ...'}
marker_exists= False

Changes in this PR

  • Adds _check_process_stdin_guards() in tools/process_registry.py.
  • Reuses tools.approval.check_all_command_guards(..., "local") for process stdin payloads.
  • Converts failed approval decisions into process-tool blocked / approval_required responses.
  • Rejects non-UTF-8 byte stdin payloads because they cannot be safety-scanned.
  • Calls the guard before PTY or pipe writes.
  • Adds tests proving hardline stdin data is blocked before stdin.write() / stdin.flush().
  • Adds a safe-path test proving ordinary stdin writes still work.

Files changed

Category Files What changed
Stdin approval hardening tools/process_registry.py Adds guard helpers and calls them before PTY/pipe stdin writes
Tests tests/tools/test_process_registry.py Adds regression coverage for blocked write_stdin, blocked submit_stdin, and safe stdin writes

Maintainer impact

  • The patch is localized to process stdin handling.
  • Safe interactive stdin usage remains supported.
  • The hardline floor now applies consistently whether command text arrives through terminal() or through a later process.submit call.
  • Existing terminal approval policy remains the source of truth; this PR does not introduce a separate policy language.
  • Non-local sandboxed environment process behavior is unchanged because stdin writing already requires an available local PTY/stdin handle.

Fix rationale

The right boundary is immediately before stdin data reaches the running process. Checking only at process creation cannot protect interactive shells, REPLs, or interpreters because executable command text may arrive later.

Reusing the existing terminal guard keeps the policy consistent and avoids creating a separate process-specific detection layer. Blocking non-UTF-8 bytes is a conservative fail-closed choice because opaque bytes cannot be scanned reliably before reaching a local process.

Type of change

  • Security fix
  • Tests
  • Documentation update
  • Refactor with no behavior change

Test plan

  • python3.11 -m py_compile tools/process_registry.py
  • ruff check tools/process_registry.py tests/tools/test_process_registry.py
  • git diff --check
  • pytest tests/tools/test_process_registry.py::TestStdinApprovalGuard -q
  • pytest tests/tools/test_process_registry.py tests/tools/test_approval.py -q
  • Manual safe repro after the patch: the hardline-looking stdin payload returned status: blocked, and /tmp/hermes-process-stdin-approval-bypass was not created.

Executed with:

python3.11 -m py_compile tools/process_registry.py
ruff check tools/process_registry.py tests/tools/test_process_registry.py
git diff --check
pytest tests/tools/test_process_registry.py::TestStdinApprovalGuard -q
pytest tests/tools/test_process_registry.py tests/tools/test_approval.py -q

Focused results:

  • TestStdinApprovalGuard: 3 passed
  • process registry + approval suites: 182 passed

Disclosure notes

  • This PR is intentionally bounded to second-stage stdin data sent through process.write / process.submit.
  • It does not claim unauthenticated remote code execution.
  • It does not remove background processes or safe interactive stdin support.
  • No unrelated files were changed.

@Hinotoi-agent

Copy link
Copy Markdown
Contributor Author

CI note: the ruff + ty diff job computed its summary successfully and reported no new ruff issues and no new ty diagnostics. The job failed only while trying to create/update its PR comment from the fork workflow token (403: Resource not accessible by integration). Local validation also passed ruff check tools/process_registry.py tests/tools/test_process_registry.py, git diff --check, and the focused pytest commands listed in the PR body.

@alt-glitch alt-glitch added type/security Security vulnerability or hardening comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint tool/terminal Terminal execution and process management P2 Medium — degraded but workaround exists labels May 11, 2026
@Hinotoi-agent
Hinotoi-agent force-pushed the fix/process-stdin-approval-guard branch from 5e0b15f to 7848b79 Compare May 13, 2026 03:38
@Hinotoi-agent
Hinotoi-agent force-pushed the fix/process-stdin-approval-guard branch from 7166e89 to a0728fc Compare May 23, 2026 09:09

@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 identifying a real second-stage execution boundary: current main still writes process.write/process.submit payloads directly to PTY or pipe sinks in tools/process_registry.py:1526-1545.

Problems

  • The added check_all_command_guards(command_text, "local") call at tools/process_registry.py:96 omits the interactive approval callback. The normal terminal route supplies it at tools/terminal_tool.py:281-286; tools/approval.py:2907-2909 only forwards an explicitly supplied callback, and tools/approval.py:1698-1718 denies callback-less prompt_toolkit approvals. This would make dangerous interactive stdin submissions fail closed rather than use the established approval UI.
  • The new tests cover mocked pipe writes only. Current interactive stdin is PTY-backed (tests/tools/test_process_registry.py:473-489), so the user-facing sink needs coverage too.

Suggested changes

  • Route stdin approval through the existing callback-aware terminal guard, or pass the current terminal approval callback explicitly.
  • Add PTY-path and callback-path regression coverage alongside the blocked-write assertion.

Automated hermes-sweeper review.

Comment thread tools/process_registry.py Outdated
command_text = data
from tools.approval import check_all_command_guards

approval = check_all_command_guards(command_text, "local")

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.

This bypasses the terminal wrapper that passes _get_approval_callback() (tools/terminal_tool.py:281-286). On current main, check_all_command_guards forwards only its explicit callback to prompt_dangerous_approval; without one, an active prompt_toolkit CLI fails closed instead of showing the established approval UI. Please preserve that callback path here.

@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:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 13, 2026
@Hinotoi-agent
Hinotoi-agent force-pushed the fix/process-stdin-approval-guard branch from a0728fc to 2ec3dd9 Compare July 13, 2026 04:52
@Hinotoi-agent

Copy link
Copy Markdown
Contributor Author

Thanks, updated in 2ec3dd9.

Changes made:

  • rebased onto current main
  • changed the process stdin guard to route through the existing callback-aware terminal guard (tools.terminal_tool._check_all_guards) instead of calling check_all_command_guards directly
  • preserved the current PTY write behavior while applying the guard before both PTY and pipe writes
  • added PTY regression coverage that verifies blocked stdin never reaches pty.write
  • added callback-path regression coverage showing a recoverable dangerous stdin payload is approved through the terminal approval callback before writing

Validation:

  • uv run --extra dev --extra messaging python -m pytest tests/tools/test_process_registry.py -q → 108 passed
  • uv run --extra dev --extra messaging python -m ruff check tools/process_registry.py tests/tools/test_process_registry.py → passed
  • git diff --check → passed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists 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-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data tool/terminal Terminal execution and process management type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants