Skip to main content

hikmah_kernel/
principal.rs

1//! Who is writing: detect an AI agent session from the environment its host sets.
2//!
3//! `source` and `verified` on a trace are claims made by whoever runs the command; the kernel does
4//! not authenticate them. An agent could otherwise write `--source human:x --verified` and turn
5//! its own output into "verified" memory. When a coding-agent host's variables are present
6//! (`CLAUDECODE`, `CLAUDE_CODE_*`, `CODEX_*`, `CURSOR_*`, `GEMINI_CLI`, `AI_AGENT`), the CLI
7//! [`stamp`](AgentSession::stamp)s the trace: its locator records `agent-session:<host>:<id>` and
8//! it cannot be marked verified. [`Trace::validate`](crate::trace::Trace::validate) enforces the
9//! second half for every write path. The CLI also refuses, in such a session, the commands that
10//! accept ledger records no hikmah write acknowledged (`verify-ledger --accept-tail` and
11//! `--reset-head`; see [`AgentSession::refuse_person_only`]): those records may be a forged append,
12//! and an agent that follows a refusal message must not be the one to approve them.
13//!
14//! This is a guard against self-verification by default, not authentication. A process that clears
15//! these variables is not detected. A variable a person's own shell or IDE terminal sets counts as
16//! a marker (except the documented configuration settings in [`USER_CONFIGURATION`]); the refusal
17//! names it so that person can run the command from a shell without it. Authenticated attestation,
18//! meaning a signature with a key the agent cannot read, is not implemented.
19use crate::error::{KernelError, Result};
20use crate::trace::Trace;
21use std::collections::BTreeMap;
22
23/// Locator prefix of a trace written from a detected agent session.
24pub 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    /// Variables that carry the host's session id, in order of preference.
31    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
67/// Configuration a person commonly exports in their own shell profile (documented settings of the
68/// host tools). These configure the tool; they do not show that an agent is running this process,
69/// so they are not markers. Claude Code always sets `CLAUDECODE` in the shells it runs, so leaving
70/// its settings out does not hide a Claude Code session.
71pub 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
89/// Where a person whose own shell is mistaken for an agent session finds how to proceed.
90const FALSE_POSITIVE_HELP: &str = "\"Agent sessions\" in docs/MEMORY.md";
91/// A refusal names at most this many marker variables (an agent host can set dozens).
92const LISTED_MARKERS: usize = 5;
93
94const MAX_SESSION_ID: usize = 64;
95
96/// An AI agent session detected from environment variables.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct AgentSession {
99    /// `claude-code`, `codex`, `cursor`, `gemini-cli`, or `ai-agent`.
100    pub host: &'static str,
101    /// The environment variable that identified the session.
102    pub marker: String,
103    /// Every non-empty marker variable present, for any host, sorted by name.
104    pub markers: Vec<String>,
105    /// The host's session id (ASCII letters, digits, `-`, `_`, `.`; at most 64), when it sets one.
106    pub session_id: Option<String>,
107}
108
109impl AgentSession {
110    /// `agent-session:<host>:<session id or "unknown">`.
111    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    /// Apply the agent-session rule to a trace this session is about to write: refuse `verified`
120    /// (an agent cannot attest its own memory) and record the session as the locator, keeping any
121    /// caller locator after it.
122    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    /// The refusal for a command only a person may run inside this session, such as accepting
139    /// ledger records no hikmah write acknowledged (`verify-ledger --accept-tail` or
140    /// `--reset-head`). Those records may be a forged append (the chain is unkeyed), and an agent
141    /// that follows a refusal message must not be the one to approve them. The message tells the
142    /// agent to stop and ask a person; it deliberately gives no command that gets around the check.
143    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
174/// True when `name` alone marks an agent session (an empty value does not; see [`detect`]).
175pub 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
188/// Detect an agent session from `(name, value)` pairs. Variables with an empty value are ignored.
189/// Deterministic: hosts are checked in a fixed order and variable names in sorted order.
190pub 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
224/// [`detect`] over this process's environment (variables that are not valid Unicode are skipped).
225pub 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}