/** * IronBee — Configuration Loader * * Loads config from up to three sources and merges them in order: * 1. Global: ~/.ironbee/config.json * 2. Project: /.ironbee/config.json (committed) * 3. Project-local: /.ironbee/config.local.json (gitignored) * * Each later layer overrides earlier ones (deep merge for nested cycle * blocks: `browser`, `node`, `backend`; shallow for primitives and other * top-level keys). The local layer is intended for personal / per-machine * overrides (debug toggles, machine-specific paths, alternate collector * endpoints) and must not be committed — `ironbee install` adds * `.ironbee/config.local.json` to `.gitignore` automatically. */ import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { homedir } from "os"; import { logger } from "./logger"; /** * Optional cycles — those that default to **disabled** (empty `verifyPatterns`) * until the operator explicitly opts in via `ironbee enable`. The * always-on browser cycle is the only default-enabled cycle and is excluded * from this list. Order is the canonical sort order used by verify-gate when * building per-cycle messages. */ export const OPTIONAL_CYCLES: readonly string[] = ["node", "backend"] as const; /** * Every cycle the runtime knows about — browser plus all optional cycles. * Used by the platform-section sync (skill / rule / command-verify md files * carry one marker block per cycle) and by toggle commands when validating * the cycle name. Order matches verify-gate's canonical block ordering * (browser first, then OPTIONAL_CYCLES). */ export const ALL_CYCLES: readonly string[] = ["browser", ...OPTIONAL_CYCLES] as const; /** * Cycles whose default state is "enabled" (the cycle activates without the * operator having to write `verifyPatterns` in config). Currently only the * browser cycle ships with built-in default patterns; node and backend cycles * default to inert (`[]`) and require `ironbee enable`. * * Used by `getCyclePatterns` runtime fallback (block-absent + default-on * → code defaults activate) and by toggle commands. ` disable` records * its intent via the top-level `disabled` list — that takes precedence over * the default-on fallback, so a disabled browser stays off even though * `CYCLES_ENABLED_BY_DEFAULT` contains it. */ export const CYCLES_ENABLED_BY_DEFAULT: ReadonlySet = new Set(["browser"]); /** Map cycle key → MCP server name. Browser is always `browser-devtools`. */ export const CYCLE_TO_SERVER: Record = { browser: "browser-devtools", node: "node-devtools", backend: "backend-devtools", }; /** A single evidence path under a `RequiredToolsConfig`. */ export interface EvidencePath { /** Stable identifier — used in `verification_requested.modes` and block messages. */ name: string; /** * All entries must be satisfied for this path to count. * String → exact tool must be present. `{ anyOf }` → at least one of the listed tools. */ allOf: (string | { anyOf: string[] })[]; } /** * Per-cycle required-tools spec. The gate validates by: * 1. All `alwaysRequired` tools are present, AND * 2. At least one `evidencePaths` entry is fully satisfied (or evidencePaths is empty). */ export interface RequiredToolsConfig { alwaysRequired: string[]; evidencePaths: EvidencePath[]; } /** Per-cycle config block — same shape across browser / node / backend. */ export interface CycleConfig { /** * Explicit on/off switch for the cycle at this layer. When `false`, the * cycle is disabled regardless of any other fields (mirrors the * `recording.enable` / `jobQueue.enable` / `verification.enable` pattern). * When `true` or absent, the rest of the block resolves per the * verifyPatterns four-state semantic in `getCyclePatterns`. * * Written by ` disable` (= `false`) and stripped by ` enable`. */ enable?: boolean; verifyPatterns?: string[]; additionalVerifyPatterns?: string[]; alwaysRequired?: string[]; evidencePaths?: EvidencePath[]; } export interface IronBeeConfig { /** * Glob patterns for files to exclude from verification. Applies to every * cycle (browser and all backend runtimes). Checked first — matches here * are skipped regardless of which cycle's patterns would otherwise match. */ ignoredVerifyPatterns?: string[]; /** Maximum retry attempts before allowing completion despite failures. */ maxRetries?: number; /** * Browser-cycle config (patterns + required tools). Default-on — when * `verifyPatterns` is unset, `DEFAULT_BROWSER_VERIFY_PATTERNS` (40+ code * extensions) kicks in. Opt out via `ironbee browser disable` (writes * `verifyPatterns: []` to override the fallback). */ browser?: CycleConfig; /** * Node.js runtime debug cycle (`node-devtools` MCP, `ndt_*` tools). Opt-in * — empty `verifyPatterns` by default. Activate via `ironbee node enable`. */ node?: CycleConfig; /** * Runtime-agnostic backend protocol cycle (`backend-devtools` MCP, * `bedt_*` tools). Drives HTTP / gRPC / GraphQL / WebSocket calls * against running backend services to verify behavior — language- and * framework-independent. Opt-in — activate via `ironbee backend enable`. */ backend?: CycleConfig; /** * IronBee Collector configuration for remote event ingestion. When the * section is present and `enable` is not explicitly `false`, events are * sent to the collector in addition to local storage. */ collector?: { enable?: boolean; url?: string; apiKey?: string; batchSize?: number; /** * Per-request HTTP timeout in milliseconds. Single-event interactive * sends keep the legacy 3000ms cap (writes from `appendAction` must * not block hooks). Batched sends from analytics emit * (`sendEventsBatchToCollector`) honor this value — default 10000ms, * since N-event payloads can be hundreds of KB on slow links. * Clamped to [1000, 60000] at use site. */ timeoutMs?: number; }; /** * Recording enforcement. Browser-only — backend cycles never trigger * recording. Presence of the section opts in; `enable: false` opts out. */ recording?: { enable?: boolean; }; /** Job queue tuning. Presence opts in; `enable: false` opts out. */ jobQueue?: { enable?: boolean; autoFlushSizeBytes?: number; autoFlushIntervalSeconds?: number; }; /** * Session analytics collection. Presence-as-opt-in: the `analytics` * section being in config.json turns analytics on; `enable: false` * explicitly opts out. Same semantics as `jobQueue` / `recording` / * `collector`. The CLI projects the host transcript JSONL into a * `SessionAnalytics` record (no content) and ships it via the existing * collector pipeline as a `session_analytics` event. */ analytics?: { enable?: boolean; /** When `true`, project + emit at every Stop hook. When `false`, only SessionEnd. Default `true`. */ emitOnStop?: boolean; /** Throttle: skip Stop-hook projection if last successful emit was within this many seconds. Default 0 (disabled). */ emitOnStopMinIntervalSeconds?: number; /** * Per-event-type opt-in for the granular turn / step wire records * (`session_turn_analytics`, `session_turn_step_analytics`). * **Default `false`** — opt-in by setting `true`. * * Inverse of the `analytics` master switch (which is presence-as-opt-in) * because turn/step events are high-volume secondary signals that * not every collector consumer wants. The base `session_analytics` * record always ships when analytics is enabled. */ emitTurnEvents?: boolean; emitStepEvents?: boolean; /** * When true (default), emit one `api_request` wire event per * assistant message line in the transcript (success + failure). * **Default `true` (opt-out)** — inverse of turn/step gates; per- * request audit is the primary value of the analytics pipeline for * backend cost accounting. Set explicitly to `false` to suppress. */ emitApiRequestEvents?: boolean; }; /** * Verification enforcement. **Inverse semantics from `recording`/`jobQueue`/`collector`**: * verification is the core feature, opt-out by `enable: false`. Section absent * or `enable !== false` → enabled. When disabled, ironbee runs in monitoring-only * mode — no enforcement hooks, no skill/rule, no MCP servers; only session * lifecycle and tool_call events still flow to the collector. */ verification?: { enable?: boolean; }; /** * Anonymous internal telemetry (PostHog). **Inverse semantics — opt-out * by `enable: false`**; section absent or `enable !== false` → enabled. * * This is the IronBee project's own product-analytics signal (CLI usage * counts, install / uninstall / register / session_start / verdict_write * events) — NOT the collector / IronBee Platform pipeline (which is the * operator's own event sink, controlled by the `collector` section). * * When disabled, two things happen in lockstep: * 1. The CLI's own PostHog sends are short-circuited at `isTelemetryEnabled()` * in `lib/telemetry.ts` (override order: `IRONBEE_TELEMETRY=false` env * > config `telemetry.enable: false` > legacy `~/.ironbee/telemetry.json` * > default true). * 2. `TELEMETRY_ENABLE=false` is auto-injected into every devtools MCP * server's env block (browser / node / backend) at install / rerender * time. The devtools package's own PostHog client sees the env var * and stays off, so the ironbee-devtools side mirrors the CLI's state. * * The env injection is install-time, so the change only reaches running * devtools children on the NEXT host (Claude/Cursor) session — same caveat * as `collector` / `browser` / `node` / `backend` toggles. */ telemetry?: { enable?: boolean; }; /** * Statusline integration (Claude only). Drives the `session_status` event * emitted per statusline tick (context-window size, cost, subscription * rate-limit utilization — signals unavailable to hooks or the transcript). * * Presence-as-opt-in + collector auto-enable (same pattern as `jobQueue` / * `recording` / `analytics`): the section being present (with `enable !== * false`) turns it on, AND a configured collector implicitly enables it * even when the section is absent. Explicit `enable: false` always wins. * * `renderDefault` (default `false`): when there is no upstream statusline * to chain to, `false` keeps the statusline silent (preserves the user's * "no statusline" experience); `true` renders a minimal model + context-% * line. Has no effect when the user already has a statusline (we chain it). */ statusLine?: { enable?: boolean; renderDefault?: boolean; /** * Minimum seconds between two emitted `session_status` events, on top * of skip-if-unchanged. Even when the resource metrics change, don't * emit more often than this — rapid changes (fast tool loops) coalesce * to the latest state after the interval elapses. Default `10`; set to * `0` to disable the throttle (emit on every metric change). */ emitMinIntervalSeconds?: number; /** * Claude Code's own `statusLine.refreshInterval` (seconds, min 1) — * re-runs the statusline command on a timer IN ADDITION to event-driven * updates. When set, IronBee writes it into the `.claude/settings.local * .json` statusLine block at install/rerender; **unset by default** * (no timer — event-driven only). Useful as a flush for the emit * throttle when the session goes idle. Version-dependent (host ignores * it on older Claude Code). Values < 1 are treated as unset. */ refreshInterval?: number; }; /** * `file_change` event payload tuning. * * `captureChangeset` (default `false`) controls whether the wire event * carries a hunks-only unified-diff string for the change. When off, * only metadata is shipped (operation + line counts). When on, raw * before/after file content is read at hook time, diffed, and attached * as `@@`-headed hunks with `space`/`-`/`+` content lines (no * `Index:` / `---` / `+++` filename header — `file_path` already * lives on the event). This is opt-in because the default * `tool_input` whitelist deliberately strips file content from the * wire — enabling this routes content through `file_change` instead. * * `maxChangesetBytes` (default 64 KB) is a hard cap. Diffs over the cap * get truncated with a `... (truncated, N bytes omitted)` footer so the * collector POST stays within typical reverse-proxy body limits. */ fileChange?: { captureChangeset?: boolean; maxChangesetBytes?: number; }; /** * Historical-session import tuning. Read by `ironbee import`. * * `concurrency` is the default number of sessions processed in parallel. * Resolution order: `--concurrency` flag > `import.concurrency` > * built-in default (4). Lower = gentler on the collector; higher = faster * for large backfills. */ import?: { concurrency?: number; }; /** * Local OTEL collector pipeline (Claude Code OTLP raw-API-body export → * local daemon → `session_context` context-usage events). A single switch: * `session_context` derivation is its only consumer. Orthogonal to * `telemetry.*` (PostHog product analytics) and `collector.*` (the IronBee * event sink). */ otel?: { /** * Master switch for the whole pipeline (the `.claude/settings.json` env * block incl. raw-bodies + the daemon + `session_context` derivation). * Presence-as-opt-in + collector auto-enable + explicit `false` wins — * same resolution as `jobQueue` / `analytics` / `statusLine`. */ enable?: boolean; /** Loopback port the daemon binds + the OTLP endpoint points at. Default 15986. */ port?: number; /** Daemon self-reaps after this much inactivity. Default 600 (10 min). */ idleTimeoutSeconds?: number; /** Throttle for the high-frequency `ensure` hooks (seconds). Default 30. */ ensureMinIntervalSeconds?: number; /** Coalesce `session_context` emits per session (seconds; 0 = every request). Default 0. */ emitMinIntervalSeconds?: number; }; /** * Claude-specific capabilities (Claude-only; Cursor's credential store is * opaque to us). `oauthAccess` gates whether IronBee may read the Claude * Code OAuth token (macOS Keychain / `~/.claude/.credentials.json`) and * call OAuth-scoped Anthropic endpoints (`api.anthropic.com/api/oauth/*`). * The first consumer is the statusline rate-limit fallback — when the * statusline JSON carries no `rate_limits` (team / enterprise plans, or * before the first API response) IronBee fetches them from * `/api/oauth/usage`. Default-on; opt out with `enable: false` or * `ironbee claude oauth-access disable`. Machine-global — the command * writes the global layer by default (one Keychain / token across all * projects); `--local` / `--project` narrow it to one project's layer. */ claude?: { oauthAccess?: { /** Master switch (default true — opt out with false). */ enable?: boolean; /** * TTL (seconds) for the statusline rate-limit fetch cache. The * `/api/oauth/usage` call + credential read fire at most once per * this interval even when the statusline ticks far more often. * Default 60. */ usageTtlSeconds?: number; }; }; /** Allow additional config fields (e.g. `browserDevTools` / `nodeDevTools`). */ [key: string]: unknown; } /** Browser default verify patterns — kicks in whenever the browser block is * present without an explicit `verifyPatterns`, OR (since browser is * default-on) whenever no browser block is present at any layer. */ export const DEFAULT_BROWSER_VERIFY_PATTERNS: string[] = [ "*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs", "*.vue", "*.svelte", "*.html", "*.htm", "*.css", "*.scss", "*.sass", "*.less", "*.styl", "*.py", "*.rb", "*.erb", "*.go", "*.rs", "*.java", "*.kt", "*.kts", "*.swift", "*.c", "*.cpp", "*.h", "*.hpp", "*.cs", "*.php", "*.dart", "*.ex", "*.exs", "*.erl", "*.lua", "*.r", "*.R", "*.scala", "*.clj", "*.cljs", "*.zig", "*.nim", "*.hbs", "*.ejs", "*.pug", "*.jade", "*.astro", ]; /** * Node-cycle default verify patterns — kicks in when the `node` block is * present without an explicit `verifyPatterns`. The node cycle is opt-in so * the block must be present (a fully-absent block keeps the cycle disabled). * * Heuristic-tuned for typical Node.js backends — server entry points, API * route handlers (Next.js / Remix / SvelteKit conventions), and the routes/ * folder. */ export const DEFAULT_NODE_VERIFY_PATTERNS: string[] = [ "server/**/*.{ts,js,mjs,cjs}", "src/server/**/*.{ts,js,mjs,cjs}", "backend/**/*.{ts,js,mjs,cjs}", "api/**/*.{ts,js,mjs,cjs}", "src/api/**/*.{ts,js,mjs,cjs}", "pages/api/**/*.{ts,js,mjs,cjs}", "app/api/**/*.{ts,js,mjs,cjs}", "routes/**/*.{ts,js,mjs,cjs}", "**/server.{ts,js,mjs,cjs}", ]; /** * Backend-cycle default verify patterns — kicks in when the `backend` block * is present without an explicit `verifyPatterns`. Multi-language coverage — * the cycle drives wire protocols, not language-specific tooling, so the * heuristic spans every common server-side language. */ export const DEFAULT_BACKEND_VERIFY_PATTERNS: string[] = [ "server/**/*.{ts,js,mjs,cjs,py,go,java,rb,cs,rs,kt,scala,ex,exs,php,clj}", "src/server/**/*.{ts,js,mjs,cjs,py,go,java,rb,cs,rs,kt,scala,ex,exs,php,clj}", "backend/**/*.{ts,js,mjs,cjs,py,go,java,rb,cs,rs,kt,scala,ex,exs,php,clj}", "api/**/*.{ts,js,mjs,cjs,py,go,java,rb,cs,rs,kt,scala,ex,exs,php,clj}", "src/api/**/*.{ts,js,mjs,cjs,py,go,java,rb,cs,rs,kt,scala,ex,exs,php,clj}", "pages/api/**/*.{ts,js,mjs,cjs}", "app/api/**/*.{ts,js,mjs,cjs}", "routes/**/*.{ts,js,mjs,cjs,py,go,java,rb,cs,rs,kt,scala,ex,exs,php,clj}", "controllers/**/*.{ts,js,mjs,cjs,py,go,java,rb,cs,rs,kt,scala,ex,exs,php,clj}", "handlers/**/*.{ts,js,mjs,cjs,py,go,java,rb,cs,rs,kt,scala,ex,exs,php,clj}", "services/**/*.{ts,js,mjs,cjs,py,go,java,rb,cs,rs,kt,scala,ex,exs,php,clj}", "**/server.{ts,js,mjs,cjs,py,go,java,rb,cs,rs,kt,scala,ex,exs,php,clj}", "**/main.{go,py,java,rb,kt,scala}", ]; /** * Per-cycle default `verifyPatterns` lookup. Single source of truth used by: * * - `getCyclePatterns` runtime fallback when the cycle block is present * without an explicit `verifyPatterns`. * - For browser only: also kicks in when the cycle block is fully absent * (browser is the default-on cycle — `CYCLES_ENABLED_BY_DEFAULT`). * * ` enable` does NOT materialize these into config.json; the cycle * block is written empty (`{}`) and these defaults flow in at runtime. * Keeps `config.json` minimal and lets defaults track the CLI version * automatically. */ export const CYCLE_DEFAULT_VERIFY_PATTERNS: Record = { browser: DEFAULT_BROWSER_VERIFY_PATTERNS, node: DEFAULT_NODE_VERIFY_PATTERNS, backend: DEFAULT_BACKEND_VERIFY_PATTERNS, }; /** Browser-cycle required tools — strict all-of, no alternative paths. */ export const DEFAULT_BROWSER_ALWAYS_REQUIRED: string[] = [ "bdt_navigation_go-to", "bdt_content_take-screenshot", "bdt_a11y_take-aria-snapshot", "bdt_o11y_get-console-messages", ]; /** Node-cycle required tools — connect always; then probe path or log path. */ export const DEFAULT_NODE_ALWAYS_REQUIRED: string[] = [ "ndt_debug_connect", ]; export const DEFAULT_NODE_EVIDENCE_PATHS: EvidencePath[] = [ { name: "probe", allOf: [ { anyOf: [ "ndt_debug_put-tracepoint", "ndt_debug_put-logpoint", "ndt_debug_put-exceptionpoint", ], }, "ndt_debug_get-probe-snapshots", ], }, { name: "log", allOf: ["ndt_debug_get-logs"], }, ]; /** * Backend-cycle required tools — no single tool is mandatory (different * tasks need different protocols), so `alwaysRequired` is empty and the * gate is satisfied by any one of three evidence paths: * * - `protocol-call` — agent issued a real protocol call against the * target (HTTP / gRPC / GraphQL / WebSocket / replay). The replay * tool counts because it issues a real call too. * - `log-evidence` — agent registered a log source AND read or followed * it. Useful for verification flows where the protocol layer is * exercised by an external driver (curl, the user, an integration * test) and the agent's job is to inspect the resulting log output * for the expected behavior (error gone, request id traced through, * warning logged, …). Register-source is required so a deliberate * setup step is on the wire — agents can't fluke the gate by * reading a pre-existing source unrelated to the task. * - `db-evidence` — agent opened a named DB connection AND inspected * state via a read op (query / describe-table / list-tables) OR a * state-diff op (snapshot / diff / get-changes). `bedt_db_connect` is * mandatory on this path so the connection name is deliberately on * the wire — same anti-fluke rule as `log-evidence`. Useful for * verifying migrations, seed-data changes, query-result regressions, * and side effects that surface in the database after a backend call. * * The `o11y_*` domain (`bedt_o11y_new-trace-id` / `set-trace-context` / * `get-trace-context`) is intentionally NOT an evidence path — those * tools are trace-correlation primitives, not verification work. They * are also shadowed at runtime: `_metadata.traceId` (which IronBee * injects from the active verification cycle in `require-verification`) * outranks any session pin the o11y tools set. So the orchestrator's * traceId stays authoritative for the cycle, and agents calling o11y to * "set" a trace id never break IronBee's correlation root. */ export const DEFAULT_BACKEND_ALWAYS_REQUIRED: string[] = []; export const DEFAULT_BACKEND_EVIDENCE_PATHS: EvidencePath[] = [ { name: "protocol-call", allOf: [ { anyOf: [ "bedt_request_http", "bedt_request_grpc", "bedt_request_graphql", "bedt_request_websocket-open", "bedt_request_replay", ], }, ], }, { name: "log-evidence", allOf: [ "bedt_log_register-source", { anyOf: [ "bedt_log_read", "bedt_log_read-multi", "bedt_log_follow", ], }, ], }, { name: "db-evidence", allOf: [ "bedt_db_connect", { anyOf: [ "bedt_db_query", "bedt_db_describe-table", "bedt_db_list-tables", "bedt_db_snapshot", "bedt_db_diff", "bedt_db_get-changes", ], }, ], }, ]; const DEFAULT_MAX_RETRIES: number = 3; function loadJsonFile(filePath: string): IronBeeConfig { if (!existsSync(filePath)) { return {}; } try { return JSON.parse(readFileSync(filePath, "utf-8")) as IronBeeConfig; } catch (e: unknown) { logger.debug(`failed to parse config ${filePath}: ${e}`); return {}; } } function assertVerificationShape(config: IronBeeConfig, sourcePath: string): void { if (!Object.prototype.hasOwnProperty.call(config, "verification")) { return; } const v: unknown = config.verification; if (v === null || typeof v !== "object" || Array.isArray(v)) { throw new Error( `Invalid IronBee config in ${sourcePath}: 'verification' must be an object. ` + `Expected shape: { "enable": boolean }.` ); } const block: Record = v as Record; if (Object.prototype.hasOwnProperty.call(block, "enable") && typeof block.enable !== "boolean") { throw new Error( `Invalid IronBee config in ${sourcePath}: 'verification.enable' must be boolean. ` + `Got ${typeof block.enable}.` ); } } function assertTelemetryShape(config: IronBeeConfig, sourcePath: string): void { if (!Object.prototype.hasOwnProperty.call(config, "telemetry")) { return; } const v: unknown = config.telemetry; if (v === null || typeof v !== "object" || Array.isArray(v)) { throw new Error( `Invalid IronBee config in ${sourcePath}: 'telemetry' must be an object. ` + `Expected shape: { "enable": boolean }.` ); } const block: Record = v as Record; if (Object.prototype.hasOwnProperty.call(block, "enable") && typeof block.enable !== "boolean") { throw new Error( `Invalid IronBee config in ${sourcePath}: 'telemetry.enable' must be boolean. ` + `Got ${typeof block.enable}.` ); } } /** Deep merge for a single cycle block (browser / node / backend). */ function mergeCycleConfig( base: CycleConfig | undefined, override: CycleConfig | undefined, ): CycleConfig | undefined { if (base === undefined && override === undefined) { return undefined; } return { ...(base ?? {}), ...(override ?? {}) }; } /** * Merge two `IronBeeConfig` layers — `override` wins. Top-level keys are * shallow-merged; the cycle blocks (`browser`, `node`, `backend`) deep-merge * so a project override of `browser.verifyPatterns` doesn't wipe a global * `browser.evidencePaths` (and vice-versa for the local layer). */ function mergeConfigLayers(base: IronBeeConfig, override: IronBeeConfig): IronBeeConfig { const merged: IronBeeConfig = { ...base, ...override }; merged.browser = mergeCycleConfig(base.browser, override.browser); merged.node = mergeCycleConfig(base.node, override.node); merged.backend = mergeCycleConfig(base.backend, override.backend); merged.claude = mergeClaudeConfig(base.claude, override.claude); return merged; } /** * Deep merge for the `claude` block (and its nested `oauthAccess`) so a local * `claude.oauthAccess.enable` write doesn't wipe a project-set * `claude.oauthAccess.usageTtlSeconds` (and vice-versa). */ function mergeClaudeConfig( base: IronBeeConfig["claude"], override: IronBeeConfig["claude"], ): IronBeeConfig["claude"] { if (base === undefined && override === undefined) { return undefined; } const merged: NonNullable = { ...(base ?? {}), ...(override ?? {}) }; if (base?.oauthAccess !== undefined || override?.oauthAccess !== undefined) { merged.oauthAccess = { ...(base?.oauthAccess ?? {}), ...(override?.oauthAccess ?? {}) }; } return merged; } /** On-disk paths of the three config layers, exposed for CLI plumbing (`config path`). */ export interface ConfigLayerPaths { /** `~/.ironbee/config.json` — always populated. */ global: string; /** `/.ironbee/config.json` — undefined when no projectDir was passed. */ project: string | undefined; /** `/.ironbee/config.local.json` — undefined when no projectDir was passed. */ local: string | undefined; } /** Returns the on-disk paths of all three config layers without reading them. */ export function getConfigLayerPaths(projectDir?: string): ConfigLayerPaths { return { global: join(homedir(), ".ironbee", "config.json"), project: projectDir ? join(projectDir, ".ironbee", "config.json") : undefined, local: projectDir ? join(projectDir, ".ironbee", "config.local.json") : undefined, }; } /** * Symbolic identifier for one of the three config layers. Used by every * write-side CLI command (`config set/unset`, `verification enable`, * `verification disable`, `browser enable`, `browser disable`, `node enable`, * `node disable`, `backend enable`, `backend disable`) so layer resolution * stays consistent. * * Merge precedence (low → high): `global` < `project` < `local`. Higher * layers override lower ones at the same key. */ export type ConfigTarget = "global" | "project" | "local"; /** Layers in merge-precedence order, low → high. Used by override detection. */ export const CONFIG_TARGETS_BY_PRECEDENCE: readonly ConfigTarget[] = ["global", "project", "local"] as const; /** * Returns the on-disk path for a single config layer. Throws when the * caller asks for a project-scoped layer without a `projectDir` (e.g. * `--local` outside a project context). Used by all write-side commands * to keep the targeted-path resolution in one place. */ export function getTargetConfigPath(target: ConfigTarget, projectDir?: string): string { const paths: ConfigLayerPaths = getConfigLayerPaths(projectDir); if (target === "global") { return paths.global; } if (target === "project") { if (paths.project === undefined) { throw new Error("Project layer requested but no projectDir was provided."); } return paths.project; } // local if (paths.local === undefined) { throw new Error("Local layer requested but no projectDir was provided."); } return paths.local; } /** * Resolves a `ConfigTarget` from CLI flag booleans, enforcing mutual * exclusion. Used by every write-side command that exposes * `-g / --global` and `--local` flags (`config set`, `config unset`, * `verification enable`, `verification disable`, `browser enable`, * `browser disable`, `node enable`, `node disable`, `backend enable`, * `backend disable`) so the flag-resolution rules stay identical. * * Returns `"project"` when neither flag is set (the default for * project-scoped commands). Throws on the impossible `--global --local` * combination — commander doesn't enforce mutual exclusion across * arbitrary option pairs, so we do. */ export function resolveConfigTargetFromFlags(opts: { global?: boolean; local?: boolean }): ConfigTarget { if (opts.global === true && opts.local === true) { throw new Error("Pass at most one of --global / --local."); } if (opts.global === true) { return "global"; } if (opts.local === true) { return "local"; } return "project"; } function loadAndValidateLayer(path: string): IronBeeConfig { const raw: IronBeeConfig = loadJsonFile(path); if (existsSync(path)) { assertVerificationShape(raw, path); assertTelemetryShape(raw, path); } return raw; } /** * Env-var → config-path override entry. Each declared env var, when set * to a non-empty string, overlays the merged file-layer config at the * given dotted path. * * Precedence is **env > local > project > global** — env always wins so * secrets (`IRONBEE_API_KEY`) stay out of committed files and CI / per- * shell setups can override file values without editing them. * * `coerce` translates the raw env string to a typed JSON value. Defaults * to pass-through (suitable for url / api key / arbitrary strings); for * boolean / number / array shapes, supply an explicit coercer when adding * a new entry. * * Empty-string env vars are intentionally treated as "unset" rather than * "set to empty" so a common shell pattern like `IRONBEE_API_KEY=` (clear * for one command) falls back to the file value instead of nuking it. */ export interface EnvOverride { envVar: string; configPath: string; coerce?: (raw: string) => unknown; } /** * Declared env-overrides. Add a row to extend — the loader picks the new * entry up automatically. Order doesn't matter (each entry maps a * distinct config path). */ export const ENV_OVERRIDES: readonly EnvOverride[] = [ { envVar: "IRONBEE_API_KEY", configPath: "collector.apiKey" }, ]; /** * Walk a dotted path, cloning every intermediate object we descend into * before mutating it, and set the leaf to `value`. Cloning along the * path is what makes this safe to call on a merged config without * polluting the original layer objects (which {@link mergeConfigLayers} * spread-shares at the top level only). * * Intermediates that exist but aren't plain objects (array / primitive * / null) get overwritten with `{}`. That matches the operator's intent * ("I want this nested key to exist") at the cost of clobbering a prior * shape that wouldn't have been a valid parent anyway. */ function setAtConfigPath(obj: Record, dotPath: string, value: unknown): void { const parts: string[] = dotPath.split("."); let cur: Record = obj; for (let i: number = 0; i < parts.length - 1; i++) { const k: string = parts[i]; const next: unknown = cur[k]; if (next !== null && next !== undefined && typeof next === "object" && !Array.isArray(next)) { cur[k] = { ...(next as Record) }; } else { cur[k] = {}; } cur = cur[k] as Record; } cur[parts[parts.length - 1]] = value; } /** * Apply {@link ENV_OVERRIDES} on top of a merged file-layer config. * * Returns a shallow clone of `config` with the overrides written in; * the input is never mutated (path-clone in {@link setAtConfigPath} * means previously-shared subtrees stay untouched). If no env vars are * set the original object is returned by reference — no clone cost * for the common case. */ export function applyEnvOverrides(config: IronBeeConfig): IronBeeConfig { let next: IronBeeConfig | undefined; for (const override of ENV_OVERRIDES) { const raw: string | undefined = process.env[override.envVar]; if (raw === undefined || raw.length === 0) { continue; } if (next === undefined) { next = { ...config }; } const value: unknown = override.coerce ? override.coerce(raw) : raw; setAtConfigPath(next as Record, override.configPath, value); } return next ?? config; } /** * Returns the env-override entry currently shadowing a given config * path (env var set + non-empty), or `undefined` when the path is not * shadowed. Used by `config set` / `config unset` to warn operators * when their file-layer edit would be invisible behind a live env * override. */ export function findActiveEnvOverride(dotPath: string): EnvOverride | undefined { for (const override of ENV_OVERRIDES) { if (override.configPath !== dotPath) { continue; } const raw: string | undefined = process.env[override.envVar]; if (raw !== undefined && raw.length > 0) { return override; } } return undefined; } export function loadConfig(projectDir?: string): IronBeeConfig { const paths: ConfigLayerPaths = getConfigLayerPaths(projectDir); const globalConfig: IronBeeConfig = loadAndValidateLayer(paths.global); const projectConfig: IronBeeConfig = paths.project ? loadAndValidateLayer(paths.project) : {}; const localConfig: IronBeeConfig = paths.local ? loadAndValidateLayer(paths.local) : {}; const merged: IronBeeConfig = mergeConfigLayers(mergeConfigLayers(globalConfig, projectConfig), localConfig); return applyEnvOverrides(merged); } // Glob → RegExp. Supports *, **, ** + slash (zero-or-more segments), ?, and // brace expansion {a,b,c} → (a|b|c). The trailing-slash form of ** matches // the empty path, so "server/**/*.ts" correctly matches both "server/api.ts" // (depth 0) and "server/sub/api.ts" (depth 1+). function globToRegExp(pattern: string): RegExp { // 1. Brace expansion `{a,b,c}` → `(a|b|c)` before any escaping. let p: string = pattern.replace(/\{([^}]+)\}/g, (_match: string, group: string): string => { return `(${group.split(",").map((s: string): string => s.trim()).join("|")})`; }); // 2. Tokenize globs to placeholders so the escape pass leaves them alone. p = p.replace(/\*\*\//g, "\x00DSS\x00") // **/ .replace(/\*\*/g, "\x00DS\x00") // ** .replace(/\*/g, "\x00SS\x00") // * .replace(/\?/g, "\x00QM\x00"); // ? // 3. Escape regex specials (parens / pipe stay literal — they came from brace expansion). p = p.replace(/[.+^$\\[\]]/g, "\\$&"); // 4. Restore globs as regex equivalents. p = p.replace(/\x00DSS\x00/g, "(?:.*/)?") .replace(/\x00DS\x00/g, ".*") .replace(/\x00SS\x00/g, "[^/]*") .replace(/\x00QM\x00/g, "[^/]"); return new RegExp(`(^|/)${p}$`); } function matchesAny(filePath: string, patterns: string[]): boolean { // Normalize Windows backslashes to forward slashes before matching — // glob patterns are written with `/` (POSIX style), and downstream // tools (Claude Code, Cursor) may emit paths in either separator form // depending on platform / how the path was assembled. const normalized: string = filePath.replace(/\\/g, "/"); for (const pattern of patterns) { if (globToRegExp(pattern).test(normalized)) { return true; } } return false; } /** Returns the per-cycle config block. Always-on browser, opt-in node / backend. */ function getCycleBlock(config: IronBeeConfig, cycle: string): CycleConfig | undefined { if (cycle === "browser") { return config.browser; } if (cycle === "node") { return config.node; } if (cycle === "backend") { return config.backend; } return undefined; } /** * Returns the effective pattern set for a cycle. Resolution order: * * 1. **`block.enable === false`** → cycle-specific disable signal written * by ` disable`. Mirrors `recording.enable` / `jobQueue.enable` * / `verification.enable`. * 2. **Block absent** → for default-on cycles (browser), the code defaults * kick in. For default-off cycles (node, backend), no patterns. * 3. **`verifyPatterns: []`** → hard kill (legacy disable marker — still * respected for older configs; new disables write `enable: false`). * 4. **Block present, `verifyPatterns` undefined** → enabled with code * defaults (`CYCLE_DEFAULT_VERIFY_PATTERNS[cycle]`). Post-` enable` * shape — minimal config, defaults track CLI version. * 5. **`verifyPatterns: [...non-empty]`** → enabled with custom patterns. * * `additionalVerifyPatterns` is appended to whatever base resolves at step * 4/5 — EXCEPT when the cycle is disabled via step 1 or 3. * * **Note:** does NOT check the master `verification.enable` switch. That * happens at the verification-level (install-time gating + the higher-level * {@link isCycleEnabled} helper). This function returns the cycle's pattern * shape independent of master state — useful for code paths that want * pattern info even when enforcement is off. */ function getCyclePatterns(config: IronBeeConfig, cycle: string): string[] { const block: CycleConfig | undefined = getCycleBlock(config, cycle); // Explicit per-cycle disable signal. if (block !== undefined && block.enable === false) { return []; } const cycleDefaults: readonly string[] = CYCLE_DEFAULT_VERIFY_PATTERNS[cycle] ?? []; if (block === undefined) { // Block absent: only default-on cycles activate via code defaults. if (CYCLES_ENABLED_BY_DEFAULT.has(cycle)) { return [...cycleDefaults]; } return []; } // Legacy hard kill: `verifyPatterns: []` still fully disables. Newer // configs use `enable: false` (above) but pre-existing configs may // carry this older marker — keep honoring it. if (Array.isArray(block.verifyPatterns) && block.verifyPatterns.length === 0) { return []; } // Block present, enabled: `verifyPatterns` wins when set; otherwise // fall back to code defaults (post-` enable` shape). const base: readonly string[] = block.verifyPatterns ?? cycleDefaults; const additional: string[] = block.additionalVerifyPatterns ?? []; return [...base, ...additional]; } /** * Returns the names of cycles whose pattern set matches `filePath`. * Used by verify-gate to determine which cycles are active for a Stop hook. * * Order: browser first, then each optional cycle in {@link OPTIONAL_CYCLES} * order. `ignoredVerifyPatterns` applies globally — a file matched there * activates no cycles. */ export function getActiveCycles(filePath: string, config: IronBeeConfig): string[] { const ignored: string[] = config.ignoredVerifyPatterns ?? []; if (ignored.length > 0 && matchesAny(filePath, ignored)) { return []; } const active: string[] = []; if (matchesAny(filePath, getCyclePatterns(config, "browser"))) { active.push("browser"); } for (const cycle of OPTIONAL_CYCLES) { const patterns: string[] = getCyclePatterns(config, cycle); if (patterns.length > 0 && matchesAny(filePath, patterns)) { active.push(cycle); } } return active; } /** * Returns true if a file is under verification by *any* cycle. Used by * `clear-verdict` and `require-verdict` hooks where the question is just * "should this edit be tracked / gated?", regardless of which cycle. */ export function requiresVerification(filePath: string, config: IronBeeConfig): boolean { return getActiveCycles(filePath, config).length > 0; } /** * Returns true if `cycle` is currently enabled for verification in `config` * (effective merged shape). * * Two conditions must BOTH hold: * 1. The master switch `verification.enable` is on (or absent — defaults * to true). When `verification.enable: false`, every cycle is off * regardless of its own block — per-cycle settings never override the * master switch. * 2. The cycle's own resolution per {@link getCyclePatterns} yields * non-empty patterns: * - `block.enable === false` → disabled (canonical signal). * - Block absent + default-on (browser) → enabled (code defaults). * - Block absent + default-off (node, backend) → disabled. * - `verifyPatterns: []` (legacy) → disabled. * - Block present with `verifyPatterns` undefined → enabled (defaults). * - Block present with non-empty `verifyPatterns` → enabled (custom). * * Used by `platform-section.syncPlatformSectionsToConfig` to decide whether to * splice the cycle's fragment or the placeholder into installed md files, * and by client `install` paths to gate MCP server entries + permissions. */ export function isCycleEnabled(config: IronBeeConfig, cycle: string): boolean { if (!getVerificationEnabled(config)) { return false; } return getCyclePatterns(config, cycle).length > 0; } /** * Per-cycle resolved required-tools spec. Falls back to defaults at this layer * so consumers always get a complete `{ alwaysRequired, evidencePaths }` shape. * * Validity rule: at least one of `alwaysRequired` or `evidencePaths` must be * non-empty. Both empty → config error (caught here, surfaced to caller). */ export function getRequiredToolsConfig(config: IronBeeConfig, cycle: string): RequiredToolsConfig { let alwaysRequired: string[]; let evidencePaths: EvidencePath[]; if (cycle === "browser") { alwaysRequired = config.browser?.alwaysRequired ?? DEFAULT_BROWSER_ALWAYS_REQUIRED; evidencePaths = config.browser?.evidencePaths ?? []; } else if (cycle === "node") { alwaysRequired = config.node?.alwaysRequired ?? DEFAULT_NODE_ALWAYS_REQUIRED; evidencePaths = config.node?.evidencePaths ?? DEFAULT_NODE_EVIDENCE_PATHS; } else if (cycle === "backend") { alwaysRequired = config.backend?.alwaysRequired ?? DEFAULT_BACKEND_ALWAYS_REQUIRED; evidencePaths = config.backend?.evidencePaths ?? DEFAULT_BACKEND_EVIDENCE_PATHS; } else { alwaysRequired = []; evidencePaths = []; } if (alwaysRequired.length === 0 && evidencePaths.length === 0) { throw new Error( `Invalid required-tools config for cycle '${cycle}': both 'alwaysRequired' and 'evidencePaths' are empty. ` + `At least one must specify required tools.` ); } return { alwaysRequired, evidencePaths }; } export interface BrowserDevToolsMCPConfig { [key: string]: unknown; } const DEFAULT_MCP_COMMAND: string = "npx"; const DEFAULT_MCP_ARGS: string[] = ["-y", "@ironbee-ai/devtools"]; const BROWSER_IRONBEE_ENV: Record = { TOOL_NAME_PREFIX: "bdt_", TOOL_INPUT_METADATA_ENABLE: "true", }; const NODE_IRONBEE_ENV: Record = { PLATFORM: "node", TOOL_NAME_PREFIX: "ndt_", TOOL_INPUT_METADATA_ENABLE: "true", }; const BACKEND_IRONBEE_ENV: Record = { PLATFORM: "backend", TOOL_NAME_PREFIX: "bedt_", TOOL_INPUT_METADATA_ENABLE: "true", }; const BROWSER_DEFAULT_MCP_ENV: Record = { BROWSER_DEVTOOLS_INSTALL_CHROMIUM: "true", }; const NODE_DEFAULT_MCP_ENV: Record = {}; const BACKEND_DEFAULT_MCP_ENV: Record = {}; /** * Auto-derive OTEL exporter env for the devtools MCP server when the * IronBee Collector is configured + enabled. Returns `{}` when the * collector is absent / disabled / missing a URL — in that case the MCP * server runs without telemetry, same as before. * * Precedence in the final env block (lowest → highest): * default static env < auto-injected OTEL env < user override env < ironbee env * * So operators can still override individual keys via `browserDevTools.env` * (e.g. point OTEL at a separate observability endpoint), or fully opt out * by setting `OTEL_ENABLE: "false"` in their override. * * `runtime` toggles browser-only vars (USER_INTERACTION_EVENTS, * BROWSER_HEADLESS_ENABLE) — node-devtools and backend-devtools don't * honor those. */ /** * Returns `{ TELEMETRY_ENABLE: "false" }` when telemetry is disabled in the * merged config, else `{}`. Injected into every devtools MCP server's env * block so the devtools package's own PostHog client stays off in lockstep * with the CLI's `telemetry.enable` switch. * * Single-direction switch — we only inject the disable flag, never the * enable flag, because `TELEMETRY_ENABLE=true` is the devtools default. * That means flipping CLI `telemetry.enable: true` (or stripping the key) * removes the env entry on the next rerender; the devtools child then * falls back to its own default. * * Precedence inside the MCP env (lowest → highest): * defaultEnv < otelEnv < telemetryEnv (here) < userEnv < ironbeeEnv * * So an operator can still override per-server via * `DevTools.env.TELEMETRY_ENABLE: "true"` if they want the CLI * switch to gate only the CLI's own telemetry (not the devtools side). */ function buildTelemetryEnv(config: IronBeeConfig): Record { if (getTelemetryEnabled(config)) { return {}; } return { TELEMETRY_ENABLE: "false" }; } function buildOTELEnv(config: IronBeeConfig, runtime: "browser" | "node" | "backend"): Record { if (!isCollectorConfigured(config)) { return {}; } // Cast: `isCollectorConfigured` already guaranteed url + apiKey are // non-empty strings. const section: { url: string; apiKey: string } = config.collector as { url: string; apiKey: string }; const env: Record = { OTEL_ENABLE: "true", OTEL_EXPORTER_HTTP_URL: section.url, OTEL_EXPORTER_HTTP_HEADERS: `X-API-Key=${section.apiKey}`, OTEL_EXPORTER_TYPE: "otlp/http-protobuf", }; if (runtime === "browser") { env.OTEL_INSTRUMENTATION_USER_INTERACTION_EVENTS = "change,input,click"; env.BROWSER_HEADLESS_ENABLE = "true"; } return env; } /** * Builds an MCP server entry for the given runtime ("browser" or "node"). * * Reads override config from `config.DevTools`: * - `<...>.mcp` — full MCP config (used as-is + ironbee env always wins) * - `<...>.env` — env vars merged into the default (npx -y) config * * Env precedence (lowest → highest): * 1. Static `defaultEnv` (e.g. `BROWSER_DEVTOOLS_INSTALL_CHROMIUM`) * 2. Auto-injected OTEL exporter env (when `collector` is configured + enabled) * 3. User override env from `DevTools.env` * 4. IronBee invariant env (`TOOL_NAME_PREFIX`, `TOOL_INPUT_METADATA_ENABLE`, `PLATFORM`) * * Operators can override individual OTEL vars or opt out entirely * (`DevTools.env.OTEL_ENABLE: "false"`). IronBee env always wins * so the wire contract (tool-name prefix, metadata-injection toggle) cannot * be broken from outside. */ function buildMcpEntry( config: IronBeeConfig, overrideKey: string, ironbeeEnv: Record, defaultEnv: Record, runtime: "browser" | "node" | "backend", ): BrowserDevToolsMCPConfig { const otelEnv: Record = buildOTELEnv(config, runtime); const telemetryEnv: Record = buildTelemetryEnv(config); const override: unknown = config[overrideKey]; if (override && typeof override === "object" && !Array.isArray(override)) { const block: Record = override as Record; if (block.mcp && typeof block.mcp === "object" && !Array.isArray(block.mcp)) { // Full-replacement MCP override. We still inject OTEL + telemetry // kill-switch into the env (auto-derived from config) and let // ironbee env win last. Operator's own `mcp.env` keys take // precedence over auto-injected env but lose to ironbee env. const mcp: BrowserDevToolsMCPConfig = { ...(block.mcp as BrowserDevToolsMCPConfig) }; const mcpEnv: Record = { ...otelEnv, ...telemetryEnv, ...(mcp.env as Record ?? {}), ...ironbeeEnv, }; mcp.env = mcpEnv; return mcp; } const userEnv: Record = {}; if (block.env && typeof block.env === "object" && !Array.isArray(block.env)) { const envObj: Record = block.env as Record; for (const key of Object.keys(envObj)) { if (typeof envObj[key] === "string") { userEnv[key] = envObj[key] as string; } } } return { command: DEFAULT_MCP_COMMAND, args: [...DEFAULT_MCP_ARGS], env: { ...defaultEnv, ...otelEnv, ...telemetryEnv, ...userEnv, ...ironbeeEnv }, }; } return { command: DEFAULT_MCP_COMMAND, args: [...DEFAULT_MCP_ARGS], env: { ...defaultEnv, ...otelEnv, ...telemetryEnv, ...ironbeeEnv }, }; } /** Returns the MCP server entry for `browser-devtools` (PLATFORM=browser, bdt_). */ export function getMcpServerEntry(projectDir?: string): BrowserDevToolsMCPConfig { const config: IronBeeConfig = loadConfig(projectDir); return buildMcpEntry(config, "browserDevTools", BROWSER_IRONBEE_ENV, BROWSER_DEFAULT_MCP_ENV, "browser"); } /** Returns the MCP server entry for `node-devtools` (PLATFORM=node, ndt_). */ export function getNodeDevToolsMcpEntry(projectDir?: string): BrowserDevToolsMCPConfig { const config: IronBeeConfig = loadConfig(projectDir); return buildMcpEntry(config, "nodeDevTools", NODE_IRONBEE_ENV, NODE_DEFAULT_MCP_ENV, "node"); } /** Returns the MCP server entry for `backend-devtools` (PLATFORM=backend, bedt_). */ export function getBackendDevToolsMcpEntry(projectDir?: string): BrowserDevToolsMCPConfig { const config: IronBeeConfig = loadConfig(projectDir); return buildMcpEntry(config, "backendDevTools", BACKEND_IRONBEE_ENV, BACKEND_DEFAULT_MCP_ENV, "backend"); } export function getMaxRetries(config: IronBeeConfig): number { return (typeof config.maxRetries === "number" && config.maxRetries > 0) ? config.maxRetries : DEFAULT_MAX_RETRIES; } /** * Returns true when the IronBee Collector is configured + active for this * config — i.e. the collector section is present, `enable !== false`, the * `url` is non-empty, and the `IRONBEE_COLLECTOR=false` env override isn't * set. Pure check over an already-loaded `IronBeeConfig` so callers that * have a config in hand don't pay an extra `loadConfig` round-trip. * * Used by the auto-enable behavior in `isJobQueueEnabled` / * `isRecordingEnabled` / `isAnalyticsEnabled`: when the operator has * configured a collector, we treat all three feature switches as * implicitly opted in. Rationale: setting up a collector is a strong * signal that the operator wants events to flow there, and these three * sections are exactly what makes events flow (queue dispatch, recording * enforcement, analytics derivation). Explicit `enable: false` on any of * them still wins — the auto-enable only fires for "section absent". */ export function isCollectorConfigured(config: IronBeeConfig): boolean { if (process.env.IRONBEE_COLLECTOR === "false") { return false; } const section: IronBeeConfig["collector"] = config.collector; if (!section) { return false; } if (section.enable === false) { return false; } if (typeof section.url !== "string" || section.url.length === 0) { return false; } // apiKey is required — a collector running without auth is a developer // convenience that ironbee no longer supports as the "configured" state. // Production deployments always have an api key; for local testing, // operators can either set a placeholder key or use IRONBEE_COLLECTOR=false // to fully disable. if (typeof section.apiKey !== "string" || section.apiKey.length === 0) { return false; } return true; } /** * Returns true when the job queue layer is enabled for this project. * * Resolution: * 1. Section explicitly disabled (`enable: false`) → `false`. * 2. Section present (any other shape) → `true` (presence-as-opt-in). * 3. Collector configured + valid → `true` (auto-enable: a collector is * pointless without a queue feeding it tool_call events). * 4. Otherwise → `false`. */ export function isJobQueueEnabled(projectDir?: string): boolean { const config: IronBeeConfig = loadConfig(projectDir); return isFeatureEnabledWithCollectorAutoEnable(config, config.jobQueue); } /** * Returns true when recording enforcement is enabled for this project. * Same resolution as `isJobQueueEnabled` (presence-as-opt-in + collector * auto-enable + explicit-disable wins). */ export function isRecordingEnabled(projectDir?: string): boolean { const config: IronBeeConfig = loadConfig(projectDir); return isFeatureEnabledWithCollectorAutoEnable(config, config.recording); } /** * Returns true when the statusline integration (`session_status` event + * statusline wrapper) is enabled. Same resolution as `isJobQueueEnabled` / * `isRecordingEnabled`: presence-as-opt-in + collector auto-enable + * explicit `enable: false` wins. Gates BOTH whether the wrapper is installed * (don't hijack the user's statusline when there's nowhere to send events) * AND whether each tick submits an event. */ export function isSessionStatusEnabled(config: IronBeeConfig): boolean { return isFeatureEnabledWithCollectorAutoEnable(config, config.statusLine); } /** * Whether to render a minimal default statusline when there is no upstream * statusline to chain to. Default `false` (silent — preserve the user's * "no statusline" experience). */ export function getStatusLineRenderDefault(config: IronBeeConfig): boolean { const section: IronBeeConfig["statusLine"] = config.statusLine; return section !== undefined && section.renderDefault === true; } /** * Minimum seconds between emitted `session_status` events (throttle on top of * skip-if-unchanged). Default 10; `0` (or negative) disables the throttle. */ export function getStatusLineEmitMinIntervalSeconds(config: IronBeeConfig): number { const v: unknown = config.statusLine?.emitMinIntervalSeconds; if (typeof v === "number" && Number.isFinite(v) && v >= 0) { return v; } return 10; } /** Default TTL (seconds) for the statusline OAuth rate-limit fetch cache. */ export const DEFAULT_OAUTH_USAGE_TTL_SECONDS: number = 60; /** * Whether IronBee may read the Claude Code OAuth token and call OAuth-scoped * Anthropic endpoints. Default-on (inverse semantics — opt out with * `claude.oauthAccess.enable: false`). Gates the statusline rate-limit fallback * and any future OAuth consumer. Claude-only. */ export function getClaudeOauthAccessEnabled(config: IronBeeConfig): boolean { return config.claude?.oauthAccess?.enable !== false; } /** * TTL (seconds) for the statusline rate-limit fetch cache — the `/api/oauth/usage` * call + credential read fire at most once per this interval. Default 60; * negative is clamped to the default (0 means "every tick", honored). */ export function getClaudeOauthAccessUsageTtlSeconds(config: IronBeeConfig): number { const v: unknown = config.claude?.oauthAccess?.usageTtlSeconds; if (typeof v === "number" && Number.isFinite(v) && v >= 0) { return v; } return DEFAULT_OAUTH_USAGE_TTL_SECONDS; } /** Default loopback port the OTEL collector daemon binds to. */ export const DEFAULT_OTEL_PORT: number = 15986; /** Default daemon idle-reap threshold (seconds). */ export const DEFAULT_OTEL_IDLE_TIMEOUT_SECONDS: number = 600; /** Default throttle between `ensure` health-checks from hooks (seconds). */ export const DEFAULT_OTEL_ENSURE_MIN_INTERVAL_SECONDS: number = 30; /** Default `session_context` per-session emit coalescing interval (seconds; 0 = off). */ export const DEFAULT_OTEL_EMIT_MIN_INTERVAL_SECONDS: number = 0; /** * Whether the OTEL collector pipeline (the daemon + `session_context` * derivation + the `.claude/settings.json` OTEL env block) is enabled. * Presence-as-opt-in + collector auto-enable + explicit `enable: false` wins — * same resolution as `isSessionStatusEnabled` / `isJobQueueEnabled`. */ export function isOTELEnabled(config: IronBeeConfig): boolean { return isFeatureEnabledWithCollectorAutoEnable(config, config.otel); } /** Loopback port for the OTEL collector daemon. Falls back to {@link DEFAULT_OTEL_PORT}. */ export function getOTELPort(config: IronBeeConfig): number { const v: unknown = config.otel?.port; if (typeof v === "number" && Number.isInteger(v) && v > 0 && v < 65536) { return v; } return DEFAULT_OTEL_PORT; } /** Daemon idle-reap threshold (seconds). Falls back to {@link DEFAULT_OTEL_IDLE_TIMEOUT_SECONDS}. */ export function getOTELIdleTimeoutSeconds(config: IronBeeConfig): number { const v: unknown = config.otel?.idleTimeoutSeconds; if (typeof v === "number" && Number.isFinite(v) && v > 0) { return v; } return DEFAULT_OTEL_IDLE_TIMEOUT_SECONDS; } /** Throttle (seconds) between `ensure` health-checks. Falls back to {@link DEFAULT_OTEL_ENSURE_MIN_INTERVAL_SECONDS}. */ export function getOTELEnsureMinIntervalSeconds(config: IronBeeConfig): number { const v: unknown = config.otel?.ensureMinIntervalSeconds; if (typeof v === "number" && Number.isFinite(v) && v >= 0) { return v; } return DEFAULT_OTEL_ENSURE_MIN_INTERVAL_SECONDS; } /** Per-session `session_context` emit coalescing interval (seconds; 0 = every request). */ export function getOTELEmitMinIntervalSeconds(config: IronBeeConfig): number { const v: unknown = config.otel?.emitMinIntervalSeconds; if (typeof v === "number" && Number.isFinite(v) && v >= 0) { return v; } return DEFAULT_OTEL_EMIT_MIN_INTERVAL_SECONDS; } /** * Claude Code's `statusLine.refreshInterval` (seconds) to write into the * statusLine block, or `undefined` when unset / invalid (`< 1`). Unset is the * default — IronBee writes no `refreshInterval`, leaving the host on its * event-driven default. */ export function getStatusLineRefreshInterval(config: IronBeeConfig): number | undefined { const v: unknown = config.statusLine?.refreshInterval; if (typeof v === "number" && Number.isFinite(v) && v >= 1) { return v; } return undefined; } /** * Shared resolution for sections that follow the "presence-as-opt-in + * auto-enable when collector is configured" pattern. `jobQueue`, `recording`, * `analytics` all use this. `collector` itself uses `isCollectorConfigured` * directly (it's the one that drives the auto-enable, not subject to it). */ function isFeatureEnabledWithCollectorAutoEnable(config: IronBeeConfig, section: unknown): boolean { // Explicit opt-out always wins, regardless of collector config. if (section !== undefined && section !== null && typeof section === "object" && !Array.isArray(section)) { if ((section as { enable?: unknown }).enable === false) { return false; } // Presence (with non-false `enable`) opts in. return true; } // Section absent → fall back to collector-driven auto-enable. return isCollectorConfigured(config); } function isPresenceEnabled(section: unknown): boolean { if (section === undefined || section === null || typeof section !== "object" || Array.isArray(section)) { return false; } return (section as { enable?: unknown }).enable !== false; } /** * Returns true when verification enforcement is enabled for this config. * * **Inverse semantics from `isJobQueueEnabled` / `isRecordingEnabled`**: * verification is the core feature, opt-out by `enable: false`. Section absent, * empty section, or `enable: true` all return true. Only `enable: false` * returns false. */ export function getVerificationEnabled(config: IronBeeConfig): boolean { const section: unknown = config.verification; if (section === undefined || section === null) { return true; } if (typeof section !== "object" || Array.isArray(section)) { return true; } return (section as { enable?: unknown }).enable !== false; } /** * Returns true when anonymous internal telemetry (PostHog) is enabled. * * **Inverse semantics — opt-out by `enable: false`.** Section absent, empty, * or `enable: true` all return true. Only `enable: false` returns false. * * Two callers consult this: * - `lib/telemetry.ts:isTelemetryEnabled()` gates the CLI's own PostHog * sends (env var > this config > legacy `telemetry.json` > default true). * - `buildMcpEntry` injects `TELEMETRY_ENABLE=false` into the devtools * MCP env when this returns false, so ironbee-devtools' own PostHog * stays off in lockstep. * * Kept distinct from `getVerificationEnabled` because the two switches are * orthogonal: an operator can run in monitoring-only mode but still send * anonymous CLI telemetry, OR enforce verification but disable telemetry. */ export function getTelemetryEnabled(config: IronBeeConfig): boolean { const section: unknown = config.telemetry; if (section === undefined || section === null) { return true; } if (typeof section !== "object" || Array.isArray(section)) { return true; } return (section as { enable?: unknown }).enable !== false; } const DEFAULT_MAX_CHANGESET_BYTES: number = 65536; /** * Returns true when `file_change` events should carry a unified-diff * `changeset` string. Off by default — the default wire shape stays * metadata-only (operation + line counts). */ export function getCaptureFileChangeset(config: IronBeeConfig): boolean { const section: unknown = config.fileChange; if (!section || typeof section !== "object" || Array.isArray(section)) { return false; } return (section as { captureChangeset?: unknown }).captureChangeset === true; } /** Per-event hard cap in bytes for the `changeset` string. Truncated past this. */ export function getMaxChangesetBytes(config: IronBeeConfig): number { const section: unknown = config.fileChange; if (!section || typeof section !== "object" || Array.isArray(section)) { return DEFAULT_MAX_CHANGESET_BYTES; } const raw: unknown = (section as { maxChangesetBytes?: unknown }).maxChangesetBytes; if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) { return DEFAULT_MAX_CHANGESET_BYTES; } return Math.floor(raw); } /** * Returns true when analytics collection is enabled for this project. * Same resolution as `isJobQueueEnabled` / `isRecordingEnabled`: * explicit-disable wins, presence opts in, and collector configured + * section absent auto-enables. */ export function isAnalyticsEnabled(projectDir?: string): boolean { const config: IronBeeConfig = loadConfig(projectDir); return isFeatureEnabledWithCollectorAutoEnable(config, config.analytics); } /** Whether the Stop hook should project + emit analytics. Default `true` when analytics is enabled. */ export function isAnalyticsEmitOnStopEnabled(projectDir?: string): boolean { const config: IronBeeConfig = loadConfig(projectDir); const section: unknown = config.analytics; if (section === null || typeof section !== "object" || Array.isArray(section)) { return true; } const v: unknown = (section as { emitOnStop?: unknown }).emitOnStop; return v !== false; } /** * Throttle interval (seconds) for Stop-hook projection. Default 0 (disabled). * * The CLI used to default to 30s here as a "don't hammer the backend on * hot Stop loops" guard. That was a CLI-side judgment about cadence — backends * can rate-limit / dedupe / aggregate however they want, and a 30s default * silently dropped per-Stop visibility (especially painful for verify-gate * retry loops where each Stop carries fresh signal). Default is now 0; * operators who DO want CLI-side throttling can set the config explicitly. * `throttleState` in `hook-trigger.ts` returns null when interval ≤ 0, so * 0 = no-op fast path. */ export function getAnalyticsEmitOnStopMinIntervalSeconds(projectDir?: string): number { const config: IronBeeConfig = loadConfig(projectDir); const section: unknown = config.analytics; if (section === null || typeof section !== "object" || Array.isArray(section)) { return 0; } const v: unknown = (section as { emitOnStopMinIntervalSeconds?: unknown }).emitOnStopMinIntervalSeconds; return typeof v === "number" && v >= 0 ? v : 0; } /** * Returns true when the per-turn `session_turn_analytics` wire records should * be emitted alongside `session_analytics`. **Default `false` (opt-in)** — * inverse of the `analytics` master switch's presence-as-opt-in. * * Rationale: turn-grain records are high-volume secondary signal; backends * that only want session aggregates shouldn't pay for them by default. The * base `session_analytics` record always ships when analytics is enabled. */ export function isAnalyticsTurnEventsEnabled(projectDir?: string): boolean { const config: IronBeeConfig = loadConfig(projectDir); const section: unknown = config.analytics; if (section === null || typeof section !== "object" || Array.isArray(section)) { return false; } return (section as { emitTurnEvents?: unknown }).emitTurnEvents === true; } /** * Returns true when the per-step `session_turn_step_analytics` wire records * should be emitted. **Default `false` (opt-in)** — same rationale as * {@link isAnalyticsTurnEventsEnabled}, with one extra dimension: step-grain * records are an order of magnitude higher volume than turn-grain. * * Independent of `emitTurnEvents`. Backend can reconstruct turn from steps, * so step-only / turn-only / both / neither are all valid combinations. */ export function isAnalyticsStepEventsEnabled(projectDir?: string): boolean { const config: IronBeeConfig = loadConfig(projectDir); const section: unknown = config.analytics; if (section === null || typeof section !== "object" || Array.isArray(section)) { return false; } return (section as { emitStepEvents?: unknown }).emitStepEvents === true; } /** * Returns true when the per-API-request `api_request` wire records should be * emitted. **Default `true` (opt-out)** — INVERSE of the turn / step event * gating, mirroring the `verification.enable` opt-out semantics. Per-request * audit (cost attribution, failure tracking, request-id timeline) is the * primary value of the analytics pipeline for backend cost accounting, so * it ships by default. Operators can opt out via `emitApiRequestEvents: false` * if backend doesn't consume them. * * Independent of `emitTurnEvents` / `emitStepEvents`. The analytics master * switch ({@link isAnalyticsEnabled}) is gated separately upstream — this * function answers only "given that analytics is on, should api_request ship?" * Section absent therefore falls back to the opt-out default (`true`), so * collector-driven auto-enable (analytics section omitted but `collector` * configured) still ships api_request events as documented. */ export function isAnalyticsApiRequestEventsEnabled(projectDir?: string): boolean { const config: IronBeeConfig = loadConfig(projectDir); const section: unknown = config.analytics; if (section === null || typeof section !== "object" || Array.isArray(section)) { return true; } return (section as { emitApiRequestEvents?: unknown }).emitApiRequestEvents !== false; }