@@ -7,7 +7,7 @@ import { Command } from 'commander'
77import { z } from 'zod'
88import { readFileSync } from 'node:fs'
99import assert from 'node:assert/strict'
10- import type { ResolvedConfig } from './config/types.ts'
10+ import type { ResolvedConfig , CommandPolicy } from './config/types.ts'
1111import { getResolvedConfig } from './config/store.ts'
1212import { extractSchemaArgs , validateSchemaArgs } from './lib/schema-args.ts'
1313import 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 */
181236function 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 } : { } )
0 commit comments