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
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions cli-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ jsonwebtoken = { version = "11", optional = true, default-features = false }
keyring = { version = "3.6.1", optional = true, default-features = false }
open = { version = "5.4.1", optional = true }
rand = { version = "0.9", optional = true }
sha2 = { version = "0.10.9", optional = true }
sha2 = "0.10.9"
url = { version = "2.5.4", optional = true }
zeroize = { version = "1.8", optional = true, features = ["derive"] }
chrono = { version = "0.4.42", default-features = false, features = ["clock", "serde"] }
clap = { version = "4.5.53", features = ["derive", "std", "string"] }
clap_complete = "4"
is-ai-agent = "0.6"
jmespath = "0.5.0"
reqwest = { version = "0.13", default-features = false, features = ["json", "multipart", "form", "rustls"] }
regex = "1.12.2"
Expand All @@ -53,7 +54,7 @@ keyring = { version = "3.6.1", optional = true, default-features = false, featur
keyring = { version = "3.6.1", optional = true, default-features = false, features = ["windows-native"] }

[features]
pkce-auth = ["dep:jsonwebtoken", "dep:keyring", "dep:open", "dep:rand", "dep:sha2", "dep:url", "dep:zeroize"]
pkce-auth = ["dep:jsonwebtoken", "dep:keyring", "dep:open", "dep:rand", "dep:url", "dep:zeroize"]

[dev-dependencies]
pretty_assertions = "1.4.1"
Expand Down
52 changes: 52 additions & 0 deletions cli-engine/docs/attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Client attribution

Client attribution lets a CLI built on cli-engine tell the services it calls what kind of caller it is (a person at a terminal, a CI job, a script, or an AI coding agent) so usage can be understood without any telemetry channel. It adds a few tokens to the `User-Agent` header and, when an AI harness exposes a session id, one correlation header. It sends nothing to any endpoint the CLI was not already calling, writes nothing to disk, and never contacts a collector.

It is off by default. A CLI opts in with `CliConfig::with_client_attribution`.

## What is sent

| Where | Value | When |
| --- | --- | --- |
| `User-Agent` | `<name>/<version> mode/<mode>` | Always, once enabled |
| `User-Agent` | `agent/<slug>` (for example `agent/claude-code`) | A known AI harness is detected |
| `x-client-session` (name configurable) | 16 hex characters: a salted SHA-256 prefix of the harness session id | The harness exposes a session id, and the user has not opted out |

`<mode>` is the first that applies: `agent` (a harness marker is present), `ci` (the `CI` variable is set to a non-blank value other than `0`, `false`, `no`, or `off`, compared case-insensitively), `interactive` (stdin and stderr are terminals), or `script` (none of the above).

An example, from a Claude Code session: `User-Agent: gddy/1.4.0 mode/agent agent/claude-code` and `x-gddy-session: 3f9a0c51d27be8a4`.

## What is not sent

The raw session id never leaves the process. Harness ids are often meaningful on the user's machine (transcript file names, resume handles), so only a one-way hash is sent. The hash is salted with the CLI's app id and the agent slug, so the same session hashes differently in different CLIs and cannot be joined across unrelated products.

No persistent identifier is created. For a person at a terminal, or any caller without a harness session id, no session header is sent at all. Nothing is stored between runs.

## Opting out

Users can drop the session header with `<APP_ID>_NO_SESSION_ID=1`, where `<APP_ID>` is the CLI's app id uppercased with non-alphanumerics replaced by `_` (`gddy` becomes `GDDY_NO_SESSION_ID`). The user-agent tokens are not affected by this variable: they describe the kind of caller in the same way the binary name and version already do. CLI authors can also disable the header entirely with `AttributionConfig::without_session_id`.

## Seeing exactly what goes out

Run any command with `--debug=transport` to print each outbound request's headers to stderr, including the user-agent and the session header.

## How detection works, and its limits

Detection reads environment variables that AI harnesses publish to the processes they launch, using the [`is-ai-agent`](https://github.com/sdairs/is-ai-agent) crate. It also checks whether a small fixed set of marker paths defined by that crate exists (currently only `/opt/.devin`, which identifies Devin). Those checks test existence only: no file contents are read and no directories are listed. It is cooperative and heuristic. A match does not prove a model issued this particular command (a human can run commands in a terminal a harness opened), and no match does not prove a human did. Treat the result as attribution to a harness, not as proof of intent. Session ids have different scopes per harness (a conversation, a thread, a single run), so the hash correlates calls within one harness only.

## Enabling it (CLI authors)

```rust
use cli_engine::{BuildInfo, CliConfig};
use cli_engine::transport::AttributionConfig;

let config = CliConfig::new("my-cli", "Team CLI", "my-cli")
.with_build(BuildInfo::new(env!("CARGO_PKG_VERSION")))
.with_client_attribution(
AttributionConfig::new().with_session_header("x-my-cli-session"),
);
```

The engine resolves attribution once per execution, before any command runs, and publishes it process-wide. Publishing happens in the `execute*` entrypoints, after argv0 resolution, so an argv0 personality publishes its own identity and not the dispatcher's. `Cli::run` deliberately does not publish (so running a `Cli` in tests never mutates process-wide state); a harness that drives `Cli::run` and needs outbound requests to carry the identity must publish it itself. The user-agent and default headers are published and read together, so a client never sees one without the other. It is applied to every `HttpClient` and to every client built from `transport::reqwest_client_builder()` (the entry point for generated or hand-rolled `reqwest` clients), **provided the client is created after publication**. Both capture the process identity at the moment they are created, so build clients inside command handlers (which run after `execute*` has published), not during module registration or `Cli::new`, which runs earlier. A client created too early keeps the identity that was current then and silently omits attribution. The user-agent tokens also reach the engine's own OAuth token requests; the session header does not.

Because the headers are process-wide defaults, they are sent to whatever host those clients call. Build clients that talk to third-party hosts from a plain `reqwest::Client` if the session header should not reach them.
4 changes: 4 additions & 0 deletions cli-engine/docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,10 @@ The transport module provides a `reqwest`-based HTTP client with:
Auth injectors include bearer token, provider bearer, cookie, basic auth, API key, client
credentials, and no-op injectors.

Code that needs a plain `reqwest::Client` (progenitor-generated clients, hand-rolled streaming or multipart uploads) builds it from `transport::reqwest_client_builder()` instead of `reqwest::Client::builder()`. The builder comes preconfigured with the process-wide user-agent and default headers, a connect timeout, and an idle read timeout, so outbound policy is defined once in the engine. Callers layer their own settings on top.

A CLI can opt into client attribution (user-agent tokens for the kind of caller, and a hashed harness session id) with `CliConfig::with_client_attribution`. See [attribution.md](attribution.md) for exactly what is sent, how to opt out, and the limits of detection.

### HTTP debug logging

The global `--debug` flag drives transport diagnostics through the `transport` component. Bare `--debug` enables every component; to select one, use the `=` form so the value is not mistaken for the command: `--debug=transport`, or `--debug='*,-transport'` to keep everything else but silence HTTP. (As an optional-value global flag, `--debug` only attaches a space-separated value when it appears after the leaf command; before the command, write `--debug=transport`.) `flags::debug_component_enabled` parses the comma-separated pattern.
Expand Down
19 changes: 16 additions & 3 deletions cli-engine/src/cli/argv0.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,15 @@ pub(super) enum Argv0Outcome {
/// with a fully rendered result when a personality ran or an explicit `argv0`
/// invocation was rejected. When no routes are registered this is inert and
/// returns the arguments unchanged. `depth` counts chained hand-offs and
/// bounds recursion via [`MAX_ARGV0_DEPTH`].
pub(super) async fn resolve_argv0(cli: &Cli, text_args: Vec<String>, depth: usize) -> Argv0Outcome {
/// bounds recursion via [`MAX_ARGV0_DEPTH`]. `publish_identity` is forwarded to a
/// personality's own run so the CLI that actually executes publishes its
/// outbound identity, not the dispatcher's.
pub(super) async fn resolve_argv0(
cli: &Cli,
text_args: Vec<String>,
depth: usize,
publish_identity: bool,
) -> Argv0Outcome {
if cli.config.argv0_routes.is_empty() {
return Argv0Outcome::Proceed(text_args);
}
Expand Down Expand Up @@ -162,7 +169,13 @@ pub(super) async fn resolve_argv0(cli: &Cli, text_args: Vec<String>, depth: usiz
alt_args.push(bin);
alt_args.extend(rest);
Argv0Outcome::Handled(
Box::pin(super::run::run_with_depth(&alt, alt_args, depth + 1)).await,
Box::pin(super::run::run_with_depth(
&alt,
alt_args,
depth + 1,
publish_identity,
))
.await,
)
}
None if explicit => Argv0Outcome::Handled(render_argv0_error(
Expand Down
21 changes: 21 additions & 0 deletions cli-engine/src/cli/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ pub struct CliConfig {
/// the engine derives `name/version` from this config. See
/// [`CliConfig::user_agent_string`].
pub user_agent: Option<String>,
/// Opt-in client attribution (user-agent tokens and a hashed session
/// header). See [`CliConfig::with_client_attribution`].
pub attribution: Option<crate::transport::AttributionConfig>,
/// Extra HTTP header names to redact in `--debug transport` output, on top
/// of the built-in sensitive set (`authorization`, `proxy-authorization`,
/// `cookie`, `set-cookie`, `x-api-key`). Set CLI-specific secret-bearing
Expand Down Expand Up @@ -389,6 +392,24 @@ impl CliConfig {
self
}

/// Opts this CLI into client attribution.
///
/// On execution the engine appends `mode/<agent|ci|interactive|script>`
/// (and `agent/<slug>` for a detected AI harness) to the outbound
/// User-Agent, and, when the harness exposes a session id, sends a salted
/// hash of it in a correlation header. Nothing is sent to any endpoint the
/// CLI was not already calling. The identity is captured when each client is
/// created, so create clients inside command handlers, not during module
/// registration. See `docs/attribution.md`.
#[must_use]
pub fn with_client_attribution(
mut self,
attribution: crate::transport::AttributionConfig,
) -> Self {
self.attribution = Some(attribution);
self
}

/// Adds HTTP header names to redact in `--debug transport` output, on top of
/// the built-in sensitive set.
///
Expand Down
58 changes: 56 additions & 2 deletions cli-engine/src/cli/flags_apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ pub(super) fn prescan_env_flag(mut args: impl Iterator<Item = String>) -> Option
mod user_agent_tests {
use super::*;
use crate::cli::{BuildInfo, Cli, CliConfig};
use crate::transport::Signals;

#[test]
fn user_agent_string_derives_name_and_version_by_default() {
Expand All @@ -435,7 +436,7 @@ mod user_agent_tests {
}

#[test]
fn install_default_user_agent_publishes_config_value() {
fn install_client_identity_publishes_config_value() {
let _guard = crate::transport::client::UA_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Expand All @@ -444,13 +445,66 @@ mod user_agent_tests {
let cli = Cli::new(
CliConfig::new("uatest", "UA test", "uatest").with_build(BuildInfo::new("4.5.6")),
);
cli.install_default_user_agent();
cli.install_client_identity();
assert_eq!(
crate::transport::client::default_user_agent(),
"uatest/4.5.6"
);
}

fn harness_signals(app_id: &str) -> Signals {
Signals::from_lookup(
app_id,
|name| match name {
"CLAUDECODE" => Some("1".to_owned()),
"CLAUDE_CODE_SESSION_ID" => Some("session-abc".to_owned()),
_ => None,
},
|_| false,
false,
)
}

#[test]
fn client_identity_without_attribution_is_the_plain_user_agent() {
let cli = Cli::new(
CliConfig::new("attr", "Attr test", "attr").with_build(BuildInfo::new("1.0.0")),
);

let (user_agent, headers) = cli.client_identity(&harness_signals("attr"));

assert_eq!(user_agent, "attr/1.0.0");
assert!(headers.is_empty());
}

#[test]
fn client_identity_with_attribution_extends_the_base_user_agent() {
let cli = Cli::new(
CliConfig::new("attr", "Attr test", "attr")
.with_build(BuildInfo::new("1.0.0"))
.with_client_attribution(crate::transport::AttributionConfig::new()),
);

let (user_agent, headers) = cli.client_identity(&harness_signals("attr"));

assert_eq!(user_agent, "attr/1.0.0 mode/agent agent/claude-code");
assert_eq!(headers.len(), 1);
assert!(headers.contains_key("x-client-session"));
}

#[test]
fn client_identity_extends_an_explicit_user_agent_override() {
let cli = Cli::new(
CliConfig::new("attr", "Attr test", "attr")
.with_user_agent("custom/9")
.with_client_attribution(crate::transport::AttributionConfig::new()),
);

let (user_agent, _) = cli.client_identity(&harness_signals("attr"));

assert!(user_agent.starts_with("custom/9 mode/agent"));
}

#[test]
fn install_debug_transport_logger_tracks_the_debug_pattern() {
// Asserts on `debug_transport_logger_for`'s decision directly rather
Expand Down
35 changes: 29 additions & 6 deletions cli-engine/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use crate::{
flags::{register_global_flags, register_reason_flag},
module::ModuleContext,
output::{global_human_view_registry_snapshot, global_schema_registry_snapshot},
transport::{Attribution, Signals},
};

pub use argv0::{Argv0LinkMethod, Argv0Route};
Expand Down Expand Up @@ -402,15 +403,31 @@ impl Cli {
run::execute_from_until_signal(self, args, stdout, stderr, shutdown).await
}

/// Publishes the configured outbound User-Agent process-wide so that
/// command [`HttpClient`](crate::transport::HttpClient)s and the engine's
/// own OAuth token requests share it.
/// Publishes the configured outbound identity process-wide so that
/// command [`HttpClient`](crate::transport::HttpClient)s, clients from
/// [`reqwest_client_builder`](crate::transport::reqwest_client_builder),
/// and the engine's own OAuth token requests share it.
///
/// Called from the execution entrypoints rather than [`Cli::new`] so that
/// merely constructing a `Cli` (as tests do in bulk) does not mutate global
/// state. See [`CliConfig::user_agent_string`] for resolution order.
fn install_default_user_agent(&self) {
crate::transport::set_default_user_agent(self.config.user_agent_string());
fn install_client_identity(&self) {
let (user_agent, headers) =
self.client_identity(&Signals::from_process(&self.config.app_id));
crate::transport::client::set_client_identity(user_agent, headers);
}

/// Computes the outbound User-Agent and default headers for `signals`:
/// the configured base user-agent, plus attribution tokens and headers
/// when the CLI opted in.
fn client_identity(&self, signals: &Signals) -> (String, BTreeMap<String, String>) {
let mut user_agent = self.config.user_agent_string();
let Some(config) = &self.config.attribution else {
return (user_agent, BTreeMap::new());
};
let attribution = Attribution::resolve(config, &self.config.app_id, signals);
user_agent.push_str(&attribution.user_agent_suffix);
(user_agent, attribution.headers)
}

/// Registers an auth provider after construction.
Expand Down Expand Up @@ -561,11 +578,17 @@ impl Cli {
///
/// Same `--env`/tree-pruning caveat as [`Cli::execute_from`]: see
/// [`CliConfig::with_startup_args`].
///
/// Unlike the `execute*` entrypoints, this does not publish the process-wide
/// outbound identity (user-agent, default headers, client attribution), so
/// merely running a `Cli` never mutates global state. Use an `execute*`
/// entrypoint, or `transport::set_default_user_agent`, when outbound
/// requests must carry the configured identity.
pub async fn run<I, S>(&self, args: I) -> CliRunOutput
where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString> + Clone,
{
run::run_with_depth(self, args, 0).await
run::run_with_depth(self, args, 0, false).await
}
}
24 changes: 19 additions & 5 deletions cli-engine/src/cli/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,7 @@ where
E: Write,
Shutdown: Future<Output = ()>,
{
cli.install_default_user_agent();
let output = run_until_signal(cli.run(args), shutdown).await;
let output = run_until_signal(run_with_depth(cli, args, 0, true), shutdown).await;
if output.exit_code == 130
&& output.rendered == "command interrupted\n"
&& let Some(on_shutdown) = &cli.on_shutdown
Expand All @@ -105,7 +104,17 @@ where

/// Runs the CLI like [`Cli::run`](super::Cli::run), threading the `argv0` dispatch recursion
/// `depth` so a chain of personality hand-offs is bounded by [`MAX_ARGV0_DEPTH`](super::argv0::MAX_ARGV0_DEPTH).
pub(super) async fn run_with_depth<I, S>(cli: &Cli, args: I, depth: usize) -> CliRunOutput
/// `publish_identity` installs the outbound identity process-wide once it is
/// known which CLI will actually run (after argv0 resolution), so a personality
/// hand-off publishes the personality's identity. Only the `execute*`
/// entrypoints set it: plain [`Cli::run`](super::Cli::run) must not mutate
/// process globals, since tests call it concurrently.
pub(super) async fn run_with_depth<I, S>(
cli: &Cli,
args: I,
depth: usize,
publish_identity: bool,
) -> CliRunOutput
where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString> + Clone,
Expand All @@ -118,10 +127,14 @@ where
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let text_args = match super::argv0::resolve_argv0(cli, text_args, depth).await {
let text_args = match super::argv0::resolve_argv0(cli, text_args, depth, publish_identity).await
{
Argv0Outcome::Handled(output) => return output,
Argv0Outcome::Proceed(args) => args,
};
if publish_identity {
cli.install_client_identity();
}
let mut clap_args = normalize_optional_global_flags_before_command(&cli.root, &text_args);
if has_root_version_flag(&text_args, &cli.root, &cli.config.name) {
return finish_run(
Expand Down Expand Up @@ -413,7 +426,8 @@ where
&bool_flags,
&value_flags,
);
return Box::pin(run_with_depth(cli, augmented, depth + 1)).await;
// Same CLI, identity already published above.
return Box::pin(run_with_depth(cli, augmented, depth + 1, false)).await;
}
return finish_run(
cli,
Expand Down
Loading
Loading