From 4ca1180050af3cda244bf1486d1bc63754152e23 Mon Sep 17 00:00:00 2001 From: Aiden Ahn Date: Sun, 5 Jul 2026 12:34:18 +0900 Subject: [PATCH] fix: detect Hermes agent mode and restore child SIGINT --- .agents/skills/agents-env/SKILL.md | 1 + README.en.md | 3 +- README.md | 3 +- src/aimode.rs | 20 ++++++++++++ src/main.rs | 4 ++- src/mask.rs | 51 +++++++++++++++++++++++++++--- tests/cli.rs | 48 +++++++++++++++++++++++++++- 7 files changed, 121 insertions(+), 9 deletions(-) diff --git a/.agents/skills/agents-env/SKILL.md b/.agents/skills/agents-env/SKILL.md index 2d81877..4877471 100644 --- a/.agents/skills/agents-env/SKILL.md +++ b/.agents/skills/agents-env/SKILL.md @@ -45,6 +45,7 @@ Agent mode is automatic only for verified runtime markers: |---|---| | Claude Code | Auto-detect: `CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, legacy `CLAUDE_CODE_ENTRYPOINT`, or generic `AI_AGENT`. | | OpenAI Codex CLI | Auto-detect in sandboxed commands through `CODEX_SANDBOX`; opt in if sandboxing is bypassed. | +| Hermes Agent | Auto-detect through `HERMES_SESSION_ID` or `HERMES_SESSION_KEY` in gateway/tool-run child commands. | | Google Gemini CLI | Opt in with `AGENTS_ENV_AGENT_MODE=1` or a user-owned `markers=` entry. | | Google Antigravity CLI | Opt in with `AGENTS_ENV_AGENT_MODE=1` or `markers=`. | | Cursor CLI | Opt in; no stable child-command marker is documented. | diff --git a/README.en.md b/README.en.md index bcf83c2..1f54961 100644 --- a/README.en.md +++ b/README.en.md @@ -26,7 +26,7 @@ There is no shortage of env and secret tools, but few keep the value out of the - **Output masking** — when an injected secret appears in the child's stdout/stderr, it is replaced with `[masked:KEY]` in real time. `doppler run` and `infisical run` inject but leave the output untouched. - **Value-free copy** — `copy` moves a secret from the global store into a local `.env` without the value passing through the agent's context. -- **Agent-mode detection** — it auto-detects Claude Code and Codex sandbox from verified runtime markers; other assistants opt in with `AGENTS_ENV_AGENT_MODE=1` or `markers=`. +- **Agent-mode detection** — it auto-detects Claude Code, Codex sandbox, and Hermes Agent from verified runtime markers; other assistants opt in with `AGENTS_ENV_AGENT_MODE=1` or `markers=`. - **Asymmetric write guard** — the human-managed global master `.env` cannot be modified through this tool. ## How it works @@ -85,6 +85,7 @@ Auto-detection is enabled only when a stable child-process marker is verified fr |---|---| | Claude Code | Auto-detect: `CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, and existing `CLAUDE_CODE_ENTRYPOINT`/`AI_AGENT`. | | OpenAI Codex CLI | Auto-detect: `CODEX_SANDBOX` in sandboxed commands. If you bypass the sandbox, opt in explicitly. | +| Hermes Agent | Auto-detect: `HERMES_SESSION_ID` or `HERMES_SESSION_KEY` in gateway/tool-run child commands. | | Google Gemini CLI | Opt-in: no stable child-process marker verified. | | Google Antigravity CLI | Opt-in: no stable child-process marker verified. | | Cursor CLI | Opt-in: no stable marker verified that distinguishes `cursor-agent` itself from commands it runs. | diff --git a/README.md b/README.md index c8f00e4..f50ae76 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ agents-env run TAVILY_API_KEY@work -- curl -H "Authorization: Bearer {{TAVILY_AP - **출력 마스킹**: 주입한 시크릿이 자식의 stdout/stderr에 나타나면 실시간으로 `[masked:KEY]`로 치환한다. `doppler run`·`infisical run`은 주입만 하고 출력은 막지 않는다. - **값 비경유 복사**: `copy`는 전역 store의 시크릿을 로컬 `.env`로 옮기되, 값이 에이전트 컨텍스트를 거치지 않는다. -- **에이전트 모드 감지**: 검증된 런타임 마커가 있는 Claude Code와 Codex sandbox는 자동 인식하고, 다른 어시스턴트는 `AGENTS_ENV_AGENT_MODE=1` 또는 `markers=`로 명시 opt-in한다. +- **에이전트 모드 감지**: 검증된 런타임 마커가 있는 Claude Code, Codex sandbox, Hermes Agent는 자동 인식하고, 다른 어시스턴트는 `AGENTS_ENV_AGENT_MODE=1` 또는 `markers=`로 명시 opt-in한다. - **비대칭 쓰기 가드**: 사람이 관리하는 전역 마스터 `.env`는 이 도구로 수정할 수 없다. ## 동작 방식 @@ -85,6 +85,7 @@ agents-env copy NOTION_API_KEY@demodev --to .env.local |---|---| | Claude Code | 자동 감지: `CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, 기존 `CLAUDE_CODE_ENTRYPOINT`/`AI_AGENT`. | | OpenAI Codex CLI | 자동 감지: sandbox 명령의 `CODEX_SANDBOX`. sandbox를 우회하면 opt-in 필요. | +| Hermes Agent | 자동 감지: gateway/tool-run 자식 명령의 `HERMES_SESSION_ID` 또는 `HERMES_SESSION_KEY`. | | Google Gemini CLI | opt-in: 안정적인 자식 프로세스 마커 미확인. | | Google Antigravity CLI | opt-in: 안정적인 자식 프로세스 마커 미확인. | | Cursor CLI | opt-in: `cursor-agent` 실행 자체와 에이전트가 실행한 자식 명령을 구분하는 안정 마커 미확인. | diff --git a/src/aimode.rs b/src/aimode.rs index 99fba94..98ee7d2 100644 --- a/src/aimode.rs +++ b/src/aimode.rs @@ -12,6 +12,8 @@ /// - OpenAI Codex sets `CODEX_SANDBOX` (e.g. `seatbelt`) on commands run inside /// its sandbox. Caveat: with the sandbox bypassed it may be absent — set /// `AGENTS_ENV_AGENT_MODE=1` in that config to stay safe. +/// - Hermes Agent sets `HERMES_SESSION_ID`/`HERMES_SESSION_KEY` for gateway and +/// tool-run child commands. /// - `AGENTS_ENV_AGENT_MODE` lets any other harness opt in explicitly. /// /// Do not add guessed tool-specific markers here. If a coding assistant has no @@ -26,9 +28,15 @@ pub const MARKERS: &[&str] = &[ "CLAUDE_CODE_ENTRYPOINT", "AI_AGENT", "CODEX_SANDBOX", + "HERMES_SESSION_ID", + "HERMES_SESSION_KEY", "AGENTS_ENV_AGENT_MODE", ]; +/// Agent markers whose values may carry session metadata and should be masked +/// if a wrapped child prints its inherited environment. +pub const MASKED_MARKERS: &[&str] = &["HERMES_SESSION_ID", "HERMES_SESSION_KEY"]; + pub fn agent_mode() -> bool { let builtin = MARKERS .iter() @@ -39,6 +47,18 @@ pub fn agent_mode() -> bool { .any(|m| std::env::var_os(m).is_some_and(|v| !v.is_empty())) } +pub fn masked_marker_values() -> Vec<(String, String)> { + MASKED_MARKERS + .iter() + .filter_map(|m| { + std::env::var(m) + .ok() + .filter(|v| v.len() >= 6) + .map(|v| ((*m).to_string(), v)) + }) + .collect() +} + /// User-configured extra markers from `markers=` in the config file. fn extra_markers() -> Vec { let path = crate::config::config_path(); diff --git a/src/main.rs b/src/main.rs index 7edb5dd..8c0fc74 100644 --- a/src/main.rs +++ b/src/main.rs @@ -314,7 +314,8 @@ fn cmd_run(cli: &Cli, selectors: &[String], all: bool, no_mask: bool, command: & // Mask set: injected values (ALWAYS, any length — a leak here is the value // the agent asked to use) ∪ ambient values from the global store and local - // scope (length-floored to avoid over-masking common short strings). + // scope ∪ session-marker values (length-floored to avoid over-masking + // common short strings). let mut mask_values: Vec<(String, String)> = inject.clone(); let mut ambient: Vec<(String, String)> = Vec::new(); if is_local { @@ -333,6 +334,7 @@ fn cmd_run(cli: &Cli, selectors: &[String], all: bool, no_mask: bool, command: & } ambient.retain(|(_, v)| v.len() >= 6); mask_values.extend(ambient); + mask_values.extend(aimode::masked_marker_values()); // Order-preserving dedup by value: injected entries come first, so an // injected short secret is never the one dropped. { diff --git a/src/mask.rs b/src/mask.rs index 1cd5fcf..4a608a8 100644 --- a/src/mask.rs +++ b/src/mask.rs @@ -9,6 +9,8 @@ use aho_corasick::{AhoCorasick, MatchKind}; use std::io::{self, Read, Write}; +#[cfg(unix)] +use std::os::unix::process::CommandExt; use std::process::{Command, Stdio}; use std::sync::Arc; use std::thread; @@ -107,16 +109,19 @@ pub fn run( } // Let Ctrl-C go to the child (same process group); the wrapper waits. - unsafe { - libc::signal(libc::SIGINT, libc::SIG_IGN); - } + // `signal(SIG_IGN)` is inherited across exec, so explicitly reset the child + // to the default disposition before it starts. Otherwise `kill -INT $$` or a + // terminal Ctrl-C can be ignored by the wrapped command. + let prev_sigint = prepare_child_sigint(&mut cmd); let mask_values: Vec<_> = mask_values.iter().filter(|(_, v)| !v.is_empty()).collect(); // Fast path when masking is off, or when there is no non-empty value to // mask. Empty values have no plaintext fragment to leak. if !mask || mask_values.is_empty() { - return match cmd.status() { + let status = cmd.status(); + restore_sigint(prev_sigint); + return match status { Ok(s) => exit_code(s), Err(e) => { eprintln!("agents-env: failed to run '{}': {e}", argv[0]); @@ -129,6 +134,7 @@ pub fn run( let mut child = match cmd.spawn() { Ok(c) => c, Err(e) => { + restore_sigint(prev_sigint); eprintln!("agents-env: failed to run '{}': {e}", argv[0]); return 127; } @@ -162,7 +168,9 @@ pub fn run( let _ = t_out.join(); let _ = t_err.join(); - match child.wait() { + let status = child.wait(); + restore_sigint(prev_sigint); + match status { Ok(s) => exit_code(s), Err(e) => { eprintln!("agents-env: wait failed: {e}"); @@ -171,6 +179,39 @@ pub fn run( } } +#[cfg(unix)] +type SigHandler = libc::sighandler_t; + +#[cfg(unix)] +fn prepare_child_sigint(cmd: &mut Command) -> SigHandler { + unsafe { + let prev = libc::signal(libc::SIGINT, libc::SIG_IGN); + let child_sigint = if prev == libc::SIG_IGN { + libc::SIG_IGN + } else { + libc::SIG_DFL + }; + cmd.pre_exec(move || { + libc::signal(libc::SIGINT, child_sigint); + Ok(()) + }); + prev + } +} + +#[cfg(unix)] +fn restore_sigint(prev: SigHandler) { + unsafe { + libc::signal(libc::SIGINT, prev); + } +} + +#[cfg(not(unix))] +fn prepare_child_sigint(_cmd: &mut Command) {} + +#[cfg(not(unix))] +fn restore_sigint(_: ()) {} + fn exit_code(s: std::process::ExitStatus) -> i32 { use std::os::unix::process::ExitStatusExt; s.code().unwrap_or(128 + s.signal().unwrap_or(0)) diff --git a/tests/cli.rs b/tests/cli.rs index cc48a11..34e9d1a 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -12,6 +12,8 @@ const AI_MARKERS: &[&str] = &[ "CLAUDE_CODE_ENTRYPOINT", "AI_AGENT", "CODEX_SANDBOX", + "HERMES_SESSION_ID", + "HERMES_SESSION_KEY", "AGENTS_ENV_AGENT_MODE", ]; @@ -162,6 +164,28 @@ fn run_masks_stderr_and_other_global_values() { assert!(stderr.contains("[masked:GEMINI_API_KEY]")); } +#[test] +fn run_masks_hermes_session_marker_values() { + let sb = Sandbox::new(); + let marker = "agent:main:discord:thread:sensitive-session-key"; + let out = sb + .cmd(false) + .env("HERMES_SESSION_KEY", marker) + .args([ + "run", + "TAVILY_API_KEY", + "--", + "sh", + "-c", + "echo marker=$HERMES_SESSION_KEY", + ]) + .assert() + .success(); + let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("marker=[masked:HERMES_SESSION_KEY]")); + assert!(!stdout.contains(marker)); +} + #[test] fn local_run_still_masks_global_values() { let sb = Sandbox::new(); @@ -214,6 +238,28 @@ fn run_propagates_exit_code() { .code(7); } +#[test] +fn run_child_still_receives_sigint() { + let sb = Sandbox::new(); + let out = sb + .cmd(true) + .args([ + "run", + "TAVILY_API_KEY", + "--", + "sh", + "-c", + "kill -INT $$; echo survived", + ]) + .assert() + .code(130); + let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap(); + assert!( + !stdout.contains("survived"), + "child ignored SIGINT: {stdout}" + ); +} + #[test] fn run_ambiguous_selector_lists_tags_without_values() { let sb = Sandbox::new(); @@ -410,7 +456,7 @@ fn set_warns_on_credential_looking_value() { let sb = Sandbox::new(); let out = sb .cmd(true) - .args(["set", "K", "sk-abcdef123456"]) + .args(["set", "K", "sk-test-ABC123"]) .assert() .success(); let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap();