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
1 change: 1 addition & 0 deletions docs/docs/reference/security-faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export ALTIMATE_TELEMETRY_DISABLED=true
- **Anonymous users:** A random UUID (`crypto.randomUUID()`) is generated on first run and stored at `~/.altimate/machine-id`. This is NOT tied to your hardware, OS, or identity — it's purely random.
- **Both identifiers** are only sent when telemetry is enabled. Disable with `ALTIMATE_TELEMETRY_DISABLED=true`.
- **No fingerprinting:** We do not use browser fingerprinting, hardware IDs, MAC addresses, or IP-based tracking.
- **CLI auth flow:** When you sign in via `altimate auth login`, the anonymous machine ID is included in the authorization URL and associated with your account in product analytics for funnel analysis. This is suppressed when `ALTIMATE_TELEMETRY_DISABLED=true` is set — the machine ID is omitted from the URL entirely.

### What happens on first launch?

Expand Down
4 changes: 4 additions & 0 deletions docs/docs/reference/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ Altimate Code uses two types of anonymous identifiers for analytics, depending o

Both identifiers are only sent when telemetry is enabled. Disable telemetry entirely with `ALTIMATE_TELEMETRY_DISABLED=true` or the config option above.

### CLI Authentication Flow

When you sign in using the CLI browser auth flow (`altimate auth login`), an anonymized session identifier (the `machine-id` UUID) is included in the authorization URL and associated with your account in product analytics. This is used solely to correlate CLI install events with authenticated accounts in aggregate funnel analytics — it is never used for tracking, advertising, or cross-site identification. Respecting `ALTIMATE_TELEMETRY_DISABLED=true` suppresses this: when telemetry opt-out is set, the machine ID is omitted from the authorization URL entirely.

### Data Retention

Telemetry data is sent to Azure Application Insights and retained according to [Microsoft's data retention policies](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/data-retention-configure). We do not maintain a separate data store. To request deletion of your telemetry data, contact privacy@altimate.ai.
Expand Down
78 changes: 73 additions & 5 deletions packages/opencode/src/altimate/plugin/altimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import open from "open"
import { AltimateApi } from "../api/client"
// altimate_change — onboarding telemetry for the gateway sign-in funnel
import * as OnboardingTelemetry from "../telemetry/onboarding"
// altimate_change — shared machine-id helper (race-safe, UUID-validated, size-capped)
import { getOrCreateMachineId } from "../util/machine-id"
import { Config } from "@/config/config"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Log } from "@/altimate/util/log"

/**
* Why a failure reason is attached at the rejection site rather than inferred from the message:
Expand Down Expand Up @@ -47,6 +52,71 @@ const DEFAULT_WEB_URL = "https://app.myaltimate.com"
// deliver.
const DEFAULT_API_URL = "https://api.myaltimate.com"

const log = Log.create({ service: "altimate-plugin" })

// altimate_change — getOrCreateMachineId is now in util/machine-id.ts (re-exported
// from there so existing test imports that reference this module continue to work).
export { getOrCreateMachineId } from "../util/machine-id"

// Builds a base64url-encoded context blob for correlating this browser auth
// session with CLI telemetry in PostHog. The machine_id is a random UUID
// stored at ~/.altimate/machine-id — not tied to hardware, OS, or user identity.
// After sign-in, the frontend calls posthog.alias(email, machine_id) to associate
// the device with the authenticated account in product analytics.
//
// Privacy note: cli_context is sent in the URL *fragment* (#cli_context=...),
// not the query string. The browser never transmits a fragment to the server,
// so the machine_id — though a non-PII crypto.randomUUID() — stays out of
// app.myaltimate.com's access logs, any fronting CDN/WAF, and the Referer
// header, while remaining readable by the /register page via location.hash.
// The frontend reads it from the fragment (see useCliContext.ts). The fragment
// must be the last URL segment, after all query params.
export async function buildCliContext(machineIdPath?: string): Promise<string> {
// altimate_change start — honour both telemetry opt-out gates, mirroring
// telemetry/index.ts::doInit exactly:
// 1. ALTIMATE_TELEMETRY_DISABLED=true env var (early, always-works escape hatch)
// 2. config.telemetry.disabled (resolved via the async Config.get())
// Config.get() may throw outside an Instance context; treat a config failure as
// "not disabled" (same as doInit) — the env var above is the hard opt-out.
let disabled = process.env.ALTIMATE_TELEMETRY_DISABLED === "true"
if (!disabled) {
try {
const userConfig = (await Config.get()) as any
disabled = Boolean(userConfig.telemetry?.disabled)
} catch {
// Config unavailable — proceed with telemetry enabled.
}
}
let machineId = ""
if (!disabled) {
// getOrCreateMachineId returns "" on all error conditions (ENOENT excluded —
// it mints a new UUID instead) and logs appropriately; no try/catch needed.
machineId = getOrCreateMachineId(machineIdPath)
}
// altimate_change end
// altimate_change start — omit machine_id key when empty (matches telemetry
// module pattern: `...(machineId && { machine_id: machineId })`). Sending ""
// is meaningless for posthog.alias() and misleads downstream consumers.
const ctx: Record<string, unknown> = { v: 1, cli_version: InstallationVersion }
if (machineId) ctx.machine_id = machineId
// altimate_change end
return Buffer.from(JSON.stringify(ctx)).toString("base64url")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR — Documentation: the frontend contract is unspecified, and this payload is untrusted input on that side

Nothing records what the consumer must do: decode base64url (not standard base64), require v === 1, cap decoded size, catch base64/JSON parse failures, validate field types, and treat cli_version: "local" as a legitimate dev value rather than a release.

From the frontend's perspective a URL query param is attacker-suppliable — anyone can hand-craft one and load /register.

Fix: add JSDoc here stating the contract, and make sure the companion PR validates rather than trusts.

}

// altimate_change start — exported so tests can assert on the full URL shape
// without duplicating the construction logic.
export async function buildAuthorizeUrl(webUrl: string, redirect: string, state: string): Promise<string> {
return (
`${webUrl}/register?client=altimate-code` +
`&redirect=${encodeURIComponent(redirect)}` +
`&state=${state}` +
// Fragment (#), not a query param — keeps the durable machine_id out of
// server access logs / Referer. Must stay last, after all query params.
`#cli_context=${encodeURIComponent(await buildCliContext())}`
)
}
// altimate_change end

// The one-time login_token is POSTed to the callback-supplied API base, so that
// base must be trusted — otherwise a crafted callback could exfiltrate the token
// to an attacker's server. Allow only HTTPS Altimate-owned hosts, an explicitly
Expand Down Expand Up @@ -341,10 +411,7 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise<Hooks> {
const redirect = `http://127.0.0.1:${boundPort}/callback`
// Land on the sign-up page and let the user choose how to authenticate
// (Google today, more providers later) rather than forcing Google.
const authorizeUrl =
`${webUrl}/register?client=altimate-code` +
`&redirect=${encodeURIComponent(redirect)}` +
`&state=${state}`
const authorizeUrl = await buildAuthorizeUrl(webUrl, redirect, state)

// Try to open the browser. Failure is silent because the URL is
// already surfaced elsewhere: the auth dialog in packages/tui/src/
Expand All @@ -363,7 +430,8 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise<Hooks> {
// attempted". open() failures are swallowed above (the URL is also printed for the
// user to paste), so this fires even when no browser actually launched.
// The URL is never sent — it carries the CSRF `state`.
if (OnboardingTelemetry.isFunnelActive()) void OnboardingTelemetry.emit({ type: "gateway_device_code_issued" })
if (OnboardingTelemetry.isFunnelActive())
void OnboardingTelemetry.emit({ type: "gateway_device_code_issued" })

// One outcome per attempt. callback() closes over `result` and re-runs its whole body
// on every invocation, so a repeated call would otherwise re-emit completion/failure
Expand Down
31 changes: 6 additions & 25 deletions packages/opencode/src/altimate/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Config } from "@/config/config"
import { Flag } from "@/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Log } from "@/altimate/util/log"
// altimate_change — shared machine-id helper (race-safe, UUID-validated, size-capped)
import { getOrCreateMachineId } from "@/altimate/util/machine-id"
import { createHash, randomUUID } from "crypto"
import fs from "fs"
import path from "path"
Expand Down Expand Up @@ -1698,31 +1700,10 @@ export namespace Telemetry {
} catch {
// Account unavailable — proceed without user ID
}
try {
const machineIdPath = path.join(os.homedir(), ".altimate", "machine-id")
try {
machineId = fs.readFileSync(machineIdPath, "utf8").trim()
} catch {
// altimate_change start — create exclusively so two threads cannot mint different ids.
// The TUI main thread and the server worker each initialise their own copy of this
// module, and on a genuinely new install both can find the file missing at the same
// moment. With a plain write, the loser's value overwrites the winner's while both keep
// their own in memory, so a single first run reports two machine_ids — breaking the
// fallback identity exactly on the run that matters most. `wx` makes one of them fail,
// and the loser re-reads what the winner wrote.
const candidate = randomUUID()
fs.mkdirSync(path.dirname(machineIdPath), { recursive: true })
try {
fs.writeFileSync(machineIdPath, candidate, { encoding: "utf8", flag: "wx" })
machineId = candidate
} catch {
machineId = fs.readFileSync(machineIdPath, "utf8").trim()
}
// altimate_change end
}
} catch {
// Machine ID unavailable — proceed without it
}
// altimate_change — use shared getOrCreateMachineId() from util/machine-id.ts.
// Returns "" on all error conditions (ENOENT: mints new UUID; EACCES/corrupt/oversized:
// logs + returns ""). No try/catch needed — all paths are handled inside.
machineId = getOrCreateMachineId()
enabled = true
log.info("telemetry initialized", { mode: "appinsights" })
// altimate_change — clear any existing interval before installing a new one. doInit() can
Expand Down
105 changes: 105 additions & 0 deletions packages/opencode/src/altimate/util/machine-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// altimate_change — shared machine-id helper extracted from plugin/altimate.ts.
// All three call sites (telemetry/index.ts, plugin/altimate.ts, cli/welcome.ts)
// use this to guarantee they converge on the same file and the same UUID value.
import { randomUUID } from "crypto"
import fs from "fs"
import os from "os"
import path from "path"
import { Log } from "./log"

const log = Log.create({ service: "machine-id" })

// Max bytes to read from the machine-id file. A UUID is 36 chars; 512 bytes
// is generous enough for any valid value while capping pathological cases
// (multi-MB symlink targets, garbage-filled files).
const MAX_BYTES = 512

// RFC 4122 v4 UUID — the only format we mint, so the only format we accept.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i

/**
* Read the machine-id from `~/.altimate/machine-id`, minting a new random UUID
* with `flag: "wx"` (exclusive create) if the file is absent.
*
* - **Race-safe**: two concurrent callers on a fresh install converge on the
* same UUID — the winner writes, the loser re-reads.
* - **Regular-file-only**: uses `lstat` and rejects symlinks / non-regular
* files rather than following them to an attacker-chosen target.
* - **Size-capped**: reads at most 512 bytes to avoid multi-MB file attacks.
* - **UUID-validated**: rejects content that does not match RFC 4122 v4 UUID
* format (corrupt file, injected content, etc.) and returns `""` with a warn
* log so callers can omit the field rather than propagate garbage.
*
* @param machineIdPath Override path (for tests). Defaults to `~/.altimate/machine-id`.
* @returns A v4 UUID string, or `""` if the value is invalid or unreadable.
*/
export function getOrCreateMachineId(machineIdPath?: string): string {
const idPath = machineIdPath ?? path.join(os.homedir(), ".altimate", "machine-id")

// --- Read path ---
let raw: string | undefined
try {
// lstat (not stat) so a symlink is inspected as itself rather than followed
// to an attacker-chosen target. Reject anything that is not a regular file
// (symlink, directory, socket, …) and cap read size to avoid multi-MB files.
const stat = fs.lstatSync(idPath)
if (!stat.isFile()) {
log.warn("machine-id is not a regular file — omitting", { path: idPath })
return ""
}
if (stat.size > MAX_BYTES) {
log.warn("machine-id file exceeds size limit — omitting", { path: idPath, size: stat.size })
return ""
}
raw = fs.readFileSync(idPath, "utf8").trim()
} catch (readErr) {
const code = (readErr as NodeJS.ErrnoException)?.code
if (code !== "ENOENT") {
// EACCES, EMFILE, etc. — log and bail; we cannot create either.
log.warn("machine-id read failed", { code, path: idPath })
return ""
}
// File absent — fall through to create path below.
raw = undefined
}

if (raw !== undefined) {
// Validate before returning: reject corrupt or symlink-injected content.
if (!UUID_RE.test(raw)) {
log.warn("machine-id file contains non-UUID content — omitting", { path: idPath })
return ""
}
return raw
}

// --- Create path (ENOENT) ---
// `flag: "wx"` is atomic exclusive-create: the OS guarantees only one writer
// succeeds. The loser re-reads what the winner wrote.
const candidate = randomUUID()
fs.mkdirSync(path.dirname(idPath), { recursive: true })
try {
fs.writeFileSync(idPath, candidate, { encoding: "utf8", flag: "wx" })
return candidate
} catch (writeErr) {
const code = (writeErr as NodeJS.ErrnoException)?.code
if (code !== "EEXIST") {
log.warn("machine-id create failed", { code, path: idPath })
return ""
}
// Lost the race — read what the winner wrote.
try {
const winner = fs.readFileSync(idPath, "utf8").trim()
if (!UUID_RE.test(winner)) {
log.warn("machine-id written by race winner is non-UUID — omitting", { path: idPath })
return ""
}
return winner
} catch (rereadErr) {
log.warn("machine-id re-read after race failed", {
code: (rereadErr as NodeJS.ErrnoException)?.code,
path: idPath,
})
return ""
}
}
}
12 changes: 8 additions & 4 deletions packages/opencode/src/cli/welcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { EOL } from "os"
// altimate_change start — import Telemetry for first_launch event
import { Telemetry } from "../altimate/telemetry"
// altimate_change end
// altimate_change — import shared machine-id utility so the path is canonical across all call sites
import { getOrCreateMachineId } from "../altimate/util/machine-id"

