@@ -30,14 +30,21 @@ export type DropReason =
3030 | 'dynamic_command'
3131 | 'curl_pipe_shell'
3232 | 'eval_dynamic'
33+ | 'unsupported_construct'
3334 | 'empty' ;
3435
3536/** One dependent pipeline: commands joined by `|`. */
3637export type Pipeline = ParsedCommand [ ] ;
3738
3839export type DecomposeResult =
3940 | { ok : true ; clauses : Pipeline [ ] }
40- | { ok : false ; reason : DropReason ; clauses : Pipeline [ ] } ;
41+ | {
42+ ok : false ;
43+ reason : DropReason ;
44+ /** For `unsupported_construct`: the AST node kind that fell outside the safety fragment. */
45+ detail ?: string ;
46+ clauses : Pipeline [ ] ;
47+ } ;
4148
4249let cachedParser : Parser | null = null ;
4350
@@ -59,6 +66,24 @@ function getParser(): Parser {
5966const NETWORK_CMDS = new Set ( [ 'curl' , 'wget' , 'fetch' ] ) ;
6067const SHELL_INTERPRETERS = new Set ( [ 'bash' , 'sh' , 'zsh' , 'ksh' , 'dash' ] ) ;
6168
69+ /**
70+ * Thrown by {@link collectClauses} when it encounters an AST node kind that is
71+ * not a member of {@link SAFETY_FRAGMENT}. Caught by {@link decompose} and
72+ * surfaced as `unsupported_construct` with the node kind in `detail`.
73+ */
74+ class UnsupportedConstructError extends Error {
75+ readonly nodeKind : string ;
76+
77+ /**
78+ * @param nodeKind - The tree-sitter-bash node kind that fell outside the
79+ * safety fragment.
80+ */
81+ constructor ( nodeKind : string ) {
82+ super ( `unsupported AST node: ${ nodeKind } ` ) ;
83+ this . nodeKind = nodeKind ;
84+ }
85+ }
86+
6287/**
6388 * Parse a bash source string and decompose it into a list of commands.
6489 *
@@ -80,23 +105,37 @@ export function decompose(source: string): DecomposeResult {
80105 return { ok : false , reason : 'parse_error' , clauses : [ ] } ;
81106 }
82107
83- // Collect clauses from all top-level children of the program node
108+ // Security check runs before clause collection so it fires even when the
109+ // curl|sh pipeline is nested inside a construct outside the safety fragment.
110+ if ( hasCurlPipeShell ( tree . rootNode ) ) {
111+ return { ok : false , reason : 'curl_pipe_shell' , clauses : [ ] } ;
112+ }
113+
84114 const clauses : Pipeline [ ] = [ ] ;
85- for ( let i = 0 ; i < tree . rootNode . namedChildCount ; i ++ ) {
86- const child = tree . rootNode . namedChild ( i ) ;
87- if ( child !== null ) {
88- clauses . push ( ...collectClauses ( child ) ) ;
115+ try {
116+ for ( let i = 0 ; i < tree . rootNode . namedChildCount ; i ++ ) {
117+ const child = tree . rootNode . namedChild ( i ) ;
118+ if ( child !== null ) {
119+ clauses . push ( ...collectClauses ( child ) ) ;
120+ }
89121 }
122+ } catch ( error ) {
123+ if ( error instanceof UnsupportedConstructError ) {
124+ return {
125+ ok : false ,
126+ reason : 'unsupported_construct' ,
127+ detail : error . nodeKind ,
128+ clauses : [ ] ,
129+ } ;
130+ }
131+ throw error ;
90132 }
91133
92134 const allCommands = clauses . flat ( ) ;
93135
94136 if ( allCommands . some ( ( cmd ) => cmd . name === '<dynamic>' ) ) {
95137 return { ok : false , reason : 'dynamic_command' , clauses } ;
96138 }
97- if ( hasCurlPipeShell ( tree . rootNode ) ) {
98- return { ok : false , reason : 'curl_pipe_shell' , clauses } ;
99- }
100139 if ( hasEvalDynamic ( allCommands ) ) {
101140 return { ok : false , reason : 'eval_dynamic' , clauses } ;
102141 }
@@ -123,86 +162,104 @@ function hasErrorNode(node: Parser.SyntaxNode): boolean {
123162 return false ;
124163}
125164
126- /**
127- * Collect all `command` nodes found under the given syntax node.
128- *
129- * @param node - The root of the subtree to walk.
130- * @returns An array of ParsedCommand objects extracted from command nodes.
131- */
132- function collectCommands ( node : Parser . SyntaxNode ) : ParsedCommand [ ] {
133- const out : ParsedCommand [ ] = [ ] ;
134- walk ( node , ( nd ) => {
135- if ( nd . type === 'command' ) {
136- out . push ( extractCommand ( nd ) ) ;
137- }
138- } ) ;
139- return out ;
140- }
165+ type FragmentHandler = ( node : Parser . SyntaxNode ) => Pipeline [ ] ;
166+
167+ // SAFETY_FRAGMENT: the set of tree-sitter-bash AST node kinds caprock knows
168+ // how to decompose, each mapped to the handler that turns it into clauses.
169+ // Every node `collectClauses` encounters must be a member; anything else
170+ // throws `UnsupportedConstructError` so `decompose` can refuse with
171+ // `unsupported_construct` rather than fall through to a permissive walk.
172+ // Extending the fragment is a one-line entry plus tests — adding a kind here
173+ // is a deliberate decision, not an accident of "it happened to parse."
174+ const SAFETY_FRAGMENT : ReadonlyMap < string , FragmentHandler > = new Map <
175+ string ,
176+ FragmentHandler
177+ > ( [
178+ [ 'command' , ( node ) => [ [ extractCommand ( node ) ] ] ] ,
179+ [ 'pipeline' , collectPipelineClause ] ,
180+ [ 'list' , recurseIntoChildren ] ,
181+ [ 'redirected_statement' , delegateInsideRedirect ] ,
182+ [ 'subshell' , recurseIntoChildren ] ,
183+ [ 'compound_statement' , recurseIntoChildren ] ,
184+ ] ) ;
141185
142186/**
143187 * Collect pipeline clauses from a syntax node, splitting on `&&`, `||`, and `;`.
144188 *
145- * - `list` nodes (&&/||) are recursed into, producing one clause per operand.
146- * - `pipeline` nodes produce one clause containing all their command nodes.
147- * - `command` nodes produce one single-command clause.
148- * - `redirected_statement` nodes delegate to their inner command/pipeline child.
149- * - All other node types (subshell, compound_statement, etc.) are treated as
150- * one opaque clause by falling back to {@link collectCommands}.
189+ * Dispatches through {@link SAFETY_FRAGMENT}. Nodes outside the fragment throw
190+ * {@link UnsupportedConstructError}, which {@link decompose} catches.
151191 *
152192 * @param node - The syntax node to collect clauses from.
153193 * @returns An array of Pipeline clauses.
154194 */
155195function collectClauses ( node : Parser . SyntaxNode ) : Pipeline [ ] {
156- switch ( node . type ) {
157- case 'list' : {
158- // && and || — recurse into both named children
159- const result : Pipeline [ ] = [ ] ;
160- for ( let i = 0 ; i < node . namedChildCount ; i ++ ) {
161- const child = node . namedChild ( i ) ;
162- if ( child !== null ) {
163- result . push ( ...collectClauses ( child ) ) ;
164- }
165- }
166- return result ;
167- }
168- case 'pipeline' : {
169- // All commands in this pipeline form one clause.
170- // Each stage may be a bare `command` or a `redirected_statement` wrapping one.
171- const cmds : ParsedCommand [ ] = [ ] ;
172- for ( let i = 0 ; i < node . namedChildCount ; i ++ ) {
173- const child = node . namedChild ( i ) ;
174- if ( child === null ) {
175- continue ;
176- }
177- const cmd = extractPipelineStage ( child ) ;
178- if ( cmd !== null ) {
179- cmds . push ( cmd ) ;
180- }
181- }
182- return cmds . length > 0 ? [ cmds ] : [ ] ;
183- }
184- case 'command' :
185- return [ [ extractCommand ( node ) ] ] ;
186- case 'redirected_statement' : {
187- for ( let i = 0 ; i < node . namedChildCount ; i ++ ) {
188- const child = node . namedChild ( i ) ;
189- if (
190- child !== null &&
191- child . type !== 'file_redirect' &&
192- child . type !== 'heredoc_redirect' &&
193- child . type !== 'herestring_redirect'
194- ) {
195- return collectClauses ( child ) ;
196- }
197- }
198- return [ ] ;
196+ const handler = SAFETY_FRAGMENT . get ( node . type ) ;
197+ if ( handler === undefined ) {
198+ throw new UnsupportedConstructError ( node . type ) ;
199+ }
200+ return handler ( node ) ;
201+ }
202+
203+ /**
204+ * Recurse into every named child of `node`, concatenating their clauses.
205+ * Used for nodes that act as transparent grouping (`list`, `subshell`,
206+ * `compound_statement`).
207+ *
208+ * @param node - The grouping node to descend into.
209+ * @returns Concatenated clauses from the children.
210+ */
211+ function recurseIntoChildren ( node : Parser . SyntaxNode ) : Pipeline [ ] {
212+ const result : Pipeline [ ] = [ ] ;
213+ for ( let i = 0 ; i < node . namedChildCount ; i ++ ) {
214+ const child = node . namedChild ( i ) ;
215+ if ( child !== null ) {
216+ result . push ( ...collectClauses ( child ) ) ;
217+ }
218+ }
219+ return result ;
220+ }
221+
222+ /**
223+ * Collect every stage of a `pipeline` node into a single clause.
224+ *
225+ * @param node - The `pipeline` node to walk.
226+ * @returns Either one clause with all stages, or no clauses if empty.
227+ */
228+ function collectPipelineClause ( node : Parser . SyntaxNode ) : Pipeline [ ] {
229+ const cmds : ParsedCommand [ ] = [ ] ;
230+ for ( let i = 0 ; i < node . namedChildCount ; i ++ ) {
231+ const child = node . namedChild ( i ) ;
232+ if ( child === null ) {
233+ continue ;
199234 }
200- default : {
201- // subshell, compound_statement, etc. — collect all contained commands as one opaque clause
202- const cmds = collectCommands ( node ) ;
203- return cmds . length > 0 ? [ cmds ] : [ ] ;
235+ const cmd = extractPipelineStage ( child ) ;
236+ if ( cmd !== null ) {
237+ cmds . push ( cmd ) ;
238+ }
239+ }
240+ return cmds . length > 0 ? [ cmds ] : [ ] ;
241+ }
242+
243+ /**
244+ * Delegate a `redirected_statement` to its inner non-redirect child. The
245+ * redirects themselves are attached to the inner command by {@link extractCommand}.
246+ *
247+ * @param node - The `redirected_statement` node.
248+ * @returns Clauses produced by the inner statement.
249+ */
250+ function delegateInsideRedirect ( node : Parser . SyntaxNode ) : Pipeline [ ] {
251+ for ( let i = 0 ; i < node . namedChildCount ; i ++ ) {
252+ const child = node . namedChild ( i ) ;
253+ if (
254+ child !== null &&
255+ child . type !== 'file_redirect' &&
256+ child . type !== 'heredoc_redirect' &&
257+ child . type !== 'herestring_redirect'
258+ ) {
259+ return collectClauses ( child ) ;
204260 }
205261 }
262+ return [ ] ;
206263}
207264
208265/**
0 commit comments