feat(telemetry): fingerprint sandbox runtime and agent vendor

Add two new properties to every CLI telemetry event so we can tell
managed-sandbox traffic (Codex Cloud, Claude Code Web, etc.) apart from
real developer laptops without geolocation guesswork:

- sandbox_runtime: 'gvisor' | 'firecracker' | 'docker' | 'kvm' | 'wsl' | null
  gVisor detected via kernel string ('4.19.0-gvisor' or legacy Sentry
  '4.4.0') + /proc/version. Firecracker via /dev/vsock + DMI sys_vendor.
  Docker reuses the existing /.dockerenv + cgroup probe.

- agent_runtime: claude_code | codex | cursor | copilot_agent | jules
  | replit | devin | aider | gemini_cli | hermes | openclaw | null
  Detected by the EXISTENCE of well-known vendor env vars only — values
  are never read. Hermes rule keys on HERMES_QUIET=1 (set unconditionally
  at hermes-agent/cli.py:50). openclaw rule keys on OPENCLAW_STATE_DIR
  or OPENCLAW_CONFIG_PATH (set explicitly in the spawned child env at
  openclaw/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts).

Drive-by cleanups required by fallow because system.ts and client.ts
fall into the audit scope of this PR:
- Extract detectWSL into platform.ts to break the system.ts ↔ agent_runtime.ts cycle.
- Refactor detectCI / getCIName into a single CI_PROVIDERS table.
- Dedupe flush / flushSync via a shared drainQueueToPayload helper.

