Skip to content

Commit 02b0474

Browse files
authored
Add allow/deny list support for commands in config YAML (#66)
1 parent 97def5d commit 02b0474

9 files changed

Lines changed: 548 additions & 14 deletions

File tree

src/cli.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import { Command } from 'commander'
88
import { createRequire } from 'node:module'
9-
import { defineCommand, defineGroup } from './factory.ts'
9+
import { defineCommand, defineGroup, hideBlockedCommands } from './factory.js'
1010
import { loadConfig } from './config/loader.ts'
1111
import { setResolvedConfig } from './config/store.ts'
1212

@@ -67,9 +67,18 @@ if (firstArg === 'cloud') {
6767
program.addCommand(defineGroup({ name: 'cloud', description: 'Manage Elastic Cloud deployments and serverless projects' }))
6868
}
6969

70+
// Load config before Commander parses so --help can hide blocked commands.
71+
// Uses cosmiconfig auto-discovery (the preAction hook re-loads with any
72+
// --config/--context overrides when an actual command runs).
73+
const earlyResult = await loadConfig({})
74+
if (earlyResult.ok) {
75+
setResolvedConfig(earlyResult.value)
76+
hideBlockedCommands(program, earlyResult.value.commands)
77+
}
78+
7079
if (process.argv.slice(2).length === 0) {
7180
program.outputHelp()
7281
process.exit(0)
7382
}
7483

75-
program.parseAsync(process.argv)
84+
await program.parseAsync(process.argv)

src/config/loader.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ export function resolveContext (config: ConfigFile, contextName: string): Resolv
6363
if (ctx.elasticsearch != null) resolved.elasticsearch = ctx.elasticsearch
6464
if (ctx.kibana != null) resolved.kibana = ctx.kibana
6565
if (ctx.cloud != null) resolved.cloud = ctx.cloud
66-
return { context: resolved }
66+
const result: ResolvedConfig = { context: resolved }
67+
if (config.commands != null) result.commands = config.commands
68+
return result
6769
}
6870

6971
/** Options accepted by {@link loadConfig}. */

src/config/schema.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,30 @@ export const ContextSchema = z
5050
{ error: 'at least one service block (elasticsearch, kibana, or cloud) is required' }
5151
)
5252

