Skip to content
19 changes: 15 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Command } from 'commander'
import { defineCommand, defineGroup, hideBlockedCommands } from './factory.js'
import type { OpaqueCommandHandle } from './factory.js'
import { loadConfig, type LoadConfigResult } from './config/loader.ts'
import { BUILT_IN_PROFILES, type BuiltInProfile } from './config/profiles.ts'
import { setResolvedConfig } from './config/store.ts'
import { renderLogo } from './lib/logo.ts'

Expand All @@ -22,6 +23,7 @@ program
.description('Interface with the Elastic Stack and Elastic Cloud from the command line.')
.option('--config-file <path>', 'path to a config file (default: ~/.elasticrc.yml)')
.option('--use-context <name>', 'override the active context from the config file')
.option(`--command-profile <name>`, `restrict available commands to a deployment profile (${BUILT_IN_PROFILES.join(', ')})`)
.option('--json', 'output as JSON')
.option('--output-fields <list>', 'comma-separated list of fields to include in output (dot-notation supported)')
.option('--output-template <string>', 'Mustache-like template for custom text output (e.g. "{{id}}: {{name}}")')
Expand All @@ -43,16 +45,18 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
for (let c = actionCommand.parent; c != null; c = c.parent) {
if (c.name() === 'config') return
}
const { configFile: configPath, useContext: contextName } = thisCommand.opts()
const { configFile: configPath, useContext: contextName, commandProfile: profileName } = thisCommand.opts()
const typedProfileName = profileName as BuiltInProfile | undefined