const APP_NAME = "altimate-code"
const MARKER_FILE = ".installed-version"
Expand Down Expand Up @@ -39,12 +41,14 @@ export function showWelcomeBannerIfNeeded(): void {
// Remove marker first to avoid showing twice even if display fails
fs.unlinkSync(markerPath)

// altimate_change start — use ~/.altimate/machine-id existence as a proxy for upgrade vs fresh install
// Since postinstall.mjs always writes the current version to the marker file, we can't reliably
// use installedVersion !== currentVersion for release builds. Instead, if machine-id exists,
// they've run the CLI before.
// altimate_change start — "upgrade" means the machine-id file already existed before this
// launch. Probe existence with existsSync FIRST — do NOT use getOrCreateMachineId() as the
// probe, because it mints the file on a fresh install and would then report every new user
// as an upgrade. After probing, mint the id (unless telemetry is opted out via env) so
// telemetry.doInit() finds it ready — welcome.ts runs before doInit().
const machineIdPath = path.join(os.homedir(), ".altimate", "machine-id")
const isUpgrade = fs.existsSync(machineIdPath)
if (process.env.ALTIMATE_TELEMETRY_DISABLED !== "true") getOrCreateMachineId()
// altimate_change end

// altimate_change start — track first launch for new user counting (privacy-safe: only version + machine_id)
Expand Down
Loading
Loading