Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/agents-env/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
3 changes: 2 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. |
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`는 이 도구로 수정할 수 없다.

## 동작 방식
Expand Down Expand Up @@ -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` 실행 자체와 에이전트가 실행한 자식 명령을 구분하는 안정 마커 미확인. |
Expand Down
20 changes: 20 additions & 0 deletions src/aimode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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<String> {
let path = crate::config::config_path();
Expand Down
4 changes: 3 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
{
Expand Down
51 changes: 46 additions & 5 deletions src/mask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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}");
Expand All @@ -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))
Expand Down
48 changes: 47 additions & 1 deletion tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
];

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down