if (configPath == null && contextName == null && earlyConfig?.ok === true) {
if (configPath == null && contextName == null && profileName == null && earlyConfig?.ok === true) {
setResolvedConfig(earlyConfig.value)
return
}

const result = await loadConfig({
...(configPath != null && { configPath }),
...(contextName != null && { contextName })
...(contextName != null && { contextName }),
...(typedProfileName != null && { profileName: typedProfileName }),
})
if (result.ok) {
setResolvedConfig(result.value)
Expand Down Expand Up @@ -177,7 +181,14 @@ if (firstArg === 'sanitize') {
// to avoid unnecessary file I/O and a confusing "no config found" path.
// The result is cached in earlyConfig so the preAction hook can reuse it.
if (firstArg !== 'version' && firstArg !== 'config' && firstArg !== 'sanitize') {
earlyConfig = await loadConfig({})
// Parse --profile early (before Commander's full parse) so the early config load
// and hideBlockedCommands can apply the correct profile-based allow-list to --help.
const profileArgIdx = process.argv.indexOf('--command-profile')
const earlyProfile = profileArgIdx !== -1 ? process.argv[profileArgIdx + 1] as BuiltInProfile | undefined : undefined

earlyConfig = await loadConfig({
...(earlyProfile != null && { profileName: earlyProfile }),
})
if (earlyConfig.ok) {
setResolvedConfig(earlyConfig.value)
hideBlockedCommands(program, earlyConfig.value.commands)
Expand Down
100 changes: 95 additions & 5 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { ContextSchema, CommandPolicySchema, StructuralConfigSchema } from './sc
import { resolveExpressions } from '@elastic/config-resolver'
import { hasInlineSecrets, type RawConfig } from './writer.ts'
import type { ConfigFile, ResolvedConfig, ResolvedContext } from './types.ts'
import { BUILT_IN_PROFILES, type BuiltInProfile } from './profiles.ts'

/** Extensions that are rejected to prevent arbitrary code execution. */
const EXECUTABLE_EXTENSIONS = new Set(['.js', '.ts', '.mjs', '.cjs'])
Expand Down Expand Up @@ -103,15 +104,80 @@ export async function loadConfigFile (filePath: string): Promise<unknown> {
* @param contextName - The key of the context to resolve.
* @returns A `ResolvedConfig` wrapping only that context's service blocks.
*/
export function resolveContext (config: ConfigFile, contextName: string): ResolvedConfig {
/**
* Computes the effective command policy from the per-context policy, the root policy,
* a `default_profile` fallback, and an optional `--profile` flag override.
*
* Precedence (highest to lowest):
* 1. `profileOverride` — the `--profile` CLI flag
* 2. Per-context `commands.profile` / `commands.allowed`
* 3. Root `commands.profile` / `commands.allowed`
* 4. `defaultProfile` — `default_profile` from the root config
*
* `blocked` lists from context and root are unioned (further restriction always applies).
* `profile` and `allowed` are mutually exclusive; passing `--profile` with a context
* that declares `commands.allowed` is an error.
*
* Returns `undefined` when no filtering is active (allow everything).
* Returns `{ error }` on an invalid combination.
*/
export function resolveEffectiveCommands (
contextCommands: ConfigFile['commands'],
rootCommands: ConfigFile['commands'],
defaultProfile: BuiltInProfile | undefined,
profileOverride: BuiltInProfile | undefined,
): { commands: ConfigFile['commands'] } | { error: string } {
// Effective allowed/blocked from config (context overrides root)
const effectiveAllowed = contextCommands?.allowed ?? rootCommands?.allowed
const contextBlocked = contextCommands?.blocked ?? []
const rootBlocked = rootCommands?.blocked ?? []
const effectiveBlocked = [...contextBlocked, ...rootBlocked.filter(p => !contextBlocked.includes(p))]

// Effective profile from precedence chain
const configProfile = contextCommands?.profile ?? rootCommands?.profile ?? defaultProfile
const effectiveProfile = profileOverride ?? configProfile

// Validate: --profile flag cannot be used when allowed is set
if (profileOverride != null && effectiveAllowed != null) {
return { error: '--profile flag cannot be used with a context that has commands.allowed set' }
}

const hasProfile = effectiveProfile != null
const hasAllowed = effectiveAllowed != null
const hasBlocked = effectiveBlocked.length > 0

if (!hasProfile && !hasAllowed && !hasBlocked) return { commands: undefined }

return {
commands: {
...(hasProfile && { profile: effectiveProfile }),
...(hasAllowed && { allowed: effectiveAllowed }),
...(hasBlocked && { blocked: effectiveBlocked }),
},
}
}

export function resolveContext (config: ConfigFile, contextName: string, profileOverride?: BuiltInProfile): ResolvedConfig {
// non-null: caller (loadConfig) guarantees contextName exists in config.contexts
const ctx = config.contexts[contextName]!
const resolved: ResolvedContext = {}
if (ctx.elasticsearch != null) resolved.elasticsearch = ctx.elasticsearch
if (ctx.kibana != null) resolved.kibana = ctx.kibana
if (ctx.cloud != null) resolved.cloud = ctx.cloud
const result: ResolvedConfig = { context: resolved }
if (config.commands != null) result.commands = config.commands

const effectiveCommandsResult = resolveEffectiveCommands(
ctx.commands,
config.commands,
config.default_profile,
profileOverride,
)
if ('error' in effectiveCommandsResult) {
// Surface the error; callers (loadConfig) catch this and return LoadConfigErr
throw new Error(effectiveCommandsResult.error)
}
if (effectiveCommandsResult.commands != null) result.commands = effectiveCommandsResult.commands

if (config.banner != null) result.banner = config.banner
return result
}
Expand Down Expand Up @@ -151,6 +217,8 @@ export interface LoadConfigOptions {
configPath?: string
/** Context name override (`--use-context` flag). Overrides `current_context` in the file. */
contextName?: string
/** Profile name override (`--profile` flag). Overrides any profile set in the config file. */
profileName?: BuiltInProfile
}

/** Successful result from {@link loadConfig}. */
Expand Down Expand Up @@ -183,7 +251,13 @@ export type LoadConfigResult = LoadConfigOk | LoadConfigErr
* @returns A `LoadConfigResult` discriminated union.
*/
export async function loadConfig (options: LoadConfigOptions = {}): Promise<LoadConfigResult> {
const { configPath, contextName } = options
const { configPath, contextName, profileName } = options

// Validate profileName early (before any I/O) so the error is immediate and clear
if (profileName != null && !(BUILT_IN_PROFILES as readonly string[]).includes(profileName)) {
const valid = BUILT_IN_PROFILES.join(', ')
return { ok: false, error: { message: `Unknown profile "${profileName}". Valid profiles: ${valid}` } }
}

// Step 1: load raw config
// Precedence: --config-file flag > ELASTIC_CLI_CONFIG_FILE env var > home-directory discovery
Expand Down Expand Up @@ -223,7 +297,7 @@ export async function loadConfig (options: LoadConfigOptions = {}): Promise<Load
return { ok: false, error: { message: z.prettifyError(structural.error) } }
}

const { current_context, contexts, commands: rawCommands } = structural.data
const { current_context, contexts, commands: rawCommands, default_profile: rawDefaultProfile } = structural.data

// Step 3: resolve context name (--use-context override or current_context from file)
const resolvedContextName = contextName ?? current_context
Expand All @@ -238,6 +312,16 @@ export async function loadConfig (options: LoadConfigOptions = {}): Promise<Load
}
}

// Validate default_profile if present
let defaultProfile: BuiltInProfile | undefined
if (rawDefaultProfile != null) {
if (!(BUILT_IN_PROFILES as readonly unknown[]).includes(rawDefaultProfile)) {
const valid = BUILT_IN_PROFILES.join(', ')
return { ok: false, error: { message: `default_profile: unknown profile "${String(rawDefaultProfile)}". Valid profiles: ${valid}` } }
}
defaultProfile = rawDefaultProfile as BuiltInProfile
}

// Step 4: resolve expressions only in active context and commands (in parallel)
let resolvedRawContext: unknown
let resolvedRawCommands: unknown
Expand Down Expand Up @@ -271,7 +355,13 @@ export async function loadConfig (options: LoadConfigOptions = {}): Promise<Load
current_context: resolvedContextName,
contexts: { [resolvedContextName]: contextParsed.data },
...(commands != null && { commands }),
...(defaultProfile != null && { default_profile: defaultProfile }),
...(structural.data.banner != null && { banner: structural.data.banner }),
}
return { ok: true, value: resolveContext(config, resolvedContextName) }
try {
return { ok: true, value: resolveContext(config, resolvedContextName, profileName) }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { ok: false, error: { message } }
}
}
69 changes: 69 additions & 0 deletions src/config/profiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and contributors
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Built-in command profiles for deployment-aware API surface filtering.
*
* A profile resolves to an effective allow-list at config load time,
* which then flows through the same `isCommandAllowed` / `hideBlockedCommands`
* path as the manual `commands.allowed` policy.
*
* Built-in profiles:
* - `serverless` — only commands that work on Elastic Serverless. Hides
* `cloud hosted` and exposes `cloud serverless` + all stack commands.
* - `stack` — full surface including self-managed / hosted-only APIs.
* Equivalent to having no policy (allow everything).
* - `default` — alias for `serverless`; the most conservative baseline,
* recommended for agents and LLM-based tooling.
*/

/** The set of valid built-in profile names. */
export const BUILT_IN_PROFILES = ['serverless', 'stack', 'default'] as const

/** Union of valid built-in profile name strings. */
export type BuiltInProfile = typeof BUILT_IN_PROFILES[number]

/**
* Resolves a built-in profile to an effective allow-list.
*
* Returns `null` for `stack`, which means "no restriction" (allow everything).
* Returns an object with `allowed` patterns for all other profiles.
*/
export function resolveBuiltinProfile (name: BuiltInProfile): { allowed: readonly string[] } | null {
if (name === 'stack') return null

// `default` is an alias for `serverless`
return {
allowed: [
// CLI utilities — always visible regardless of deployment type
'version',
'docs.*',
'config.*',
'sanitize.*',

// Top-level es/kb alias stubs (leaf nodes that redirect to stack.es/stack.kb)
'es',
'kb',

// All stack commands (Elasticsearch + Kibana)
// Individual serverless-incompatible ES endpoints will be filtered in a
// future iteration once per-command availability metadata is added to the
// API manifest (see issue #283 for tracking).
'stack.*',

// Cloud cross-cutting namespaces (apply to both Hosted and Serverless)
'cloud.trust.*',
'cloud.auth.*',
'cloud.orgs.*',
'cloud.users.*',
'cloud.billing.*',

// Cloud Serverless management
'cloud.serverless.*',

// cloud.hosted.* is intentionally excluded from serverless/default profiles
],
}
}
60 changes: 45 additions & 15 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { z } from 'zod'
import { BUILT_IN_PROFILES } from './profiles.ts'

/**
* Zod schemas for configuration file validation.
Expand Down Expand Up @@ -41,34 +42,61 @@ export const ServiceBlockSchema = z.object({
auth: AuthSchema.optional()
})

/** A context value: optional service blocks with at least one present. */
export const ContextSchema = z
.object({
elasticsearch: ServiceBlockSchema.optional(),
kibana: ServiceBlockSchema.optional(),
cloud: ServiceBlockSchema.optional()
})
.refine(
(ctx) => ctx.elasticsearch != null || ctx.kibana != null || ctx.cloud != null,
{ error: 'at least one service block (elasticsearch, kibana, or cloud) is required' }
)

/**
* Policy controlling which commands are permitted to run.
* Only one of `allowed` or `blocked` may be present.
* Entries may use a trailing wildcard (e.g. `elasticsearch.*`) to match a namespace.
*
* Mutually exclusive combinations:
* - `profile` and `allowed` cannot both be set (profile replaces the allow-list)
* - `allowed` and `blocked` cannot both be set
*
* Valid combinations:
* - `profile` alone — use a built-in allow-list
* - `profile` + `blocked` — built-in allow-list with additional restrictions
* - `allowed` alone — explicit allow-list
* - `blocked` alone — explicit deny-list (everything else is allowed)
*
* Entries may use a trailing wildcard (e.g. `stack.es.*`) to match a namespace.
*/
export const CommandPolicySchema = z
.object({
profile: z.enum(BUILT_IN_PROFILES).optional(),
allowed: z.array(z.string().min(1)).min(1).optional(),
blocked: z.array(z.string().min(1)).min(1).optional(),
})
.refine(
(p) => !(p.profile != null && p.allowed != null),
{ error: 'commands: "profile" and "allowed" are mutually exclusive' },
)
.refine(
(p) => !(p.allowed != null && p.blocked != null),
{ error: 'commands: "allowed" and "blocked" are mutually exclusive' },
)

/** The root configuration file structure. */
/**
* A context value: optional service blocks with at least one present, plus
* an optional per-context command policy that overrides the root-level policy.
*/
export const ContextSchema = z
.object({
elasticsearch: ServiceBlockSchema.optional(),
kibana: ServiceBlockSchema.optional(),
cloud: ServiceBlockSchema.optional(),
commands: CommandPolicySchema.optional(),
})
.refine(
(ctx) => ctx.elasticsearch != null || ctx.kibana != null || ctx.cloud != null,
{ error: 'at least one service block (elasticsearch, kibana, or cloud) is required' }
)

/**
* The root configuration file structure.
*
* `default_profile` sets a fallback profile for all contexts that don't
* specify their own `commands.profile`. It is overridden by a per-context
* `commands.profile` and by the `--profile` CLI flag.
*
* `commands` is the root-level policy; per-context `commands` takes precedence.
*/
export const ConfigFileSchema = z
.object({
current_context: z.string().min(1),
Expand All @@ -77,6 +105,7 @@ export const ConfigFileSchema = z
{ error: 'contexts must contain at least one entry' },
),
commands: CommandPolicySchema.optional(),
default_profile: z.enum(BUILT_IN_PROFILES).optional(),
banner: z.boolean().optional(),
})
.refine(
Expand All @@ -97,5 +126,6 @@ export const StructuralConfigSchema = z
{ error: 'contexts must contain at least one entry' },
),
commands: z.unknown().optional(),
default_profile: z.unknown().optional(),
banner: z.boolean().optional(),
})
1 change: 1 addition & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
ConfigFileSchema,
CommandPolicySchema,
} from './schema.ts'
export type { BuiltInProfile } from './profiles.ts'

/**
* TypeScript types exported from Zod schemas for the configuration system.
Expand Down
Loading
Loading