Privacy posture unchanged: HYPERFRAMES_NO_TELEMETRY=1 still opts out;
disclosure in docs/packages/cli.mdx updated to enumerate the new fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
James
2026-05-22 19:10:30 -04:00
committed by James Russo
co-authored by Claude Opus 4.7
parent e9c515dedd
commit 0c6012a2ec
6 changed files with 512 additions and 62 deletions
@@ -0,0 +1,202 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
// agent_runtime.ts reads node:os via release/platform and node:fs for the
// /proc files. detectAgentRuntime is exercised by mutating process.env;
// detectSandboxRuntime is exercised through a small set of node:os mocks.
const VENDOR_ENV_KEYS = [
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CODEX_HOME",
"CODEX_SANDBOX",
"CODEX_SANDBOX_NETWORK_DISABLED",
"CURSOR_TRACE_ID",
"CURSOR_AGENT",
"TERM_PROGRAM",
"GITHUB_ACTIONS",
"COPILOT_AGENT_ID",
"RUNNER_NAME",
"JULES_TASK_ID",
"JULES_SESSION",
"REPL_ID",
"REPLIT_USER",
"DEVIN_SESSION_ID",
"AIDER_RUN_ID",
"GEMINI_CLI",
"HERMES_QUIET",
"_HERMES_GATEWAY",
"HERMES_INFERENCE_PROVIDER",
"OPENCLAW_CLI",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
] as const;
function stripVendorEnv(): void {
for (const key of VENDOR_ENV_KEYS) delete process.env[key];
}
describe("detectAgentRuntime — base behavior", () => {
const savedEnv = { ...process.env };
beforeEach(stripVendorEnv);
afterEach(() => {
process.env = { ...savedEnv };
});
it("returns null on a plain shell with no agent markers", async () => {
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBeNull();
});
it("first matching vendor wins (rule order)", async () => {
// Claude Code marker set alongside a Codex marker — Claude Code is the
// first rule, so it wins.
process.env["CLAUDECODE"] = "1";
process.env["CODEX_HOME"] = "/home/codex";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("claude_code");
});
it("never reads env-var values — even API-key-shaped values stay unread", async () => {
process.env["CODEX_HOME"] = "/home/codex";
process.env["CODEX_API_KEY"] = "sk-supersecret-DO-NOT-LEAK";
const { detectAgentRuntime } = await import("./agent_runtime.js");
const result = detectAgentRuntime();
expect(result).toBe("codex");
expect(typeof result).toBe("string");
expect((result ?? "").includes("supersecret")).toBe(false);
});
});
describe("detectAgentRuntime — Claude Code", () => {
const savedEnv = { ...process.env };
beforeEach(stripVendorEnv);
afterEach(() => {
process.env = { ...savedEnv };
});
it("detects via CLAUDECODE=1", async () => {
process.env["CLAUDECODE"] = "1";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("claude_code");
});
it("detects via CLAUDE_CODE_ENTRYPOINT", async () => {
process.env["CLAUDE_CODE_ENTRYPOINT"] = "cli";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("claude_code");
});
});
describe("detectAgentRuntime — OpenAI Codex", () => {
const savedEnv = { ...process.env };
beforeEach(stripVendorEnv);
afterEach(() => {
process.env = { ...savedEnv };
});
it("detects via CODEX_HOME", async () => {
process.env["CODEX_HOME"] = "/home/codex";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("codex");
});
it("detects via CODEX_SANDBOX_NETWORK_DISABLED", async () => {
process.env["CODEX_SANDBOX_NETWORK_DISABLED"] = "1";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("codex");
});
});
describe("detectAgentRuntime — Cursor / Copilot / cohort", () => {
const savedEnv = { ...process.env };
beforeEach(stripVendorEnv);
afterEach(() => {
process.env = { ...savedEnv };
});
it("detects Cursor via TERM_PROGRAM=cursor", async () => {
process.env["TERM_PROGRAM"] = "cursor";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("cursor");
});
it("detects Copilot Coding Agent via GITHUB_ACTIONS + COPILOT_AGENT_ID", async () => {
process.env["GITHUB_ACTIONS"] = "true";
process.env["COPILOT_AGENT_ID"] = "abc123";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("copilot_agent");
});
it("does NOT flag generic GitHub Actions as copilot_agent", async () => {
process.env["GITHUB_ACTIONS"] = "true";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBeNull();
});
});
describe("detectAgentRuntime — Jules / Replit / Devin / Hermes / openclaw", () => {
const savedEnv = { ...process.env };
beforeEach(stripVendorEnv);
afterEach(() => {
process.env = { ...savedEnv };
});
it("detects Jules via JULES_TASK_ID", async () => {
process.env["JULES_TASK_ID"] = "task-1";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("jules");
});
it("detects Replit via REPL_ID", async () => {
process.env["REPL_ID"] = "repl-1";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("replit");
});
it("detects Devin via DEVIN_SESSION_ID", async () => {
process.env["DEVIN_SESSION_ID"] = "sess-1";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("devin");
});
it("detects Hermes via HERMES_QUIET=1 (set unconditionally by cli.py)", async () => {
process.env["HERMES_QUIET"] = "1";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("hermes");
});
it("detects openclaw via inherited OPENCLAW_STATE_DIR", async () => {
process.env["OPENCLAW_STATE_DIR"] = "/tmp/openclaw";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("openclaw");
});
});
describe("detectSandboxRuntime — kernel-string path", () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});
it("reports gvisor for a 4.19.0-gvisor kernel string", async () => {
vi.doMock("node:os", async () => {
const actual = await vi.importActual<typeof import("node:os")>("node:os");
return { ...actual, release: () => "4.19.0-gvisor", platform: () => "linux" };
});
const { detectSandboxRuntime } = await import("./agent_runtime.js");
expect(detectSandboxRuntime()).toBe("gvisor");
});
it("reports gvisor for the legacy 4.4.0 Sentry kernel string", async () => {
vi.doMock("node:os", async () => {
const actual = await vi.importActual<typeof import("node:os")>("node:os");
return { ...actual, release: () => "4.4.0", platform: () => "linux" };
});
const { detectSandboxRuntime } = await import("./agent_runtime.js");
expect(detectSandboxRuntime()).toBe("gvisor");
});
});
+214
View File
@@ -0,0 +1,214 @@
import { existsSync, readFileSync } from "node:fs";
import { platform, release } from "node:os";
import { detectWSL } from "./platform.js";
// ---------------------------------------------------------------------------
// Sandbox runtime + agent vendor fingerprinting.
//
// Goal: distinguish "real developer laptop" from "ephemeral managed sandbox
// driving the CLI on someone's behalf" (Codex Cloud, Claude Code Web, Cursor
// Background Agents, etc.) without collecting any PII.
//
// We only read:
// - well-known kernel strings (release(), /proc/version)
// - sandbox marker files (/.dockerenv etc.)
// - the *existence* of vendor environment variables — never the value
// (some are API keys).
//
// Output is two opaque strings: sandbox_runtime ('gvisor' | 'docker' | ...)
// and agent_runtime ('claude_code' | 'codex' | ...). Both null when unknown.
// ---------------------------------------------------------------------------
export type SandboxRuntime = "gvisor" | "firecracker" | "docker" | "kvm" | "wsl" | null;
export type AgentRuntime =
| "claude_code"
| "codex"
| "cursor"
| "copilot_agent"
| "jules"
| "replit"
| "devin"
| "aider"
| "gemini_cli"
| "hermes"
| "openclaw"
| null;
interface VendorRule {
name: Exclude<AgentRuntime, null>;
/** Check returns true when the named agent is driving the CLI. */
check: (env: NodeJS.ProcessEnv) => boolean;
}
// Ordering matters: the FIRST rule that matches wins. Put more specific rules
// before more generic ones (e.g. copilot_agent before a hypothetical generic
// 'github_actions' rule).
const VENDOR_RULES: VendorRule[] = [
// Anthropic Claude Code — local Claude Code spawns subprocesses with
// CLAUDECODE=1 and an entrypoint marker. Same env vars appear in the
// Claude Code Web sandbox.
{
name: "claude_code",
check: (env) => env["CLAUDECODE"] === "1" || typeof env["CLAUDE_CODE_ENTRYPOINT"] === "string",
},
// OpenAI Codex — Codex CLI and Codex Cloud both set CODEX_HOME, and the
// managed sandbox additionally sets CODEX_SANDBOX_NETWORK_DISABLED.
{
name: "codex",
check: (env) =>
typeof env["CODEX_HOME"] === "string" ||
typeof env["CODEX_SANDBOX"] === "string" ||
typeof env["CODEX_SANDBOX_NETWORK_DISABLED"] === "string",
},
// Cursor IDE + Cursor Background Agents.
{
name: "cursor",
check: (env) =>
typeof env["CURSOR_TRACE_ID"] === "string" ||
typeof env["CURSOR_AGENT"] === "string" ||
env["TERM_PROGRAM"] === "cursor",
},
// GitHub Copilot Coding Agent runs inside GitHub Actions, but Copilot's
// workflow injects an extra marker that distinguishes it from generic CI.
{
name: "copilot_agent",
check: (env) =>
env["GITHUB_ACTIONS"] === "true" &&
(typeof env["COPILOT_AGENT_ID"] === "string" || env["RUNNER_NAME"] === "Copilot"),
},
// Google Jules.
{
name: "jules",
check: (env) =>
typeof env["JULES_TASK_ID"] === "string" || typeof env["JULES_SESSION"] === "string",
},
// Replit / Replit Agent.
{
name: "replit",
check: (env) => typeof env["REPL_ID"] === "string" || typeof env["REPLIT_USER"] === "string",
},
// Devin (Cognition).
{
name: "devin",
check: (env) => typeof env["DEVIN_SESSION_ID"] === "string",
},
// Aider.
{
name: "aider",
check: (env) => typeof env["AIDER_RUN_ID"] === "string",
},
// Gemini CLI — sets a known env var when invoking shell tools.
{
name: "gemini_cli",
check: (env) => typeof env["GEMINI_CLI"] === "string",
},
// Nous Research Hermes Agent — cli.py:50 unconditionally executes
// os.environ["HERMES_QUIET"] = "1"
// at module load, so the marker propagates via os.environ to every
// subprocess spawned by Hermes.
// Source: https://github.com/NousResearch/hermes-agent (cli.py:50)
{
name: "hermes",
check: (env) => env["HERMES_QUIET"] === "1",
},
// openclaw — multi-channel AI gateway. When openclaw spawns a CLI
// subprocess it builds the child env with OPENCLAW_STATE_DIR /
// OPENCLAW_CONFIG_PATH / OPENCLAW_DISABLE_AUTO_UPDATE set explicitly
// (extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts:344-351).
// We key on OPENCLAW_STATE_DIR since it's a path scope-bound to openclaw.
// Source: https://github.com/openclaw/openclaw
{
name: "openclaw",
check: (env) =>
typeof env["OPENCLAW_STATE_DIR"] === "string" ||
typeof env["OPENCLAW_CONFIG_PATH"] === "string",
},
];
/**
* Identify the managed sandbox runtime hosting this CLI invocation.
* Returns null on a normal developer machine. Dispatches to runtime-specific
* detectors that each return a boolean; the priority order encoded here is
* deliberate (WSL > gVisor > Docker > Firecracker > KVM).
*/
export function detectSandboxRuntime(): SandboxRuntime {
if (platform() === "win32") return null;
if (detectWSL()) return "wsl";
if (isGVisor()) return "gvisor";
if (isDocker()) return "docker";
if (isFirecracker()) return "firecracker";
if (isKVM()) return "kvm";
return null;
}
/**
* Identify the coding-agent vendor that spawned this process, if any.
* Returns null on a regular interactive shell. Only checks for the
* EXISTENCE of well-known env vars — never reads their values.
*/
export function detectAgentRuntime(): AgentRuntime {
for (const rule of VENDOR_RULES) {
if (rule.check(process.env)) return rule.name;
}
return null;
}
// ---------------------------------------------------------------------------
// Sandbox runtime detectors — one per runtime, kept small and side-effect-free.
// ---------------------------------------------------------------------------
/**
* gVisor reports kernel string `4.19.0-gvisor` (current) or `4.4.0` (legacy
* Sentry kernel). Both are unambiguous: no production Linux box reports
* either today. /proc/version is the backup signal.
*/
function isGVisor(): boolean {
const kernel = release();
if (kernel === "4.4.0" || kernel.includes("gvisor")) return true;
if (platform() !== "linux") return false;
try {
return readFileSync("/proc/version", "utf-8").includes("gVisor");
} catch {
return false;
}
}
function isDocker(): boolean {
if (existsSync("/.dockerenv")) return true;
if (platform() !== "linux") return false;
try {
const cgroup = readFileSync("/proc/1/cgroup", "utf-8");
return cgroup.includes("docker") || cgroup.includes("containerd");
} catch {
return false;
}
}
/**
* AWS Firecracker microVMs expose /dev/vsock and report sys_vendor='Amazon EC2'
* with product_name containing 'Firecracker'. Full EC2 reports a real instance
* type like 't3.large', so the product_name check distinguishes them.
*/
function isFirecracker(): boolean {
if (platform() !== "linux") return false;
if (!existsSync("/dev/vsock")) return false;
try {
const sysVendor = readFileSync("/sys/class/dmi/id/sys_vendor", "utf-8").trim();
if (sysVendor !== "Amazon EC2") return false;
const productName = readFileSync("/sys/class/dmi/id/product_name", "utf-8").trim();
return productName.toLowerCase().includes("firecracker");
} catch {
return false;
}
}
function isKVM(): boolean {
if (platform() !== "linux") return false;
try {
const sysVendor = readFileSync("/sys/class/dmi/id/sys_vendor", "utf-8").trim();
return sysVendor === "QEMU" || sysVendor.includes("KVM");
} catch {
return false;
}
}
+29 -30
View File
@@ -17,7 +17,7 @@ const FLUSH_TIMEOUT_MS = 5_000;
// ---------------------------------------------------------------------------
interface EventProperties {
[key: string]: string | number | boolean | undefined;
[key: string]: string | number | boolean | null | undefined;
}
let eventQueue: Array<{
@@ -81,30 +81,41 @@ export function trackEvent(event: string, properties: EventProperties = {}): voi
ci_name: sys.ci_name ?? undefined,
is_wsl: sys.is_wsl,
is_tty: sys.is_tty,
sandbox_runtime: sys.sandbox_runtime ?? undefined,
agent_runtime: sys.agent_runtime ?? undefined,
},
timestamp: new Date().toISOString(),
});
}
/**
* Drain the in-memory queue into a PostHog `/batch/` payload string.
* Returns null when there's nothing to send. Resets the queue as a side effect
* so callers can fire-and-forget the resulting payload.
*
* $ip:null tells PostHog not to record the request IP for any of these events.
* Server-side "Discard client IP data" is also enabled in project settings.
*/
function drainQueueToPayload(): string | null {
if (eventQueue.length === 0) return null;
const config = readConfig();
const batch = eventQueue.map((e) => ({
event: e.event,
properties: { ...e.properties, $ip: null },
distinct_id: config.anonymousId,
timestamp: e.timestamp,
}));
eventQueue = [];
return JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
}
/**
* Flush all queued events to PostHog via async HTTP POST.
* Called before normal process exit via `beforeExit`.
*/
export async function flush(): Promise<void> {
if (eventQueue.length === 0) {
return;
}
const config = readConfig();
const batch = eventQueue.map((e) => ({
event: e.event,
// $ip: null tells PostHog to not record the request IP for this event.
// Server-side "Discard client IP data" is also enabled in project settings.
properties: { ...e.properties, $ip: null },
distinct_id: config.anonymousId,
timestamp: e.timestamp,
}));
eventQueue = [];
const payload = drainQueueToPayload();
if (payload == null) return;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
@@ -113,7 +124,7 @@ export async function flush(): Promise<void> {
await fetch(`${POSTHOG_HOST}/batch/`, {
method: "POST",
headers: { "Content-Type": "application/json", Connection: "close" },
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
body: payload,
signal: controller.signal,
});
} catch {
@@ -129,20 +140,8 @@ export async function flush(): Promise<void> {
* so the parent process exits immediately without waiting.
*/
export function flushSync(): void {
if (eventQueue.length === 0) {
return;
}
const config = readConfig();
const batch = eventQueue.map((e) => ({
event: e.event,
properties: { ...e.properties, $ip: null },
distinct_id: config.anonymousId,
timestamp: e.timestamp,
}));
eventQueue = [];
const payload = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
const payload = drainQueueToPayload();
if (payload == null) return;
try {
const { spawn } = require("node:child_process") as typeof import("node:child_process");
+18
View File
@@ -0,0 +1,18 @@
import { platform, release } from "node:os";
import { readFileSync } from "node:fs";
// Shared host-platform detectors used by both system.ts (overall metadata)
// and agent_runtime.ts (sandbox fingerprinting). Lives in its own module
// to avoid an import cycle between those two files.
export function detectWSL(): boolean {
if (platform() !== "linux") return false;
try {
const osRelease = release().toLowerCase();
if (osRelease.includes("microsoft") || osRelease.includes("wsl")) return true;
const procVersion = readFileSync("/proc/version", "utf-8").toLowerCase();
return procVersion.includes("microsoft") || procVersion.includes("wsl");
} catch {
return false;
}
}
+48 -31
View File
@@ -1,5 +1,12 @@
import { cpus, totalmem, platform, release } from "node:os";
import { existsSync, readFileSync, statfsSync } from "node:fs";
import {
detectAgentRuntime,
detectSandboxRuntime,
type AgentRuntime,
type SandboxRuntime,
} from "./agent_runtime.js";
import { detectWSL } from "./platform.js";
// ---------------------------------------------------------------------------
// System metadata collected once per CLI session and attached to all events.
@@ -23,6 +30,20 @@ export interface SystemMeta {
ci_name: string | null;
is_wsl: boolean;
is_tty: boolean;
/**
* Managed sandbox runtime hosting this invocation, when one is detectable
* (gvisor / firecracker / docker / kvm / wsl). null on a normal dev
* machine. Lets us distinguish "real laptop" from "ephemeral cloud
* sandbox driving the CLI" without geo guesswork.
*/
sandbox_runtime: SandboxRuntime;
/**
* Coding-agent vendor that spawned this process, if any (claude_code,
* codex, cursor, copilot_agent, jules, replit, devin, aider, gemini_cli).
* Detected by env-var existence only — values are never read. null when
* no agent is detected (i.e. a human invoked the CLI directly).
*/
agent_runtime: AgentRuntime;
}
let cached: SystemMeta | null = null;
@@ -48,6 +69,8 @@ export function getSystemMeta(): SystemMeta {
ci_name: getCIName(),
is_wsl: detectWSL(),
is_tty: Boolean(process.stdout?.isTTY),
sandbox_runtime: detectSandboxRuntime(),
agent_runtime: detectAgentRuntime(),
};
return cached;
}
@@ -70,42 +93,36 @@ function detectDocker(): boolean {
return false;
}
// Each entry: env var name, optional named CI provider, predicate.
// Named providers come first so getCIName() picks the most specific match.
// `truthy` accepts 'true' or '1' to cover both common conventions.
const CI_PROVIDERS: Array<{ name: string | null; envVar: string; truthy?: true; presence?: true }> =
[
{ name: "github_actions", envVar: "GITHUB_ACTIONS", truthy: true },
{ name: "gitlab_ci", envVar: "GITLAB_CI", truthy: true },
{ name: "circleci", envVar: "CIRCLECI", truthy: true },
{ name: "jenkins", envVar: "JENKINS_URL", presence: true },
{ name: "buildkite", envVar: "BUILDKITE", truthy: true },
{ name: "travis", envVar: "TRAVIS", truthy: true },
{ name: null, envVar: "CONTINUOUS_INTEGRATION", truthy: true },
{ name: null, envVar: "CI", truthy: true },
];
function matchesProvider(p: (typeof CI_PROVIDERS)[number]): boolean {
const v = process.env[p.envVar];
if (p.presence) return v != null;
return v === "true" || v === "1";
}
function detectCI(): boolean {
return (
process.env["CI"] === "true" ||
process.env["CI"] === "1" ||
process.env["CONTINUOUS_INTEGRATION"] === "true" ||
process.env["GITHUB_ACTIONS"] === "true" ||
process.env["GITLAB_CI"] === "true" ||
process.env["CIRCLECI"] === "true" ||
process.env["JENKINS_URL"] != null ||
process.env["BUILDKITE"] === "true" ||
process.env["TRAVIS"] === "true" ||
false
);
return CI_PROVIDERS.some(matchesProvider);
}
function getCIName(): string | null {
if (process.env["GITHUB_ACTIONS"] === "true") return "github_actions";
if (process.env["GITLAB_CI"] === "true") return "gitlab_ci";
if (process.env["CIRCLECI"] === "true") return "circleci";
if (process.env["JENKINS_URL"] != null) return "jenkins";
if (process.env["BUILDKITE"] === "true") return "buildkite";
if (process.env["TRAVIS"] === "true") return "travis";
if (detectCI()) return "unknown";
return null;
}
function detectWSL(): boolean {
if (platform() !== "linux") return false;
try {
const osRelease = release().toLowerCase();
if (osRelease.includes("microsoft") || osRelease.includes("wsl")) return true;
const procVersion = readFileSync("/proc/version", "utf-8").toLowerCase();
return procVersion.includes("microsoft") || procVersion.includes("wsl");
} catch {
return false;
for (const provider of CI_PROVIDERS) {
if (provider.name && matchesProvider(provider)) return provider.name;
}
return detectCI() ? "unknown" : null;
}
// ---------------------------------------------------------------------------