1use crate::error::{KernelError, Result};
20use crate::trace::Trace;
21use std::collections::BTreeMap;
22
23pub const AGENT_LOCATOR_PREFIX: &str = "agent-session:";
25
26struct Host {
27 name: &'static str,
28 exact: &'static [&'static str],
29 prefixes: &'static [&'static str],
30 session_vars: &'static [&'static str],
32}
33
34const HOSTS: &[Host] = &[
35 Host {
36 name: "claude-code",
37 exact: &["CLAUDECODE"],
38 prefixes: &["CLAUDE_CODE_"],
39 session_vars: &["CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_REMOTE_SESSION_ID"],
40 },
41 Host {
42 name: "codex",
43 exact: &[],
44 prefixes: &["CODEX_"],
45 session_vars: &["CODEX_SESSION_ID", "CODEX_THREAD_ID"],
46 },
47 Host {
48 name: "cursor",
49 exact: &[],
50 prefixes: &["CURSOR_"],
51 session_vars: &["CURSOR_SESSION_ID", "CURSOR_TRACE_ID"],
52 },
53 Host {
54 name: "gemini-cli",
55 exact: &["GEMINI_CLI"],
56 prefixes: &[],
57 session_vars: &[],
58 },
59 Host {
60 name: "ai-agent",
61 exact: &["AI_AGENT"],
62 prefixes: &[],
63 session_vars: &[],
64 },
65];
66
67pub const USER_CONFIGURATION: &[&str] = &[
72 "CODEX_HOME",
73 "CLAUDE_CODE_USE_BEDROCK",
74 "CLAUDE_CODE_USE_VERTEX",
75 "CLAUDE_CODE_USE_FOUNDRY",
76 "CLAUDE_CODE_SKIP_BEDROCK_AUTH",
77 "CLAUDE_CODE_SKIP_VERTEX_AUTH",
78 "CLAUDE_CODE_MAX_OUTPUT_TOKENS",
79 "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
80 "CLAUDE_CODE_ENABLE_TELEMETRY",
81 "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA",
82 "CLAUDE_CODE_OAUTH_TOKEN",
83 "CLAUDE_CODE_CLIENT_CERT",
84 "CLAUDE_CODE_CLIENT_KEY",
85 "CLAUDE_CODE_CLIENT_KEY_PASSPHRASE",
86 "CLAUDE_CODE_GIT_BASH_PATH",
87];
88
89const FALSE_POSITIVE_HELP: &str = "\"Agent sessions\" in docs/MEMORY.md";
91const LISTED_MARKERS: usize = 5;
93
94const MAX_SESSION_ID: usize = 64;
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct AgentSession {
99 pub host: &'static str,
101 pub marker: String,
103 pub markers: Vec<String>,
105 pub session_id: Option<String>,
107}
108
109impl AgentSession {
110 pub fn locator(&self) -> String {
112 format!(
113 "{AGENT_LOCATOR_PREFIX}{}:{}",
114 self.host,
115 self.session_id.as_deref().unwrap_or("unknown")
116 )
117 }
118
119 pub fn stamp(&self, trace: &mut Trace) -> Result<()> {
123 if trace.provenance.verified {
124 return Err(KernelError::Invalid(format!(
125 "--verified is refused inside an AI agent session ({}). `verified` means a person checked the claim, and an agent cannot attest its own memory; `--source` does not change that. Record it without --verified (it is stored with locator {}). To verify it, a person runs `hikmah remember --verified --supersedes <trace id> ...` from their own terminal, outside the agent session. {}",
126 self.detected(),
127 self.locator(),
128 self.false_positive_note()
129 )));
130 }
131 trace.provenance.locator = Some(match trace.provenance.locator.take() {
132 Some(given) if !given.trim().is_empty() => format!("{}; {given}", self.locator()),
133 _ => self.locator(),
134 });
135 Ok(())
136 }
137
138 pub fn refuse_person_only(&self, command: &str, why: &str) -> KernelError {
144 KernelError::Invalid(format!(
145 "`{command}` is refused inside an AI agent session ({}): {why}. An agent must stop here and ask a person. The person inspects what `hikmah verify-ledger` lists and runs `{command}` from their own terminal, outside the agent session. {}",
146 self.detected(),
147 self.false_positive_note()
148 ))
149 }
150
151 fn detected(&self) -> String {
152 format!("{} detected through {}", self.host, self.marker)
153 }
154
155 fn false_positive_note(&self) -> String {
156 let mut listed: Vec<&str> = self
157 .markers
158 .iter()
159 .take(LISTED_MARKERS)
160 .map(String::as_str)
161 .collect();
162 let more;
163 if self.markers.len() > LISTED_MARKERS {
164 more = format!("and {} more", self.markers.len() - LISTED_MARKERS);
165 listed.push(&more);
166 }
167 format!(
168 "Agent variables set: {}. If you are a person and your own shell profile or IDE terminal sets them, see {FALSE_POSITIVE_HELP}",
169 listed.join(", ")
170 )
171 }
172}
173
174pub fn is_agent_marker(name: &str) -> bool {
176 host_of(name).is_some()
177}
178
179fn host_of(name: &str) -> Option<&'static Host> {
180 if USER_CONFIGURATION.contains(&name) {
181 return None;
182 }
183 HOSTS.iter().find(|host| {
184 host.exact.contains(&name) || host.prefixes.iter().any(|p| name.starts_with(p))
185 })
186}
187
188pub fn detect<I, K, V>(vars: I) -> Option<AgentSession>
191where
192 I: IntoIterator<Item = (K, V)>,
193 K: AsRef<str>,
194 V: AsRef<str>,
195{
196 let vars: BTreeMap<String, String> = vars
197 .into_iter()
198 .filter(|(_, value)| !value.as_ref().trim().is_empty())
199 .map(|(name, value)| (name.as_ref().to_string(), value.as_ref().to_string()))
200 .collect();
201 let markers: Vec<String> = vars
202 .keys()
203 .filter(|name| is_agent_marker(name))
204 .cloned()
205 .collect();
206 HOSTS.iter().find_map(|host| {
207 let marker = vars
208 .keys()
209 .find(|name| host_of(name).is_some_and(|h| h.name == host.name))?;
210 let session_id = host
211 .session_vars
212 .iter()
213 .filter_map(|var| vars.get(*var))
214 .find_map(|value| sanitize_id(value));
215 Some(AgentSession {
216 host: host.name,
217 marker: marker.clone(),
218 markers: markers.clone(),
219 session_id,
220 })
221 })
222}
223
224pub fn detect_from_env() -> Option<AgentSession> {
226 detect(std::env::vars_os().filter_map(|(name, value)| {
227 Some((
228 name.into_string().ok()?,
229 value.to_string_lossy().into_owned(),
230 ))
231 }))
232}
233
234fn sanitize_id(value: &str) -> Option<String> {
235 let id: String = value
236 .chars()
237 .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
238 .take(MAX_SESSION_ID)
239 .collect();
240 (!id.is_empty()).then_some(id)
241}