mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 13:27:13 +00:00
* feat(cli/telemetry): surface unrecognized agents in the agent_runtime=null bucket agent_runtime is a closed allowlist: an agent we have no rule for collapses to null with no trace of what it was, so ~18% of CLI users are unattributable and new agents stay invisible until reverse-engineered by hand. Add detectAgentHints(), a self-populating residual signal computed only for the null bucket (gated off classified events): - agent_hint: value of AGENT / AI_AGENT (the emerging self-identification convention; Crush and Goose set AGENT=<name>) — names agents the allowlist misses. - term_program: raw TERM_PROGRAM (editor name) — catches the IDE-terminal class the same way the cursor/windsurf rules do. - agent_env_hints: sorted, comma-joined "agent-ish" env-var KEY names present but matched by no vendor rule — a fingerprint that clusters by agent. Privacy stays consistent with the existing "never read secret-shaped values" stance: agent_env_hints emits key names only; the three value-reads are vars whose sole purpose is non-secret identification, each passed through a strict short-slug allowlist so anything long/spaced/secret-shaped is dropped. Breaking down agent_hint / agent_env_hints filtered to agent_runtime IS NULL AND is_tty=false gives a ranked leaderboard of new agents to promote into VENDOR_RULES. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli/telemetry): guard agent_hint/term_program against short credential-shaped values Review feedback (Magi, #1978): the short-slug allowlist in sanitizeHint() still accepted short credential-shaped values (AGENT=sk-ant-api03, AGENT=AKIAIOSFODNN7EXAMPLE, AGENT=github_pat_abc), so the "never emit a secret" claim wasn't actually enforced — only overlong values were dropped. Add a credential-shape guard on top of the slug allowlist: - known token/credential prefixes (sk-, ghp_, github_pat_, akia, ya29, ...) - any unbroken alphanumeric run >= 16 chars (key bodies, hex, base64-ish), while agent names segment on _/-/. and keep each run short. Replace the single overlong-value test with the short credential shapes from the review (parametrized) plus a positive case (gemini_managed_agent) proving real multi-segment names still pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
498 lines
22 KiB
TypeScript
498 lines
22 KiB
TypeScript
import { existsSync, readFileSync, statSync } 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"
|
|
| "replit"
|
|
| "hermes"
|
|
| "openclaw"
|
|
| "pi"
|
|
| "gemini_managed_agent"
|
|
| "windsurf"
|
|
| "cline"
|
|
| "gemini_cli"
|
|
| "crush"
|
|
| 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 — sets CLAUDECODE=1 on every Bash/PowerShell tool
|
|
// spawn (Shell.ts:321) and CLAUDE_CODE_ENTRYPOINT at startup, inherited by
|
|
// every child (main.tsx:527). Both propagate to spawned subprocesses.
|
|
// Source: confirmed by @magi from Claude Code internal source.
|
|
{
|
|
name: "claude_code",
|
|
check: (env) =>
|
|
typeof env["CLAUDECODE"] === "string" || typeof env["CLAUDE_CODE_ENTRYPOINT"] === "string",
|
|
},
|
|
// OpenAI Codex (https://github.com/openai/codex).
|
|
// - CODEX_THREAD_ID — set unconditionally on every spawned shell command
|
|
// (codex-rs/protocol/src/shell_environment.rs:6 constant, set by
|
|
// codex-rs/core/src/unified_exec/process_manager.rs:1010 and
|
|
// codex-rs/core/src/tools/runtimes/mod.rs:164).
|
|
// - CODEX_CI — hardcoded in the UNIFIED_EXEC_ENV array, always set on
|
|
// every unified-exec child (process_manager.rs:70).
|
|
// - CODEX_SANDBOX_NETWORK_DISABLED — set when network sandbox is active
|
|
// (codex-rs/core/src/sandboxing/mod.rs:135-138, default-on).
|
|
// CODEX_HOME is deliberately NOT used — it's a config override read at
|
|
// Codex startup, not propagated to spawned subprocesses.
|
|
{
|
|
name: "codex",
|
|
check: (env) =>
|
|
typeof env["CODEX_THREAD_ID"] === "string" ||
|
|
typeof env["CODEX_CI"] === "string" ||
|
|
typeof env["CODEX_SANDBOX_NETWORK_DISABLED"] === "string",
|
|
},
|
|
// Cursor IDE integrated terminal — exports TERM_PROGRAM=cursor (exact,
|
|
// lowercase). Cursor Background Agent env vars are not publicly documented;
|
|
// if a canonical marker is identified later, add it here.
|
|
{
|
|
name: "cursor",
|
|
check: (env) => env["TERM_PROGRAM"] === "cursor",
|
|
},
|
|
// Windsurf (Codeium) integrated terminal — exports TERM_PROGRAM=windsurf.
|
|
// Attested across many independent detectors (nx
|
|
// packages/nx/src/native/ide/detection.rs, adonisjs/application, ag-grid
|
|
// git-hooks). Compared case-INsensitively (unlike the exact Cursor rule
|
|
// above) because Windsurf sources disagree on casing ("windsurf" vs
|
|
// "Windsurf"); Cursor's do not, so it stays exact. Like Cursor this marks the
|
|
// editor's integrated terminal, not specifically that the Cascade agent is
|
|
// driving; under WSL/remote it can also fall back to TERM_PROGRAM=vscode.
|
|
{
|
|
name: "windsurf",
|
|
check: (env) => env["TERM_PROGRAM"]?.toLowerCase() === "windsurf",
|
|
},
|
|
// GitHub Copilot Coding Agent — runs inside GitHub Actions and the
|
|
// workflow injects an additional marker to distinguish from generic CI.
|
|
// Not yet verified from a public-source citation in this audit; the var
|
|
// names below match GitHub Copilot Coding Agent documentation but
|
|
// should be confirmed before relying on attribution.
|
|
{
|
|
name: "copilot_agent",
|
|
check: (env) =>
|
|
env["GITHUB_ACTIONS"] === "true" &&
|
|
(typeof env["COPILOT_AGENT_ID"] === "string" || env["RUNNER_NAME"] === "Copilot"),
|
|
},
|
|
// Replit — REPL_ID and REPLIT_USER are long-documented environment
|
|
// variables exposed inside every Replit workspace.
|
|
// Source: https://docs.replit.com/replit-workspace/configuring-the-environment
|
|
{
|
|
name: "replit",
|
|
check: (env) => typeof env["REPL_ID"] === "string" || typeof env["REPLIT_USER"] === "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. Keying on existence (not the literal
|
|
// "1") so we still match if Hermes ever changes the value.
|
|
// Source: https://github.com/NousResearch/hermes-agent (cli.py:50)
|
|
{
|
|
name: "hermes",
|
|
check: (env) => typeof env["HERMES_QUIET"] === "string",
|
|
},
|
|
// 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",
|
|
},
|
|
// Pi coding agent (https://pi.dev, https://github.com/earendil-works/pi).
|
|
// packages/coding-agent/src/cli.ts:13 unconditionally executes
|
|
// process.env.PI_CODING_AGENT = "true";
|
|
// at module entry, so every subprocess Pi spawns sees this marker.
|
|
{
|
|
name: "pi",
|
|
check: (env) => typeof env["PI_CODING_AGENT"] === "string",
|
|
},
|
|
// Cline (cline/cline) VS Code extension — injects CLINE_ACTIVE=true into the
|
|
// integrated terminal via vscode.TerminalOptions.env, which the terminal
|
|
// exports to every shell command run in it
|
|
// (apps/vscode/src/hosts/vscode/terminal/VscodeTerminalRegistry.ts:29).
|
|
// Caveat: present only on the default "vscodeTerminal" exec path — the opt-in
|
|
// backgroundExec/YOLO path spawns via child_process without the marker. Same
|
|
// integrated-terminal-only scope as the Cursor/Windsurf rules above.
|
|
// Source: https://github.com/cline/cline (VscodeTerminalRegistry.ts:29)
|
|
{
|
|
name: "cline",
|
|
check: (env) => typeof env["CLINE_ACTIVE"] === "string",
|
|
},
|
|
// Google Gemini CLI (open-source @google/gemini-cli) — DISTINCT from the
|
|
// Gemini managed-agent sandbox. (If a /.agents/ filesystem detector is
|
|
// present in detectAgentRuntime() it runs ahead of this loop and wins for a
|
|
// managed-agent sandbox, leaving this rule to match only the local CLI.)
|
|
// The shell-execution service
|
|
// sets GEMINI_CLI=1 on the child env of every shell command it spawns, so
|
|
// downstream executables can tell they were launched by Gemini CLI
|
|
// (packages/core/src/services/shellExecutionService.ts:56,486-487 — spread
|
|
// onto baseEnv after sanitizeEnvironment, passed as env: to both the
|
|
// child_process and node-pty spawn paths).
|
|
// Caveat: under STRICT sanitization (when GITHUB_SHA is set / the GitHub
|
|
// Action surface) GEMINI_CLI is not allow-listed and gets stripped — reliable
|
|
// for the local CLI, not inside Gemini's GitHub Action runner.
|
|
// Source: https://github.com/google-gemini/gemini-cli (shellExecutionService.ts:56,486-487)
|
|
{
|
|
name: "gemini_cli",
|
|
check: (env) => typeof env["GEMINI_CLI"] === "string",
|
|
},
|
|
// Crush (charmbracelet/crush) — internal/shell/shell.go:43-48,98
|
|
// unconditionally appends CRUSH=1 (plus generic AGENT=crush / AI_AGENT=crush)
|
|
// to the env of every shell it spawns: both the interactive bash tool and the
|
|
// hook runner. We key on CRUSH since AGENT/AI_AGENT are generic and collide.
|
|
// Source: https://github.com/charmbracelet/crush (internal/shell/shell.go:43-48,98)
|
|
{
|
|
name: "crush",
|
|
check: (env) => typeof env["CRUSH"] === "string",
|
|
},
|
|
];
|
|
|
|
// Agents evaluated and deliberately NOT added. Each fails the bar the rules
|
|
// above meet — a marker reliably present in the environment of the
|
|
// shell/subprocess the agent spawns. Recorded here (not only in the PR) so the
|
|
// next person doesn't re-derive it:
|
|
// - OpenHands — OPENHANDS_BUILD_GIT_SHA/_REF exists in the agent-server
|
|
// Dockerfile (base-image-minimal stage, added 2025-11-09 in PR #1100) but
|
|
// is empirically ABSENT from the runtime env of every published
|
|
// ghcr.io/openhands/agent-server image inspected (12+ tags, 2025-10 →
|
|
// 2026-01, incl. the introducing PR's merge commit). The declared ENV
|
|
// never reaches the published image. Re-add only if a real published image
|
|
// carries it in `docker inspect .Config.Env`.
|
|
// - Aider — sets no self-identifying env var; both shell-spawn sites
|
|
// (run_cmd.py Popen / pexpect.spawn) pass no env=, so children inherit
|
|
// os.environ verbatim.
|
|
// - Goose — AGENT=goose/GOOSE_TERMINAL=1 are set on the recipe-retry path
|
|
// and the computercontroller MCP extension, but NOT on the default
|
|
// developer `shell` tool (sets only PATH+cwd), so the primary path is
|
|
// undetected.
|
|
// - opencode — OPENCODE_TERMINAL=1 is set only on the interactive PTY panel,
|
|
// not on the model's bash/shell tool.
|
|
// - Roo Code — ROO_ACTIVE is set only on the `vscode` terminal provider; the
|
|
// shipped default is the execa provider (terminalShellIntegrationDisabled
|
|
// defaults true), which sets no marker.
|
|
// - Amp / Devin / Jules / Factory Droid — no verifiable unconditional runtime
|
|
// marker (Amp is closed/minified; Devin/Jules are closed sandboxes;
|
|
// Factory's FACTORY_PROJECT_DIR / DROID_PLUGIN_ROOT are hook-scoped only).
|
|
|
|
/**
|
|
* 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. Most rules only check the
|
|
* EXISTENCE of well-known env vars (never their values), but a few agents
|
|
* are best identified by filesystem markers — those run via dedicated
|
|
* detector functions ahead of the env-var rule loop.
|
|
*/
|
|
export function detectAgentRuntime(): AgentRuntime {
|
|
// Gemini managed agent — keyed on the `/.agents/` platform mount with a
|
|
// gVisor guard against false positives. See `isGeminiManagedAgent` for the
|
|
// uniqueness-anchor-vs-guard split. Env vars alone are insufficient
|
|
// (`GEMINI_API_KEY` is user-settable), so this runs ahead of VENDOR_RULES.
|
|
if (isGeminiManagedAgent()) return "gemini_managed_agent";
|
|
for (const rule of VENDOR_RULES) {
|
|
if (rule.check(process.env)) return rule.name;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// New-agent discovery signals.
|
|
//
|
|
// VENDOR_RULES is a CLOSED allowlist: an agent we haven't written a rule for
|
|
// collapses to agent_runtime=null, leaving no trace of what it was. That makes
|
|
// the null bucket un-attributable — new agents stay invisible until someone
|
|
// reverse-engineers their marker by hand (which is how every rule above was
|
|
// derived).
|
|
//
|
|
// detectAgentHints() adds a self-populating residual signal for exactly that
|
|
// null bucket, so an unrecognized agent surfaces on its own in analytics and
|
|
// can be promoted to a real VENDOR_RULE later. Callers should only emit these
|
|
// when detectAgentRuntime() returns null — a classified event needs no hint.
|
|
//
|
|
// Privacy — consistent with the "never read secret-shaped values" stance above:
|
|
// - agent_env_hints emits KEY NAMES only, never values.
|
|
// - agent_hint / term_program read the VALUE of three vars whose sole purpose
|
|
// is non-secret self-identification: the emerging AGENT / AI_AGENT
|
|
// agent-name convention (e.g. Crush and Goose set AGENT=<name>) and
|
|
// TERM_PROGRAM's editor name (how the cursor/windsurf rules already work).
|
|
// Each value is passed through a strict short-slug allowlist, so anything
|
|
// long, spaced, or secret-shaped is dropped to null.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface AgentHints {
|
|
/**
|
|
* Best-effort agent name from a self-identifying env-var value: AGENT, else
|
|
* AI_AGENT. null when neither is set or the value isn't a short safe slug.
|
|
*/
|
|
agent_hint: string | null;
|
|
/**
|
|
* Raw TERM_PROGRAM value (editor/terminal name) — surfaces IDE-terminal
|
|
* agents not yet covered by the cursor/windsurf rules. Noisy (also set by
|
|
* plain human terminals); read alongside is_tty=false. null when unset/unsafe.
|
|
*/
|
|
term_program: string | null;
|
|
/**
|
|
* Sorted, comma-joined list of "agent-ish" env-var KEY names present but
|
|
* matched by no vendor rule — a compact fingerprint that clusters by agent.
|
|
* KEYS only, never values. null when none are present.
|
|
*/
|
|
agent_env_hints: string | null;
|
|
}
|
|
|
|
// Self-identifying values are agent/editor NAMES by convention — short slugs.
|
|
// Anything longer, spaced, or secret-shaped falls outside this and is dropped.
|
|
const SAFE_HINT = /^[a-z0-9_.-]{1,32}$/;
|
|
|
|
// The slug allowlist alone still accepts SHORT credential-shaped values
|
|
// (AGENT=sk-ant-api03, AGENT=AKIAIOSFODNN7EXAMPLE, AGENT=github_pat_abc), so
|
|
// two extra guards enforce the "never emit a secret" boundary this PR relies on:
|
|
// 1. known credential/token prefixes (compared lowercased), and
|
|
// 2. any unbroken alphanumeric run >= 16 chars — the shape of key bodies,
|
|
// hex digests, and base64-ish tokens (agent names segment on _/-/. and
|
|
// keep each run short).
|
|
const CREDENTIAL_PREFIXES = [
|
|
"sk-",
|
|
"sk_",
|
|
"pk-",
|
|
"pplx-",
|
|
"ghp_",
|
|
"gho_",
|
|
"ghu_",
|
|
"ghs_",
|
|
"ghr_",
|
|
"github_pat_",
|
|
"glpat-",
|
|
"gsk_",
|
|
"xox",
|
|
"akia",
|
|
"asia",
|
|
"aiza",
|
|
"ya29",
|
|
"hf_",
|
|
"r8_",
|
|
];
|
|
const LONG_ALNUM_RUN = /[a-z0-9]{16,}/;
|
|
|
|
function looksLikeCredential(v: string): boolean {
|
|
if (CREDENTIAL_PREFIXES.some((p) => v.startsWith(p))) return true;
|
|
return LONG_ALNUM_RUN.test(v);
|
|
}
|
|
|
|
function sanitizeHint(value: string | undefined): string | null {
|
|
if (typeof value !== "string") return null;
|
|
const v = value.trim().toLowerCase();
|
|
if (!SAFE_HINT.test(v)) return null;
|
|
if (looksLikeCredential(v)) return null;
|
|
return v;
|
|
}
|
|
|
|
// A key (uppercased) looks like a coding-agent marker. Excludes the two
|
|
// value-captured generics (read via agent_hint) and the SSH/GPG "agent" false
|
|
// friends, which are credential agents, not coding agents. No bare `CODING`
|
|
// token — it substring-matches `ENCODING` (e.g. PYTHONIOENCODING) and real
|
|
// coding-agent keys already match via `AGENT` (e.g. PI_CODING_AGENT).
|
|
const HINT_KEY_PATTERN = /AGENT|ASSISTANT|COPILOT|CODEX|CLAUDE|LLM|_THREAD_ID$|_SESSION_ID$/;
|
|
|
|
function isDiscoveryHintKey(upperKey: string): boolean {
|
|
if (upperKey === "AGENT" || upperKey === "AI_AGENT") return false;
|
|
if (upperKey.startsWith("SSH_") || upperKey.startsWith("GPG_")) return false;
|
|
return HINT_KEY_PATTERN.test(upperKey);
|
|
}
|
|
|
|
/**
|
|
* Residual discovery signals for the agent_runtime=null bucket. See the section
|
|
* comment above for intent and the privacy contract. Pure over process.env.
|
|
*/
|
|
export function detectAgentHints(): AgentHints {
|
|
const env = process.env;
|
|
const agent_hint = sanitizeHint(env["AGENT"]) ?? sanitizeHint(env["AI_AGENT"]);
|
|
const term_program = sanitizeHint(env["TERM_PROGRAM"]);
|
|
|
|
const keys = new Set<string>();
|
|
for (const key of Object.keys(env)) {
|
|
if (key.length > 64) continue;
|
|
const upper = key.toUpperCase();
|
|
if (isDiscoveryHintKey(upper)) keys.add(upper);
|
|
}
|
|
const sorted = [...keys].sort();
|
|
const agent_env_hints = sorted.length ? sorted.slice(0, 16).join(",") : null;
|
|
|
|
return { agent_hint, term_program, agent_env_hints };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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).
|
|
*
|
|
* `4.19.0-gvisor` is unambiguous — no real Linux box reports that string.
|
|
* `4.4.0` collides with Ubuntu 16.04 LTS / older real kernels, so we only
|
|
* accept it as a gVisor signal when /proc/version ALSO contains "gVisor".
|
|
*/
|
|
function isGVisor(): boolean {
|
|
const kernel = release();
|
|
if (kernel.includes("gvisor")) return true;
|
|
if (platform() !== "linux") return false;
|
|
try {
|
|
const procVersion = readFileSync("/proc/version", "utf-8");
|
|
return procVersion.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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gemini managed-agent sandbox — Google's Managed Agents runtime (the
|
|
* Antigravity base agent). The platform auto-discovers the agent definition
|
|
* under `/.agents/` and runs it inside a gVisor kernel.
|
|
*
|
|
* Signal hierarchy (the two checks are NOT co-equal):
|
|
* - `/.agents/` is the *uniqueness anchor*: the platform's agent-definition
|
|
* mount root. Per Google's Managed Agents docs the runtime scans `/.agents/`
|
|
* for the agent's instructions (`/.agents/AGENTS.md`) and skills
|
|
* (`/.agents/skills/<name>/SKILL.md`). Nothing in the generic
|
|
* Google-Cloud-on-gVisor universe (Cloud Run gen2, GKE Sandbox, Fly.io)
|
|
* mounts `/.agents/` at the filesystem root.
|
|
*
|
|
* We key on the `/.agents/` DIRECTORY, not `/.agents/AGENTS.md`: AGENTS.md
|
|
* is OPTIONAL. Google's docs state "AGENTS.md is optional ... the
|
|
* system_instruction and AGENTS.md are additive; both apply when present",
|
|
* so an agent may declare its instructions inline via `system_instruction`
|
|
* in agent.yaml and ship no AGENTS.md file. Keying on the file would
|
|
* silently miss every managed agent that uses inline instructions or a
|
|
* skills-only definition; the directory mount generalizes across all
|
|
* managed agents that ship any definition.
|
|
* - `isGVisor()` is a *guard*, not a co-uniqueness signal. gVisor itself
|
|
* is shared with GKE Sandbox + Cloud Run gen2 — it does not discriminate
|
|
* the managed-agent surface from those. Its job here is to rule out the
|
|
* unlikely case of a human creating `/.agents/` on a non-sandbox host.
|
|
*
|
|
* Known coverage gap: an agent defined with ONLY inline `system_instruction`
|
|
* (no skills, no AGENTS.md) may not materialize a `/.agents/` mount — that
|
|
* tail can't be closed from the docs and needs an empirical spin to confirm.
|
|
* The common case (skills and/or AGENTS.md present) is covered.
|
|
*
|
|
* Things deliberately NOT keyed on (each fails the uniqueness test —
|
|
* shared across the broader Google-Cloud-on-gVisor universe or trivially
|
|
* user-settable on any host):
|
|
* - gVisor alone
|
|
* - `Google Compute Engine` DMI (entire GCP reports this)
|
|
* - `job` cgroup (Google-internal but broadly present)
|
|
* - egress-proxy env / CA-cert env cluster (any MITM container sets these)
|
|
* - `/workspace/` (the agent's data mount — generic, not unique)
|
|
* - `GEMINI_API_KEY` (user-settable on any host)
|
|
*
|
|
* Source: Google Managed Agents docs (ai.google.dev/gemini-api/docs/custom-agents
|
|
* + managed-agents-quickstart) for the `/.agents/` mount contract and AGENTS.md
|
|
* optionality; empirical introspection of live managed-agent sandboxes by
|
|
* gemini-agent (2026-06-09) for the gVisor pairing — present across 3
|
|
* independent fresh sandbox spins (spike + `b9db4e56` + `d59d6361`).
|
|
*/
|
|
function isGeminiManagedAgent(): boolean {
|
|
if (platform() !== "linux") return false;
|
|
// The uniqueness anchor: the managed-agent definition mount root. We key on
|
|
// the `/.agents/` directory (not the optional AGENTS.md file) so skills-only
|
|
// and inline-instruction agents are still detected. Nothing else on gVisor
|
|
// (Cloud Run, GKE Sandbox, Fly.io) creates this path. Require an actual
|
|
// directory — a stray file or symlink named `/.agents` must not match.
|
|
try {
|
|
if (!statSync("/.agents").isDirectory()) return false;
|
|
} catch {
|
|
return false; // ENOENT / EACCES — no mount, not a managed agent.
|
|
}
|
|
// The guard: rule out a stray user-created `/.agents/` on a non-sandbox
|
|
// host. Not a second uniqueness signal — gVisor isn't unique on its own.
|
|
return isGVisor();
|
|
}
|