53+
/**
54+
* Policy controlling which commands are permitted to run.
55+
* Only one of `allowed` or `blocked` may be present.
56+
* Entries may use a trailing wildcard (e.g. `elasticsearch.*`) to match a namespace.
57+
*/
58+
export const CommandPolicySchema = z
59+
.looseObject({
60+
allowed: z.array(z.string().min(1)).min(1).optional(),
61+
blocked: z.array(z.string().min(1)).min(1).optional(),
62+
})
63+
.refine(
64+
(p) => !(p.allowed != null && p.blocked != null),
65+
{ error: 'commands: "allowed" and "blocked" are mutually exclusive' },
66+
)
67+
5368
/** The root configuration file structure. */
5469
export const ConfigFileSchema = z
5570
.looseObject({
5671
current_context: z.string().min(1),
5772
contexts: z.record(z.string(), ContextSchema).refine(
5873
(map) => Object.keys(map).length > 0,
59-
{ error: 'contexts must contain at least one entry' }
60-
)
74+
{ error: 'contexts must contain at least one entry' },
75+
),
76+
commands: CommandPolicySchema.optional(),
6177
})
6278
.refine(
6379
(cfg) => cfg.current_context in cfg.contexts,

src/config/store.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,8 @@ export function setResolvedConfig (config: ResolvedConfig): void {
3333
export function getResolvedConfig (): ResolvedConfig | undefined {
3434
return _config
3535
}
36+
37+
/** Resets the store to its initial state. Intended for test cleanup only. */
38+
export function _testResetConfig(): void {
39+
_config = undefined
40+
}

src/config/types.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import type {
88
AuthSchema,
99
ServiceBlockSchema,
1010
ContextSchema,
11-
ConfigFileSchema
11+
ConfigFileSchema,
12+
CommandPolicySchema,
1213
} from './schema.ts'
1314

1415
/**
@@ -35,7 +36,10 @@ export type Context = z.infer<typeof ContextSchema>
3536
/** The root configuration file structure. */
3637
export type ConfigFile = z.infer<typeof ConfigFileSchema>
3738

38-
/** The active context after resolution -- only its configured service blocks, no extras. */
39+
/** Policy controlling which commands are permitted to run. */
40+
export type CommandPolicy = z.infer<typeof CommandPolicySchema>
41+
42+
/** The active context after resolution — only its configured service blocks, no extras. */
3943
export interface ResolvedContext {
4044
elasticsearch?: ServiceBlock
4145
kibana?: ServiceBlock
@@ -45,4 +49,6 @@ export interface ResolvedContext {
4549
/** Typed configuration object passed to command handlers after loading and context resolution. */
4650
export interface ResolvedConfig {
4751
context: ResolvedContext
52+
/** Optional command allow/deny policy from the config file. */
53+
commands?: CommandPolicy
4854
}

src/factory.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Command } from 'commander'
77
import { z } from 'zod'
88
import { readFileSync } from 'node:fs'
99
import assert from 'node:assert/strict'
10-
import type { ResolvedConfig } from './config/types.ts'
10+
import type { ResolvedConfig, CommandPolicy } from './config/types.ts'
1111
import { getResolvedConfig } from './config/store.ts'
1212
import { extractSchemaArgs, validateSchemaArgs } from './lib/schema-args.ts'
1313
import type { SchemaArgDefinition } from './lib/schema-args.ts'
@@ -177,6 +177,61 @@ export function _testSetStdinReader (fn: () => string): () => void {
177177
return () => { stdinReader = prev }
178178
}
179179

180+
/**
181+
* Returns true if `commandDotPath` is permitted under the given policy.
182+
*
183+
* Matching rules:
184+
* - No policy (or empty policy) → always allowed
185+
* - `allowed` list → command must match at least one entry
186+
* - `blocked` list → command must NOT match any entry
187+
* - Entries ending with `.*` match any command whose dot-path starts with the prefix and a `.`
188+
* (e.g. `elasticsearch.*` matches `elasticsearch.search` and `elasticsearch.indices.get`
189+
* but NOT `elasticsearch` itself)
190+
* - All other entries are exact matches
191+
*/
192+
export function isCommandAllowed(commandDotPath: string, policy: CommandPolicy | undefined): boolean {
193+
if (policy == null) return true
194+
195+
function matches(pattern: string): boolean {
196+
if (pattern.endsWith('.*')) {
197+
const prefix = pattern.slice(0, -2)
198+
return commandDotPath === prefix + '.' + commandDotPath.slice(prefix.length + 1) &&
199+
commandDotPath.startsWith(prefix + '.')
200+
}
201+
return commandDotPath === pattern
202+
}
203+
204+
if (policy.allowed != null) return policy.allowed.some(matches)
205+
if (policy.blocked != null) return !policy.blocked.some(matches)
206+
return true
207+
}
208+
209+
// Commander checks `_hidden` to exclude commands from --help, but the
210+
// property isn't in the public typings —
211+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
212+
function setHidden(cmd: OpaqueCommandHandle, value: boolean): void { (cmd as unknown as any)._hidden = value }
213+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
214+
function isHidden(cmd: OpaqueCommandHandle): boolean { return (cmd as unknown as any)._hidden === true }
215+
216+
/**
217+
* Walk the command tree and hide any commands the policy blocks.
218+
* Groups where every child is hidden are hidden too.
219+
* Call on the root program so dot-paths like `es.cat.health` are built correctly.
220+
*/
221+
export function hideBlockedCommands(root: OpaqueCommandHandle, policy: CommandPolicy | undefined, prefix = ''): void {
222+
if (policy == null) return
223+
for (const child of root.commands as OpaqueCommandHandle[]) {
224+
const path = prefix ? `${prefix}.${child.name()}` : child.name()
225+
const subs = child.commands as OpaqueCommandHandle[]
226+
if (subs.length > 0) {
227+
hideBlockedCommands(child, policy, path)
228+
if (subs.every(isHidden)) setHidden(child, true)
229+
} else {
230+
setHidden(child, !isCommandAllowed(path, policy))
231+
}
232+
}
233+
}
234+
180235
/** converts a kebab-case option name to camelCase to match Commander's opts() keys */
181236
function camelCase (s: string): string {
182237
return s.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())
@@ -531,6 +586,27 @@ export function defineCommand<T extends z.ZodType> (config: CommandConfig<T>): O
531586
}
532587

533588
const resolvedConfig = getResolvedConfig()
589+
590+
// enforce command policy before any other work
591+
if (resolvedConfig?.commands != null) {
592+
// commandPath returns e.g. "elastic elasticsearch search"; strip root program name and dot-join
593+
const parts = commandPath(cmd).split(' ')
594+
// if mounted under a root program (e.g. "elastic"), strip that first segment
595+
const dotPath = (parts.length > 1 ? parts.slice(1) : parts).join('.')
596+
if (!isCommandAllowed(dotPath, resolvedConfig.commands)) {
597+
if (jsonFormat === true) {
598+
process.stdout.write(JSON.stringify({
599+
error: {
600+
code: 'command_blocked',
601+
message: `command "${dotPath}" is not allowed by the current policy`,
602+
},
603+
}) + '\n')
604+
throw Object.assign(new Error('command_blocked'), { exitCode: 1 })
605+
}
606+
return cmd.error(`command "${dotPath}" is not allowed by the current policy`)
607+
}
608+
}
609+
534610
const parsed: ParsedResult<z.infer<T>> = {
535611
options,
536612
...(resolvedConfig != null ? { config: resolvedConfig } : {})

test/config/loader.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,96 @@ describe('loadConfig -- --config-file override', () => {
240240
assert.ok(!result.ok, 'loadConfig should fail for a nonexistent explicit config path')
241241
})
242242
})
243+
244+
// ---------------------------------------------------------------------------
245+
// T019 — commands policy threading through loadConfig and resolveContext
246+
// ---------------------------------------------------------------------------
247+
248+
describe('T019: commands policy in ResolvedConfig', () => {
249+
let tmpDir: string
250+
after(async () => rm(tmpDir, { recursive: true }))
251+
before(async () => {
252+
tmpDir = await mkdtemp(join(tmpdir(), 'elastic-cli-policy-'))
253+
})
254+
255+
it('resolveContext includes commands policy when present', () => {
256+
const config: ConfigFile = {
257+
...VALID_CONFIG_OBJECT,
258+
commands: { allowed: ['ping', 'elasticsearch.search'] },
259+
}
260+
const resolved = resolveContext(config, 'local')
261+
assert.deepEqual(resolved.commands, { allowed: ['ping', 'elasticsearch.search'] })
262+
})
263+
264+
it('resolveContext omits commands when not present in config', () => {
265+
const resolved = resolveContext(VALID_CONFIG_OBJECT, 'local')
266+
assert.equal(resolved.commands, undefined)
267+
})
268+
269+
it('loadConfig threads allowed list into ResolvedConfig', async () => {
270+
const yaml = `
271+
current_context: local
272+
contexts:
273+
local:
274+
elasticsearch:
275+
url: http://localhost:9200
276+
auth:
277+
api_key: key1
278+
commands:
279+
allowed:
280+
- ping
281+
- elasticsearch.search
282+
`.trimStart()
283+
const configPath = join(tmpDir, 'allowed.yml')
284+
await writeFile(configPath, yaml)
285+
const result = await loadConfig({ configPath })
286+
assert.ok(result.ok)
287+
if (!result.ok) return
288+
assert.deepEqual(result.value.commands, { allowed: ['ping', 'elasticsearch.search'] })
289+
})
290+
291+
it('loadConfig threads blocked list into ResolvedConfig', async () => {
292+
const yaml = `
293+
current_context: local
294+
contexts:
295+
local:
296+
elasticsearch:
297+
url: http://localhost:9200
298+
auth:
299+
api_key: key1
300+
commands:
301+
blocked:
302+
- elasticsearch.bulk
303+
- config.*
304+
`.trimStart()
305+
const configPath = join(tmpDir, 'blocked.yml')
306+
await writeFile(configPath, yaml)
307+
const result = await loadConfig({ configPath })
308+
assert.ok(result.ok)
309+
if (!result.ok) return
310+
assert.deepEqual(result.value.commands, { blocked: ['elasticsearch.bulk', 'config.*'] })
311+
})
312+
313+
it('loadConfig returns error for config with both allowed and blocked', async () => {
314+
const yaml = `
315+
current_context: local
316+
contexts:
317+
local:
318+
elasticsearch:
319+
url: http://localhost:9200
320+
auth:
321+
api_key: key1
322+
commands:
323+
allowed:
324+
- ping
325+
blocked:
326+
- elasticsearch.bulk
327+
`.trimStart()
328+
const configPath = join(tmpDir, 'both.yml')
329+
await writeFile(configPath, yaml)
330+
const result = await loadConfig({ configPath })
331+
assert.ok(!result.ok, 'should fail when both allowed and blocked are present')
332+
if (result.ok) return
333+
assert.match(result.error.message, /mutually exclusive/)
334+
})
335+
})

0 commit comments

Comments
 (0)