Skip to content

Commit 96df9a8

Browse files
authored
Harden gh aw env update with strict input validation and non-mutating preview (#35358)
1 parent a3abc75 commit 96df9a8

6 files changed

Lines changed: 136 additions & 20 deletions

File tree

.github/workflows/daily-spdd-spec-planner.lock.yml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/src/content/docs/reference/compiler-enterprise-environment-controls.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ For max effective tokens, precedence is:
3939
2. `GH_AW_DEFAULT_MAX_EFFECTIVE_TOKENS`
4040
3. Built-in compiler default
4141

42+
A negative `GH_AW_DEFAULT_MAX_EFFECTIVE_TOKENS` disables AWF token steering and
43+
omits the budget limit when frontmatter does not set `max-effective-tokens`.
44+
4245
For default timeout-minutes, precedence is:
4346

4447
1. `timeout-minutes` in workflow frontmatter

docs/src/content/docs/reference/cost-management.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,12 @@ gh aw env update defaults.yml --scope org --org MY_ORG
231231
```
232232

233233
`gh aw env update` shows a confirmation preview before applying changes.
234-
Pass `--yes` to skip the prompt in automation. Set a field to `null` to delete
235-
the corresponding variable from the target scope.
234+
Pass `--yes` to skip the prompt in automation, or `--dry-run` to preview
235+
without changing any variables. Set a field to `null` to delete the
236+
corresponding variable from the target scope. Unknown YAML keys are rejected,
237+
`default_max_turns` / `default_timeout_minutes` must be positive integers, and
238+
`default_max_effective_tokens` must be a non-zero integer (negative values
239+
disable token steering and budget enforcement).
236240

237241
3. If you compile workflows in CI, pass compiler-read defaults into
238242
the compiler process environment (for example via `${{ vars.* }}`):

pkg/cli/env_command.go

Lines changed: 85 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/url"
77
"os"
88
"os/exec"
9+
"strconv"
910
"strings"
1011

1112
"github.com/github/gh-aw/pkg/console"
@@ -146,7 +147,7 @@ func newDefaultsGetCommand() *cobra.Command {
146147

147148
func newDefaultsUpdateCommand() *cobra.Command {
148149
var scope, repo, org, enterprise string
149-
var yes bool
150+
var yes, dryRun bool
150151

151152
cmd := &cobra.Command{
152153
Use: "update [file]",
@@ -161,7 +162,7 @@ func newDefaultsUpdateCommand() *cobra.Command {
161162
if err != nil {
162163
return err
163164
}
164-
return defaultsUpdateFromFile(target, inputFile, yes)
165+
return defaultsUpdateFromFile(target, inputFile, yes, dryRun)
165166
},
166167
}
167168

@@ -170,6 +171,7 @@ func newDefaultsUpdateCommand() *cobra.Command {
170171
cmd.Flags().StringVar(&org, "org", "", "Target organization (required for --scope org unless inferable from --repo/current repo)")
171172
cmd.Flags().StringVar(&enterprise, "enterprise", "", "Target enterprise slug (required for --scope ent)")
172173
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt")
174+
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview updates without applying any changes")
173175
_ = cmd.MarkFlagRequired("scope")
174176
return cmd
175177
}
@@ -200,18 +202,26 @@ func defaultsGetToFile(target defaultsTarget, outputFile string) error {
200202
return nil
201203
}
202204

203-
func defaultsUpdateFromFile(target defaultsTarget, inputFile string, skipConfirmation bool) error {
205+
func defaultsUpdateFromFile(target defaultsTarget, inputFile string, skipConfirmation, dryRun bool) error {
204206
data, err := os.ReadFile(inputFile)
205207
if err != nil {
206208
return fmt.Errorf("failed to read defaults file %q: %w", inputFile, err)
207209
}
208210

209-
var file defaultsFile
210-
if err := yaml.Unmarshal(data, &file); err != nil {
211-
return fmt.Errorf("failed to parse defaults file %q: %w", inputFile, err)
211+
file, err := defaultsParseFile(inputFile, data)
212+
if err != nil {
213+
return err
214+
}
215+
if err := defaultsValidateFile(&file); err != nil {
216+
return err
212217
}
213218

214219
changes := defaultsBuildUpdateChanges(&file)
220+
if dryRun {
221+
renderDefaultsUpdatePreview(target, inputFile, changes)
222+
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Dry-run mode enabled; no variables were changed."))
223+
return nil
224+
}
215225
if err := confirmDefaultsUpdate(target, inputFile, changes, skipConfirmation, console.ConfirmAction); err != nil {
216226
return err
217227
}
@@ -232,6 +242,60 @@ func defaultsUpdateFromFile(target defaultsTarget, inputFile string, skipConfirm
232242
return nil
233243
}
234244

245+
func defaultsParseFile(inputFile string, data []byte) (defaultsFile, error) {
246+
var file defaultsFile
247+
if err := yaml.UnmarshalWithOptions(data, &file, yaml.DisallowUnknownField()); err != nil {
248+
return defaultsFile{}, fmt.Errorf("failed to parse defaults file %q: %w", inputFile, err)
249+
}
250+
return file, nil
251+
}
252+
253+
func defaultsValidateFile(file *defaultsFile) error {
254+
var validationErrors []string
255+
256+
validateNonZeroInt := func(field string, value *string) {
257+
if value == nil {
258+
return
259+
}
260+
trimmed := strings.TrimSpace(*value)
261+
parsed, err := strconv.ParseInt(trimmed, 10, 64)
262+
if err != nil || parsed == 0 {
263+
validationErrors = append(validationErrors, fmt.Sprintf("%s must be a non-zero integer when set", field))
264+
}
265+
}
266+
validatePositiveInt := func(field string, value *string) {
267+
if value == nil {
268+
return
269+
}
270+
trimmed := strings.TrimSpace(*value)
271+
parsed, err := strconv.ParseInt(trimmed, 10, 64)
272+
if err != nil || parsed <= 0 {
273+
validationErrors = append(validationErrors, fmt.Sprintf("%s must be a positive integer when set", field))
274+
}
275+
}
276+
validateNonEmpty := func(field string, value *string) {
277+
if value == nil {
278+
return
279+
}
280+
if strings.TrimSpace(*value) == "" {
281+
validationErrors = append(validationErrors, fmt.Sprintf("%s cannot be empty when set", field))
282+
}
283+
}
284+
285+
validateNonZeroInt("default_max_effective_tokens", file.DefaultMaxEffectiveTokens)
286+
validatePositiveInt("default_max_turns", file.DefaultMaxTurns)
287+
validatePositiveInt("default_timeout_minutes", file.DefaultTimeoutMinutes)
288+
validateNonEmpty("default_detection_model", file.DefaultDetectionModel)
289+
validateNonEmpty("default_model_copilot", file.DefaultModelCopilot)
290+
validateNonEmpty("default_model_claude", file.DefaultModelClaude)
291+
validateNonEmpty("default_model_codex", file.DefaultModelCodex)
292+
293+
if len(validationErrors) > 0 {
294+
return fmt.Errorf("invalid defaults file: %s", strings.Join(validationErrors, "; "))
295+
}
296+
return nil
297+
}
298+
235299
func defaultsBuildUpdateChanges(file *defaultsFile) []defaultsUpdateChange {
236300
changes := make([]defaultsUpdateChange, 0, len(defaultsBindings))
237301
for _, binding := range defaultsBindings {
@@ -261,17 +325,7 @@ func confirmDefaultsUpdate(
261325
skipConfirmation bool,
262326
confirmAction func(title, affirmative, negative string) (bool, error),
263327
) error {
264-
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Defaults update preview:"))
265-
fmt.Fprint(os.Stderr, console.RenderStruct(defaultsUpdatePreview{
266-
Scope: target.scope,
267-
Target: target.displayName(),
268-
File: inputFile,
269-
Fields: len(changes),
270-
}))
271-
fmt.Fprintln(os.Stderr)
272-
fmt.Fprintln(os.Stderr)
273-
fmt.Fprint(os.Stderr, console.RenderStruct(defaultsUpdateRows(changes)))
274-
fmt.Fprintln(os.Stderr)
328+
renderDefaultsUpdatePreview(target, inputFile, changes)
275329

276330
if skipConfirmation {
277331
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Skipping confirmation because --yes was provided."))
@@ -292,6 +346,20 @@ func confirmDefaultsUpdate(
292346
return nil
293347
}
294348

349+
func renderDefaultsUpdatePreview(target defaultsTarget, inputFile string, changes []defaultsUpdateChange) {
350+
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Defaults update preview:"))
351+
fmt.Fprint(os.Stderr, console.RenderStruct(defaultsUpdatePreview{
352+
Scope: target.scope,
353+
Target: target.displayName(),
354+
File: inputFile,
355+
Fields: len(changes),
356+
}))
357+
fmt.Fprintln(os.Stderr)
358+
fmt.Fprintln(os.Stderr)
359+
fmt.Fprint(os.Stderr, console.RenderStruct(defaultsUpdateRows(changes)))
360+
fmt.Fprintln(os.Stderr)
361+
}
362+
295363
func defaultsUpdateRows(changes []defaultsUpdateChange) []defaultsUpdateRow {
296364
rows := make([]defaultsUpdateRow, 0, len(changes))
297365
for _, change := range changes {

pkg/cli/env_command_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func TestNewEnvCommand(t *testing.T) {
3333
assert.True(t, hasUpdate, "env command should include update subcommand")
3434
require.NotNil(t, updateCmd)
3535
assert.NotNil(t, updateCmd.Flags().Lookup("yes"))
36+
assert.NotNil(t, updateCmd.Flags().Lookup("dry-run"))
3637
}
3738

3839
func TestResolveDefaultsTarget(t *testing.T) {
@@ -120,6 +121,41 @@ func TestDefaultsFileYAMLNullDelete(t *testing.T) {
120121
})
121122
}
122123

124+
func TestDefaultsParseFileDisallowsUnknownFields(t *testing.T) {
125+
_, err := defaultsParseFile("defaults.yml", []byte("default_max_turns: \"42\"\ndefault_model_copliot: gpt-5-mini\n"))
126+
require.Error(t, err)
127+
assert.Contains(t, err.Error(), "default_model_copliot")
128+
}
129+
130+
func TestDefaultsValidateFile(t *testing.T) {
131+
t.Run("accepts valid values", func(t *testing.T) {
132+
err := defaultsValidateFile(&defaultsFile{
133+
DefaultMaxEffectiveTokens: strPtr("-1"),
134+
DefaultMaxTurns: strPtr("12"),
135+
DefaultTimeoutMinutes: strPtr("30"),
136+
DefaultDetectionModel: strPtr("claude-sonnet-4.6"),
137+
DefaultModelCopilot: strPtr("gpt-5-mini"),
138+
DefaultModelClaude: strPtr("claude-haiku-4.5"),
139+
DefaultModelCodex: strPtr("gpt-5.4-mini"),
140+
})
141+
require.NoError(t, err)
142+
})
143+
144+
t.Run("rejects invalid numeric and empty model values", func(t *testing.T) {
145+
err := defaultsValidateFile(&defaultsFile{
146+
DefaultMaxEffectiveTokens: strPtr("0"),
147+
DefaultMaxTurns: strPtr("abc"),
148+
DefaultTimeoutMinutes: strPtr("0"),
149+
DefaultModelCopilot: strPtr(" "),
150+
})
151+
require.Error(t, err)
152+
assert.Contains(t, err.Error(), "default_max_effective_tokens must be a non-zero integer when set")
153+
assert.Contains(t, err.Error(), "default_max_turns must be a positive integer when set")
154+
assert.Contains(t, err.Error(), "default_timeout_minutes must be a positive integer when set")
155+
assert.Contains(t, err.Error(), "default_model_copilot cannot be empty when set")
156+
})
157+
}
158+
123159
func TestDefaultsTargetEndpoints(t *testing.T) {
124160
repoTarget := defaultsTarget{scope: defaultsScopeRepo, repoOwner: "github", repoName: "gh-aw"}
125161
orgTarget := defaultsTarget{scope: defaultsScopeOrg, org: "github"}

pkg/workflow/compilerenv/manager_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ func TestResolveDefaultMaxEffectiveTokens(t *testing.T) {
3636
t.Setenv(DefaultMaxEffectiveTokens, "424242")
3737
assert.Equal(t, int64(424242), ResolveDefaultMaxEffectiveTokens(10))
3838
})
39+
40+
t.Run("negative value overrides fallback", func(t *testing.T) {
41+
t.Setenv(DefaultMaxEffectiveTokens, "-1")
42+
assert.Equal(t, int64(-1), ResolveDefaultMaxEffectiveTokens(10))
43+
})
3944
}
4045

4146
func TestBuildModelOverrideExpression(t *testing.T) {

0 commit comments

Comments
 (0)