tenseleyflow/shithub / 0bc8316

Browse files

actions/expr: lex + parse + eval with strict allowlist + taint tracking

Hand-written tiny expression evaluator for ${{ ... }} blocks. Closed allowlist: secrets/vars/env/shithub.* namespaces; contains/startsWith/endsWith/success/failure/always/cancelled functions. Operators ! && || == !=. Nothing else. (S41a)
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
0bc8316f1c119c26662097b708397b427376a296
Parents
7c43086
Tree
6ce1399

3 changed files

StatusFile+-
A internal/actions/expr/eval.go 349 0
A internal/actions/expr/lex.go 212 0
A internal/actions/expr/parse.go 217 0
internal/actions/expr/eval.goadded
@@ -0,0 +1,349 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package expr
4
+
5
+import (
6
+	"fmt"
7
+	"strings"
8
+)
9
+
10
+// Value is the result of evaluating an Expr. It's intentionally
11
+// stringly-typed (no Go interface{} polymorphism) because the types
12
+// we care about — strings + booleans — are both representable as a
13
+// Go string with a Kind tag, and stringly types make the taint flag
14
+// unambiguous to track.
15
+//
16
+// Tainted=true means the value transitively depends on an
17
+// untrusted-source reference (anything in the shithub.event.*
18
+// namespace). The runner's exec layer (S41d) refuses to interpolate
19
+// Tainted values into shell strings.
20
+type Value struct {
21
+	Kind    Kind
22
+	S       string
23
+	B       bool
24
+	Tainted bool
25
+}
26
+
27
+// Kind classifies a Value.
28
+type Kind int
29
+
30
+const (
31
+	KindString Kind = iota
32
+	KindBool
33
+	KindNull
34
+)
35
+
36
+// String renders the Value in the canonical form GHA's evaluator uses:
37
+// strings raw, booleans "true"/"false", null as "". Used by both the
38
+// admin parse subcommand (S41a) and the runner's command rendering
39
+// (S41d).
40
+func (v Value) String() string {
41
+	switch v.Kind {
42
+	case KindBool:
43
+		if v.B {
44
+			return "true"
45
+		}
46
+		return "false"
47
+	case KindNull:
48
+		return ""
49
+	}
50
+	return v.S
51
+}
52
+
53
+// Truthy implements GHA's truthiness rules used by `if:` expressions
54
+// and the boolean operators: false, "", null are falsy; anything else
55
+// is truthy. Numeric truthiness (0 falsy) is irrelevant in v1 since
56
+// we don't evaluate arithmetic.
57
+func (v Value) Truthy() bool {
58
+	switch v.Kind {
59
+	case KindBool:
60
+		return v.B
61
+	case KindString:
62
+		return v.S != ""
63
+	}
64
+	return false
65
+}
66
+
67
+// Context is the read-only state Eval consults for Ref lookups. The
68
+// caller (S41b trigger pipeline, S41d runner) populates it from the
69
+// triggering domain_event, the workflow's resolved env / secrets /
70
+// vars, and the standard `shithub.*` slots.
71
+//
72
+// Untrusted is the set of namespace prefixes whose values come from
73
+// user-controlled sources (the event payload). Refs landing inside
74
+// any of these get Tainted=true. The default is just "shithub.event"
75
+// — we may extend in v2 if other namespaces grow user-controlled
76
+// fields.
77
+type Context struct {
78
+	Secrets   map[string]string
79
+	Vars      map[string]string
80
+	Env       map[string]string
81
+	Shithub   ShithubContext
82
+	Untrusted map[string]struct{} // namespace prefixes
83
+	JobStatus JobStatus           // for success()/failure()/always()/cancelled()
84
+}
85
+
86
+// ShithubContext is the typed `shithub.*` slot. Event is a free-form
87
+// map (the trigger payload, JSON-decoded); the named scalars are
88
+// pre-resolved.
89
+type ShithubContext struct {
90
+	Event map[string]any
91
+	RunID string
92
+	SHA   string
93
+	Ref   string
94
+	Actor string
95
+}
96
+
97
+// JobStatus is the rolling status the success()/failure()/always()/
98
+// cancelled() functions consult. Filled by the runner before eval.
99
+type JobStatus struct {
100
+	Failed    bool
101
+	Cancelled bool
102
+}
103
+
104
+// DefaultUntrusted returns the standard taint-source allowlist:
105
+// shithub.event.* is user-controlled, everything else is trusted.
106
+func DefaultUntrusted() map[string]struct{} {
107
+	return map[string]struct{}{
108
+		"shithub.event": {},
109
+	}
110
+}
111
+
112
+// Eval reduces e against ctx. Errors are precise references back to
113
+// the AST node ("unknown function 'fromJSON'", "secret 'X' not bound",
114
+// etc.). Eval never panics on well-formed input.
115
+func Eval(e Expr, ctx *Context) (Value, error) {
116
+	switch n := e.(type) {
117
+	case LitString:
118
+		return Value{Kind: KindString, S: n.V}, nil
119
+	case LitBool:
120
+		return Value{Kind: KindBool, B: n.V}, nil
121
+	case LitNull:
122
+		return Value{Kind: KindNull}, nil
123
+	case Ref:
124
+		return evalRef(n, ctx)
125
+	case Call:
126
+		return evalCall(n, ctx)
127
+	case Unary:
128
+		x, err := Eval(n.X, ctx)
129
+		if err != nil {
130
+			return Value{}, err
131
+		}
132
+		return Value{Kind: KindBool, B: !x.Truthy(), Tainted: x.Tainted}, nil
133
+	case Binary:
134
+		return evalBinary(n, ctx)
135
+	}
136
+	return Value{}, fmt.Errorf("expr: unknown AST node type")
137
+}
138
+
139
+// evalRef walks a dotted path. The first segment selects the
140
+// namespace; subsequent segments index into it. Anything outside the
141
+// allowlist is an error so we don't silently let workflows reach for
142
+// e.g. `runner.os` (which we don't define).
143
+func evalRef(r Ref, ctx *Context) (Value, error) {
144
+	if len(r.Path) == 0 {
145
+		return Value{}, fmt.Errorf("expr: empty reference")
146
+	}
147
+	root := r.Path[0]
148
+	tainted := isUntrusted(r.Path, ctx.Untrusted)
149
+	switch root {
150
+	case "secrets":
151
+		if len(r.Path) != 2 {
152
+			return Value{}, fmt.Errorf("expr: secrets.<name> requires exactly one member")
153
+		}
154
+		v, ok := ctx.Secrets[r.Path[1]]
155
+		if !ok {
156
+			return Value{}, fmt.Errorf("expr: secret %q not bound", r.Path[1])
157
+		}
158
+		// Secrets are NEVER tainted — they're operator-controlled.
159
+		// The runner's log scrubber (S41e) replaces their values in
160
+		// log output, but the shell-injection guard cares about
161
+		// untrusted-source taint, not secret-vs-not.
162
+		return Value{Kind: KindString, S: v}, nil
163
+	case "vars":
164
+		if len(r.Path) != 2 {
165
+			return Value{}, fmt.Errorf("expr: vars.<name> requires exactly one member")
166
+		}
167
+		v, ok := ctx.Vars[r.Path[1]]
168
+		if !ok {
169
+			// Missing var resolves to empty string (matches GHA).
170
+			return Value{Kind: KindString, S: ""}, nil
171
+		}
172
+		return Value{Kind: KindString, S: v}, nil
173
+	case "env":
174
+		if len(r.Path) != 2 {
175
+			return Value{}, fmt.Errorf("expr: env.<name> requires exactly one member")
176
+		}
177
+		v, ok := ctx.Env[r.Path[1]]
178
+		if !ok {
179
+			return Value{Kind: KindString, S: ""}, nil
180
+		}
181
+		return Value{Kind: KindString, S: v}, nil
182
+	case "shithub":
183
+		return evalShithub(r.Path[1:], ctx, tainted)
184
+	}
185
+	return Value{}, fmt.Errorf("expr: unknown namespace %q (allowed: secrets, vars, env, shithub)", root)
186
+}
187
+
188
+func evalShithub(rest []string, ctx *Context, tainted bool) (Value, error) {
189
+	if len(rest) == 0 {
190
+		return Value{}, fmt.Errorf("expr: shithub.<...> requires a member")
191
+	}
192
+	switch rest[0] {
193
+	case "run_id":
194
+		return Value{Kind: KindString, S: ctx.Shithub.RunID}, nil
195
+	case "sha":
196
+		return Value{Kind: KindString, S: ctx.Shithub.SHA}, nil
197
+	case "ref":
198
+		return Value{Kind: KindString, S: ctx.Shithub.Ref}, nil
199
+	case "actor":
200
+		return Value{Kind: KindString, S: ctx.Shithub.Actor}, nil
201
+	case "event":
202
+		return evalEventPath(rest[1:], ctx.Shithub.Event, tainted)
203
+	}
204
+	return Value{}, fmt.Errorf("expr: unknown shithub field %q (allowed: run_id, sha, ref, actor, event)", rest[0])
205
+}
206
+
207
+// evalEventPath walks into a JSON-decoded map. Missing keys resolve
208
+// to null (GHA convention). Every value here is tainted because the
209
+// payload is user-controlled.
210
+func evalEventPath(path []string, event map[string]any, tainted bool) (Value, error) {
211
+	var cur any = event
212
+	for _, key := range path {
213
+		m, ok := cur.(map[string]any)
214
+		if !ok {
215
+			return Value{Kind: KindNull, Tainted: tainted}, nil
216
+		}
217
+		cur = m[key]
218
+	}
219
+	if cur == nil {
220
+		return Value{Kind: KindNull, Tainted: tainted}, nil
221
+	}
222
+	switch v := cur.(type) {
223
+	case string:
224
+		return Value{Kind: KindString, S: v, Tainted: tainted}, nil
225
+	case bool:
226
+		return Value{Kind: KindBool, B: v, Tainted: tainted}, nil
227
+	case float64:
228
+		return Value{Kind: KindString, S: fmt.Sprintf("%v", v), Tainted: tainted}, nil
229
+	}
230
+	// Maps + slices stringify in JSON-like form for if-comparisons.
231
+	return Value{Kind: KindString, S: fmt.Sprintf("%v", cur), Tainted: tainted}, nil
232
+}
233
+
234
+// isUntrusted checks if r.Path traces into any prefix in the untrusted
235
+// set. Prefixes are dot-joined like "shithub.event"; a path matches
236
+// when it equals or extends the prefix.
237
+func isUntrusted(path []string, untrusted map[string]struct{}) bool {
238
+	if len(untrusted) == 0 {
239
+		return false
240
+	}
241
+	for i := range path {
242
+		joined := strings.Join(path[:i+1], ".")
243
+		if _, ok := untrusted[joined]; ok {
244
+			return true
245
+		}
246
+	}
247
+	return false
248
+}
249
+
250
+// evalCall dispatches the seven allowlisted functions. Any other call
251
+// is an error — this is the closed door the campaign §"Risks" warns
252
+// about ("expression evaluator is a footgun if permissive"). Adding
253
+// a function requires a security note in the commit message.
254
+func evalCall(c Call, ctx *Context) (Value, error) {
255
+	switch c.Name {
256
+	case "contains":
257
+		return strFnArity2(c, ctx, "contains", strings.Contains)
258
+	case "startsWith":
259
+		return strFnArity2(c, ctx, "startsWith", strings.HasPrefix)
260
+	case "endsWith":
261
+		return strFnArity2(c, ctx, "endsWith", strings.HasSuffix)
262
+	case "success":
263
+		if len(c.Args) != 0 {
264
+			return Value{}, fmt.Errorf("expr: success() takes no arguments")
265
+		}
266
+		return Value{Kind: KindBool, B: !ctx.JobStatus.Failed && !ctx.JobStatus.Cancelled}, nil
267
+	case "failure":
268
+		if len(c.Args) != 0 {
269
+			return Value{}, fmt.Errorf("expr: failure() takes no arguments")
270
+		}
271
+		return Value{Kind: KindBool, B: ctx.JobStatus.Failed && !ctx.JobStatus.Cancelled}, nil
272
+	case "always":
273
+		if len(c.Args) != 0 {
274
+			return Value{}, fmt.Errorf("expr: always() takes no arguments")
275
+		}
276
+		return Value{Kind: KindBool, B: true}, nil
277
+	case "cancelled":
278
+		if len(c.Args) != 0 {
279
+			return Value{}, fmt.Errorf("expr: cancelled() takes no arguments")
280
+		}
281
+		return Value{Kind: KindBool, B: ctx.JobStatus.Cancelled}, nil
282
+	}
283
+	return Value{}, fmt.Errorf("expr: unknown function %q (allowed: contains, startsWith, endsWith, success, failure, always, cancelled)", c.Name)
284
+}
285
+
286
+func strFnArity2(c Call, ctx *Context, name string, fn func(string, string) bool) (Value, error) {
287
+	if len(c.Args) != 2 {
288
+		return Value{}, fmt.Errorf("expr: %s() takes 2 arguments, got %d", name, len(c.Args))
289
+	}
290
+	a, err := Eval(c.Args[0], ctx)
291
+	if err != nil {
292
+		return Value{}, err
293
+	}
294
+	b, err := Eval(c.Args[1], ctx)
295
+	if err != nil {
296
+		return Value{}, err
297
+	}
298
+	tainted := a.Tainted || b.Tainted
299
+	return Value{Kind: KindBool, B: fn(a.String(), b.String()), Tainted: tainted}, nil
300
+}
301
+
302
+func evalBinary(n Binary, ctx *Context) (Value, error) {
303
+	l, err := Eval(n.L, ctx)
304
+	if err != nil {
305
+		return Value{}, err
306
+	}
307
+	switch n.Op {
308
+	case "&&":
309
+		if !l.Truthy() {
310
+			return l, nil // short-circuit — preserves taint
311
+		}
312
+		r, err := Eval(n.R, ctx)
313
+		if err != nil {
314
+			return Value{}, err
315
+		}
316
+		return Value{Kind: r.Kind, S: r.S, B: r.B, Tainted: l.Tainted || r.Tainted}, nil
317
+	case "||":
318
+		if l.Truthy() {
319
+			return l, nil
320
+		}
321
+		r, err := Eval(n.R, ctx)
322
+		if err != nil {
323
+			return Value{}, err
324
+		}
325
+		return Value{Kind: r.Kind, S: r.S, B: r.B, Tainted: l.Tainted || r.Tainted}, nil
326
+	}
327
+	r, err := Eval(n.R, ctx)
328
+	if err != nil {
329
+		return Value{}, err
330
+	}
331
+	tainted := l.Tainted || r.Tainted
332
+	switch n.Op {
333
+	case "==":
334
+		return Value{Kind: KindBool, B: valuesEqual(l, r), Tainted: tainted}, nil
335
+	case "!=":
336
+		return Value{Kind: KindBool, B: !valuesEqual(l, r), Tainted: tainted}, nil
337
+	}
338
+	return Value{}, fmt.Errorf("expr: unknown binary operator %q", n.Op)
339
+}
340
+
341
+func valuesEqual(a, b Value) bool {
342
+	if a.Kind == KindNull || b.Kind == KindNull {
343
+		return a.Kind == b.Kind
344
+	}
345
+	if a.Kind == KindBool && b.Kind == KindBool {
346
+		return a.B == b.B
347
+	}
348
+	return a.String() == b.String()
349
+}
internal/actions/expr/lex.goadded
@@ -0,0 +1,212 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package expr is the strict-allowlist expression evaluator for
4
+// `${{ … }}` blocks in workflow files.
5
+//
6
+// The evaluator is intentionally tiny:
7
+//   - Allowed namespaces: secrets, env, vars, shithub.event, shithub.run_id,
8
+//     shithub.sha, shithub.ref, shithub.actor.
9
+//   - Allowed functions: contains, startsWith, endsWith,
10
+//     success(), failure(), always(), cancelled().
11
+//   - Operators: && || ! == != binary string concat (none — we don't
12
+//     support arithmetic or anything else in v1).
13
+//
14
+// Anything outside that set is an evaluation error. This is the load-
15
+// bearing security surface — the more we accept, the more attack
16
+// surface we open. Future expansion goes through a reviewer-required
17
+// note in the commit message (per the campaign §"Risks": "block any
18
+// S41 PR that adds an evaluator function without a security note").
19
+//
20
+// Every produced Value carries a Tainted bool. References that
21
+// resolve into the shithub.event.* namespace are tagged Tainted=true;
22
+// taint propagates through string concatenation, comparisons (the
23
+// boolean output isn't tainted, but the comparison operands' values
24
+// are checked), and function returns.
25
+package expr
26
+
27
+import (
28
+	"fmt"
29
+	"strings"
30
+	"unicode"
31
+)
32
+
33
+// TokenKind classifies a lexed token.
34
+type TokenKind int
35
+
36
+const (
37
+	TokInvalid TokenKind = iota
38
+	TokIdent             // foo, secrets, shithub
39
+	TokDot               // .
40
+	TokLParen            // (
41
+	TokRParen            // )
42
+	TokComma             // ,
43
+	TokString            // 'literal' (single-quoted only — GHA convention)
44
+	TokBool              // true | false
45
+	TokNull              // null
46
+	TokAnd               // &&
47
+	TokOr                // ||
48
+	TokNot               // !
49
+	TokEq                // ==
50
+	TokNe                // !=
51
+	TokEOF
52
+)
53
+
54
+// Token is a single lexed unit. Pos is the byte offset in the original
55
+// source (useful for diagnostic spans).
56
+type Token struct {
57
+	Kind  TokenKind
58
+	Value string
59
+	Pos   int
60
+}
61
+
62
+func (k TokenKind) String() string {
63
+	switch k {
64
+	case TokIdent:
65
+		return "identifier"
66
+	case TokDot:
67
+		return "."
68
+	case TokLParen:
69
+		return "("
70
+	case TokRParen:
71
+		return ")"
72
+	case TokComma:
73
+		return ","
74
+	case TokString:
75
+		return "string literal"
76
+	case TokBool:
77
+		return "boolean"
78
+	case TokNull:
79
+		return "null"
80
+	case TokAnd:
81
+		return "&&"
82
+	case TokOr:
83
+		return "||"
84
+	case TokNot:
85
+		return "!"
86
+	case TokEq:
87
+		return "=="
88
+	case TokNe:
89
+		return "!="
90
+	case TokEOF:
91
+		return "end of input"
92
+	}
93
+	return "invalid"
94
+}
95
+
96
+// Lex returns the token stream for src or an error on the first lexical
97
+// problem. Whitespace is skipped silently. The lexer doesn't strip the
98
+// surrounding `${{ … }}` — the caller does that before calling Lex.
99
+func Lex(src string) ([]Token, error) {
100
+	var out []Token
101
+	i := 0
102
+	for i < len(src) {
103
+		c := src[i]
104
+		switch {
105
+		case c == ' ' || c == '\t' || c == '\n' || c == '\r':
106
+			i++
107
+		case c == '.':
108
+			out = append(out, Token{Kind: TokDot, Value: ".", Pos: i})
109
+			i++
110
+		case c == '(':
111
+			out = append(out, Token{Kind: TokLParen, Value: "(", Pos: i})
112
+			i++
113
+		case c == ')':
114
+			out = append(out, Token{Kind: TokRParen, Value: ")", Pos: i})
115
+			i++
116
+		case c == ',':
117
+			out = append(out, Token{Kind: TokComma, Value: ",", Pos: i})
118
+			i++
119
+		case c == '\'':
120
+			tok, n, err := lexString(src[i:], i)
121
+			if err != nil {
122
+				return nil, err
123
+			}
124
+			out = append(out, tok)
125
+			i += n
126
+		case c == '&':
127
+			if i+1 < len(src) && src[i+1] == '&' {
128
+				out = append(out, Token{Kind: TokAnd, Value: "&&", Pos: i})
129
+				i += 2
130
+			} else {
131
+				return nil, fmt.Errorf("expr: stray '&' at offset %d (expected '&&')", i)
132
+			}
133
+		case c == '|':
134
+			if i+1 < len(src) && src[i+1] == '|' {
135
+				out = append(out, Token{Kind: TokOr, Value: "||", Pos: i})
136
+				i += 2
137
+			} else {
138
+				return nil, fmt.Errorf("expr: stray '|' at offset %d (expected '||')", i)
139
+			}
140
+		case c == '!':
141
+			if i+1 < len(src) && src[i+1] == '=' {
142
+				out = append(out, Token{Kind: TokNe, Value: "!=", Pos: i})
143
+				i += 2
144
+			} else {
145
+				out = append(out, Token{Kind: TokNot, Value: "!", Pos: i})
146
+				i++
147
+			}
148
+		case c == '=':
149
+			if i+1 < len(src) && src[i+1] == '=' {
150
+				out = append(out, Token{Kind: TokEq, Value: "==", Pos: i})
151
+				i += 2
152
+			} else {
153
+				return nil, fmt.Errorf("expr: stray '=' at offset %d (expected '==')", i)
154
+			}
155
+		case isIdentStart(c):
156
+			tok, n := lexIdent(src[i:], i)
157
+			out = append(out, tok)
158
+			i += n
159
+		default:
160
+			return nil, fmt.Errorf("expr: unexpected character %q at offset %d", c, i)
161
+		}
162
+	}
163
+	out = append(out, Token{Kind: TokEOF, Pos: i})
164
+	return out, nil
165
+}
166
+
167
+func lexString(src string, basePos int) (Token, int, error) {
168
+	if len(src) < 2 {
169
+		return Token{}, 0, fmt.Errorf("expr: unterminated string at offset %d", basePos)
170
+	}
171
+	// Walk until matching '. GHA expressions do NOT support backslash
172
+	// escapes; the only escape is doubling the quote: '' produces '.
173
+	var b strings.Builder
174
+	i := 1 // skip opening '
175
+	for i < len(src) {
176
+		c := src[i]
177
+		if c == '\'' {
178
+			if i+1 < len(src) && src[i+1] == '\'' {
179
+				b.WriteByte('\'')
180
+				i += 2
181
+				continue
182
+			}
183
+			return Token{Kind: TokString, Value: b.String(), Pos: basePos}, i + 1, nil
184
+		}
185
+		b.WriteByte(c)
186
+		i++
187
+	}
188
+	return Token{}, 0, fmt.Errorf("expr: unterminated string at offset %d", basePos)
189
+}
190
+
191
+func lexIdent(src string, basePos int) (Token, int) {
192
+	i := 0
193
+	for i < len(src) && isIdentChar(src[i]) {
194
+		i++
195
+	}
196
+	v := src[:i]
197
+	switch v {
198
+	case "true", "false":
199
+		return Token{Kind: TokBool, Value: v, Pos: basePos}, i
200
+	case "null":
201
+		return Token{Kind: TokNull, Value: v, Pos: basePos}, i
202
+	}
203
+	return Token{Kind: TokIdent, Value: v, Pos: basePos}, i
204
+}
205
+
206
+func isIdentStart(c byte) bool {
207
+	return unicode.IsLetter(rune(c)) || c == '_'
208
+}
209
+
210
+func isIdentChar(c byte) bool {
211
+	return unicode.IsLetter(rune(c)) || unicode.IsDigit(rune(c)) || c == '_'
212
+}
internal/actions/expr/parse.goadded
@@ -0,0 +1,217 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package expr
4
+
5
+import "fmt"
6
+
7
+// Expr is the AST node interface. Each concrete type captures one
8
+// kind of expression we support.
9
+type Expr interface {
10
+	exprNode()
11
+}
12
+
13
+// LitString is a quoted string literal in the expression source.
14
+type LitString struct{ V string }
15
+
16
+// LitBool is `true` or `false`.
17
+type LitBool struct{ V bool }
18
+
19
+// LitNull is the `null` keyword. Used in equality checks against
20
+// possibly-missing context fields.
21
+type LitNull struct{}
22
+
23
+// Ref is a dotted reference like `secrets.MY_SECRET` or
24
+// `shithub.event.pull_request.title`. Path is a non-empty slice of
25
+// identifiers (the lexer enforces leading-letter idents).
26
+type Ref struct{ Path []string }
27
+
28
+// Call is a function invocation: `contains('foo', 'bar')`. Name is
29
+// validated against the allowlist at eval time, not parse time, so
30
+// we can produce a precise error pointing at the call site.
31
+type Call struct {
32
+	Name string
33
+	Args []Expr
34
+}
35
+
36
+// Unary is `!x`.
37
+type Unary struct {
38
+	Op string // "!"
39
+	X  Expr
40
+}
41
+
42
+// Binary is `x op y`. Op ∈ {==, !=, &&, ||}. Concat would go here too
43
+// if we supported it (we don't in v1 — we tell users to use shell-side
44
+// concat in `run:`).
45
+type Binary struct {
46
+	Op string
47
+	L  Expr
48
+	R  Expr
49
+}
50
+
51
+func (LitString) exprNode() {}
52
+func (LitBool) exprNode()   {}
53
+func (LitNull) exprNode()   {}
54
+func (Ref) exprNode()       {}
55
+func (Call) exprNode()      {}
56
+func (Unary) exprNode()     {}
57
+func (Binary) exprNode()    {}
58
+
59
+// Parse turns a token stream into an AST. The grammar is small enough
60
+// that we hand-write a recursive-descent parser with explicit
61
+// precedence: || → && → equality → unary → primary.
62
+func Parse(tokens []Token) (Expr, error) {
63
+	p := &parser{tokens: tokens}
64
+	e, err := p.parseOr()
65
+	if err != nil {
66
+		return nil, err
67
+	}
68
+	if p.peek().Kind != TokEOF {
69
+		t := p.peek()
70
+		return nil, fmt.Errorf("expr: unexpected %s after expression at offset %d", t.Kind, t.Pos)
71
+	}
72
+	return e, nil
73
+}
74
+
75
+type parser struct {
76
+	tokens []Token
77
+	pos    int
78
+}
79
+
80
+func (p *parser) peek() Token { return p.tokens[p.pos] }
81
+func (p *parser) advance() Token {
82
+	t := p.tokens[p.pos]
83
+	p.pos++
84
+	return t
85
+}
86
+
87
+func (p *parser) parseOr() (Expr, error) {
88
+	left, err := p.parseAnd()
89
+	if err != nil {
90
+		return nil, err
91
+	}
92
+	for p.peek().Kind == TokOr {
93
+		op := p.advance().Value
94
+		right, err := p.parseAnd()
95
+		if err != nil {
96
+			return nil, err
97
+		}
98
+		left = Binary{Op: op, L: left, R: right}
99
+	}
100
+	return left, nil
101
+}
102
+
103
+func (p *parser) parseAnd() (Expr, error) {
104
+	left, err := p.parseEquality()
105
+	if err != nil {
106
+		return nil, err
107
+	}
108
+	for p.peek().Kind == TokAnd {
109
+		op := p.advance().Value
110
+		right, err := p.parseEquality()
111
+		if err != nil {
112
+			return nil, err
113
+		}
114
+		left = Binary{Op: op, L: left, R: right}
115
+	}
116
+	return left, nil
117
+}
118
+
119
+func (p *parser) parseEquality() (Expr, error) {
120
+	left, err := p.parseUnary()
121
+	if err != nil {
122
+		return nil, err
123
+	}
124
+	for p.peek().Kind == TokEq || p.peek().Kind == TokNe {
125
+		op := p.advance().Value
126
+		right, err := p.parseUnary()
127
+		if err != nil {
128
+			return nil, err
129
+		}
130
+		left = Binary{Op: op, L: left, R: right}
131
+	}
132
+	return left, nil
133
+}
134
+
135
+func (p *parser) parseUnary() (Expr, error) {
136
+	if p.peek().Kind == TokNot {
137
+		p.advance()
138
+		x, err := p.parseUnary()
139
+		if err != nil {
140
+			return nil, err
141
+		}
142
+		return Unary{Op: "!", X: x}, nil
143
+	}
144
+	return p.parsePrimary()
145
+}
146
+
147
+func (p *parser) parsePrimary() (Expr, error) {
148
+	t := p.peek()
149
+	switch t.Kind {
150
+	case TokString:
151
+		p.advance()
152
+		return LitString{V: t.Value}, nil
153
+	case TokBool:
154
+		p.advance()
155
+		return LitBool{V: t.Value == "true"}, nil
156
+	case TokNull:
157
+		p.advance()
158
+		return LitNull{}, nil
159
+	case TokLParen:
160
+		p.advance()
161
+		e, err := p.parseOr()
162
+		if err != nil {
163
+			return nil, err
164
+		}
165
+		if p.peek().Kind != TokRParen {
166
+			return nil, fmt.Errorf("expr: expected ')' at offset %d", p.peek().Pos)
167
+		}
168
+		p.advance()
169
+		return e, nil
170
+	case TokIdent:
171
+		return p.parseRefOrCall()
172
+	}
173
+	return nil, fmt.Errorf("expr: unexpected %s at offset %d", t.Kind, t.Pos)
174
+}
175
+
176
+func (p *parser) parseRefOrCall() (Expr, error) {
177
+	first := p.advance()
178
+	// Function call: ident immediately followed by '('
179
+	if p.peek().Kind == TokLParen {
180
+		p.advance()
181
+		var args []Expr
182
+		if p.peek().Kind != TokRParen {
183
+			for {
184
+				a, err := p.parseOr()
185
+				if err != nil {
186
+					return nil, err
187
+				}
188
+				args = append(args, a)
189
+				if p.peek().Kind == TokComma {
190
+					p.advance()
191
+					continue
192
+				}
193
+				break
194
+			}
195
+		}
196
+		if p.peek().Kind != TokRParen {
197
+			return nil, fmt.Errorf("expr: expected ')' to close call at offset %d", p.peek().Pos)
198
+		}
199
+		p.advance()
200
+		return Call{Name: first.Value, Args: args}, nil
201
+	}
202
+	// Otherwise it's a dotted reference. Walk subsequent `.ident`
203
+	// segments. The base segment IS allowed to stand alone (e.g.
204
+	// `secrets`) but we'll catch that at eval as "namespace requires
205
+	// a member".
206
+	path := []string{first.Value}
207
+	for p.peek().Kind == TokDot {
208
+		p.advance()
209
+		next := p.peek()
210
+		if next.Kind != TokIdent {
211
+			return nil, fmt.Errorf("expr: expected identifier after '.' at offset %d", next.Pos)
212
+		}
213
+		p.advance()
214
+		path = append(path, next.Value)
215
+	}
216
+	return Ref{Path: path}, nil
217
+}