Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/aw/syntax-agentic.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ description: Agentic workflow specific frontmatter fields for GitHub Agentic Wor
id: awf # Required in strict mode
version: "v0.25.29" # Optional: pin AWF version
model-fallback: false # Optional: disable model fallback (default true); set false for BYOK Azure OpenAI to prevent deployment-name rewriting
token-steering: false # Optional: disable API proxy token steering to preserve the configured provider and model
```

- To disable the agent firewall while keeping MCP gateway enabled, you must provide the dangerous-disable justification feature:
Expand Down
5 changes: 5 additions & 0 deletions docs/public/editor/autocomplete-data.json
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,11 @@
"desc": "Enable or disable model fallback for unresolved model selections.",
"leaf": true
},
"token-steering": {
"type": "boolean",
"desc": "Enable or disable API proxy token steering.",
"leaf": true
},
"runtime": {
"type": "string",
"desc": "Container runtime for the agent container.",
Expand Down
5 changes: 5 additions & 0 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -2110,6 +2110,11 @@ sandbox:
# Format 2: GitHub Actions expression that resolves to a boolean at runtime
model-fallback: "example-value"

# Enable or disable API proxy token steering. Set to false to preserve the
# explicitly configured provider and model.
# (optional)
token-steering: true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example value should be false, not true. The entire purpose of this field is to opt out of token steering — true is already the default and does not need to be set explicitly. Showing true here will mislead readers.

# Should be:
token-steering: false

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] The reference doc shows the default as token-steering: true, but the feature is a pure opt-out — the only user-facing value is false. Showing true as the canonical example may confuse authors who are looking for the opt-out pattern.

Suggest showing the opt-out value in the example, matching the usage example in sandbox.md and syntax-agentic.md:

token-steering: false

@copilot please address this.


# Container runtime for the agent container. Use 'gvisor' to run the agent under
# gVisor's runsc runtime for additional kernel-level isolation. Use 'docker-sbx'
# to run the agent inside a Docker sbx microVM with KVM hypervisor-level isolation
Expand Down
10 changes: 10 additions & 0 deletions docs/src/content/docs/reference/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,16 @@ When omitted, AWF's own default memory limit applies. Specifying an invalid form
> [!NOTE]
> Exit code 137 means the process received `SIGKILL`. A memory limit can be one cause, but verify with logs before changing `memory`. If you increase `memory`, leave headroom for the runner OS and other processes.

#### Token steering (`sandbox.agent.token-steering`)

AWF enables API proxy token steering by default. To keep the explicitly configured provider and model, disable it for a workflow:

```yaml wrap
sandbox:
agent:
token-steering: false
```

#### Copilot BYOK request customization (`sandbox.agent.targets.copilot`)

When routing Copilot through a BYOK-compatible upstream behind the AWF proxy, you can attach custom headers, extra request body fields, and an explicit session identifier on upstream requests:
Expand Down
5 changes: 5 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3515,6 +3515,11 @@
"description": "Enable or disable model fallback for unresolved model selections. Set to false for BYOK Azure OpenAI deployments to prevent deployment-name rewriting. Supports literal boolean or GitHub Actions expression.",
"examples": [false, "${{ inputs.model-fallback }}"]
},
"token-steering": {
"type": "boolean",
"description": "Enable or disable API proxy token steering. Set to false to preserve the explicitly configured provider and model.",
"examples": [false]
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] token-steering uses "type": "boolean" while its sibling model-fallback uses "$ref": "#/$defs/templatable_boolean" which also accepts GitHub Actions expressions. If a user tries token-steering: "${{ inputs.disable-steering }}" it will fail schema validation, which is an inconsistency they'll hit when parameterising workflows.

Consider using the same templatable_boolean ref — or if that's intentional (expressions not supported at runtime), add a comment in the schema noting why.

@copilot please address this.

"runtime": {
"type": "string",
"description": "Container runtime for the agent container. Use 'gvisor' to run the agent under gVisor's runsc runtime for additional kernel-level isolation. Use 'docker-sbx' to run the agent inside a Docker sbx microVM with KVM hypervisor-level isolation \u2014 requires sandbox.agent.sudo: true, DOCKER_PAT and DOCKER_USERNAME secrets, and a KVM-capable runner. Incompatible with runner.topology: arc-dind.",
Expand Down
13 changes: 10 additions & 3 deletions pkg/workflow/awf_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ type AWFAPIProxyConfig struct {
Enabled bool `json:"enabled"`

// EnableTokenSteering enables budget-warning system message injection near ET budget exhaustion.
EnableTokenSteering bool `json:"enableTokenSteering,omitempty"`
EnableTokenSteering *bool `json:"enableTokenSteering,omitempty"`

// MaxRuns is the maximum number of LLM invocations allowed for a run.
MaxRuns int `json:"maxRuns,omitempty"`
Expand Down Expand Up @@ -553,21 +553,28 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) {
// value (-1) omits that budget from the AWF config and disables token steering.
// When maxAICredits is 0 (runtime default), token steering stays enabled here.
enableTokenSteering := maxAICredits >= 0
if config.WorkflowData != nil && config.WorkflowData.SandboxConfig != nil && config.WorkflowData.SandboxConfig.Agent != nil && config.WorkflowData.SandboxConfig.Agent.TokenSteering != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explicit token-steering:true silently re-enables steering even when max-ai-credits is negative (intentionally disabled), while maxAiCredits is still omitted from the config — an untested, inconsistent combination.

💡 Details

Before this PR, enableTokenSteering := maxAICredits >= 0 was the sole source of truth, and maxAICredits < 0 reliably meant "no budget, no steering". Now an explicit sandbox.agent.token-steering: true override runs after that computation but before maxAICredits is reset to 0 for omission, so:

enableTokenSteering := maxAICredits >= 0
if ...SandboxConfig.Agent.TokenSteering != nil {
    enableTokenSteering = *config.WorkflowData.SandboxConfig.Agent.TokenSteering
}
if maxAICredits < 0 {
    maxAICredits = 0 // omitted from config
}

If a workflow sets max-ai-credits: -1 (explicitly disabling the budget) AND sandbox.agent.token-steering: true, the emitted config has enableTokenSteering:true with no maxAiCredits field. It is unclear whether AWF's token steering can function meaningfully without a budget to steer against, and no test exercises this combination (only max-ai-credits-negative alone and token-steering:false alone are covered).

Suggested fix: either ignore/reject the override when maxAICredits < 0 with a clear log message, or add a test that documents the intended behavior for this combination.

enableTokenSteering = *config.WorkflowData.SandboxConfig.Agent.TokenSteering
}
Comment on lines +556 to +558
if maxAICredits < 0 {
// Negative signals "disabled" — omit the budget from the AWF config.
maxAICredits = 0
}
var tokenSteeringEnabled *bool
if awfSupportsTokenSteering(firewallConfig) && (enableTokenSteering || (config.WorkflowData != nil && config.WorkflowData.SandboxConfig != nil && config.WorkflowData.SandboxConfig.Agent != nil && config.WorkflowData.SandboxConfig.Agent.TokenSteering != nil)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] The nil-guard chain on line 564 is a verbatim copy of the one on line 556, making the emit logic hard to follow. A shared local variable would eliminate the duplication and clarify intent.

💡 Suggested refactor

After line 557, capture the explicit-override flag once:

agent := func() *AgentSandboxConfig {
    if config.WorkflowData != nil && config.WorkflowData.SandboxConfig != nil {
        return config.WorkflowData.SandboxConfig.Agent
    }
    return nil
}()
explicitlySet := agent != nil && agent.TokenSteering != nil

Then lines 556–564 simplify to:

if explicitlySet {
    enableTokenSteering = *agent.TokenSteering
}
var tokenSteeringEnabled *bool
if awfSupportsTokenSteering(firewallConfig) && (enableTokenSteering || explicitlySet) {
    tokenSteeringEnabled = &enableTokenSteering
}

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 4-level nil-check chain for SandboxConfig.Agent.TokenSteering is duplicated three times in ~15 lines — extract a helper to avoid drift.

💡 Details
if config.WorkflowData != nil && config.WorkflowData.SandboxConfig != nil && config.WorkflowData.SandboxConfig.Agent != nil && config.WorkflowData.SandboxConfig.Agent.TokenSteering != nil {

This exact expression (or a near-duplicate) appears at line 556 and twice more within the if at line 564. Any future refactor of SandboxConfig/AgentSandboxConfig risks updating some occurrences but not others, silently breaking the override logic. Extract a small helper, e.g.:

func tokenSteeringOverride(wd *WorkflowData) *bool {
    if wd == nil || wd.SandboxConfig == nil || wd.SandboxConfig.Agent == nil {
        return nil
    }
    return wd.SandboxConfig.Agent.TokenSteering
}

and reuse tokenSteeringOverride(config.WorkflowData) != nil / the dereferenced value at each call site. This also matches the existing pattern of small extractXxx helpers already used elsewhere in this file (e.g. extractModelFallback).

tokenSteeringEnabled = &enableTokenSteering
}

apiProxy := &AWFAPIProxyConfig{
Enabled: true,
MaxRuns: maxRuns,
MaxTurnCacheMisses: maxTurnCacheMisses,
MaxAICredits: maxAICredits,
EnableTokenSteering: enableTokenSteering && awfSupportsTokenSteering(firewallConfig),
EnableTokenSteering: tokenSteeringEnabled,
}

if !enableTokenSteering {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] The log message on line 577 says "Disabling" but that condition also fires when the sandbox override explicitly sets token-steering: false — the old message was more informative (it stated the reason: negative max-ai-credits). With the new opt-out path, the same branch covers two distinct causes without distinguishing them.

💡 Suggestion

Differentiate the two disablement paths in the log:

if !enableTokenSteering {
    if explicitlySet {
        awfConfigLog.Print("Disabling apiProxy.enableTokenSteering: sandbox.agent.token-steering=false")
    } else {
        awfConfigLog.Print("Disabling apiProxy.enableTokenSteering: max-ai-credits is negative")
    }
}

This keeps operational diagnostics actionable when users report unexpected behaviour.

@copilot please address this.

awfConfigLog.Printf("Skipping apiProxy.enableTokenSteering: max-ai-credits is negative (disabled)")
awfConfigLog.Print("Disabling apiProxy.enableTokenSteering")
} else if !awfSupportsTokenSteering(firewallConfig) {
awfConfigLog.Printf("Skipping apiProxy.enableTokenSteering: AWF version %q requires at least %s", getAWFImageTag(firewallConfig), constants.AWFTokenSteeringMinVersion)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When awfSupportsTokenSteering returns false but the user explicitly set sandbox.agent.token-steering: false, the explicit opt-out is silently dropped — the log only fires for the version-gating path when enableTokenSteering is true. A user who pins an old AWF version and sets token-steering: false will get no indication that their setting was ignored.

Consider emitting a warning when the user set the field explicitly but the version does not support it:

} else if !awfSupportsTokenSteering(firewallConfig) {
    if config.WorkflowData != nil && ... tokenSteering != nil {
        awfConfigLog.Printf("Warning: sandbox.agent.token-steering is set but AWF version %q requires at least %s; setting ignored",
            getAWFImageTag(firewallConfig), constants.AWFTokenSteeringMinVersion)
    } else {
        awfConfigLog.Printf("Skipping apiProxy.enableTokenSteering: ...")
    }
}

@copilot please address this.

}
Expand Down
21 changes: 21 additions & 0 deletions pkg/workflow/awf_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,27 @@ func TestBuildAWFConfigJSON(t *testing.T) {
assert.Contains(t, jsonStr, `"enableTokenSteering":true`, "apiProxy should emit enableTokenSteering by default")
})

t.Run("token steering can be disabled in the sandbox config", func(t *testing.T) {
disabled := false
config := AWFCommandConfig{
EngineName: "copilot",
AllowedDomains: "github.com",
WorkflowData: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot"},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{Enabled: true},
},
SandboxConfig: &SandboxConfig{
Agent: &AgentSandboxConfig{TokenSteering: &disabled},
},
},
}

jsonStr, err := BuildAWFConfigJSON(config)
require.NoError(t, err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The new test verifies the happy path (sandbox disables steering), but misses the interaction case: when sandbox.agent.token-steering: false is set alongside a positive max-ai-credits, the feature should still be disabled. Without this test, a future refactor of the priority logic could silently regress the override behaviour.

💡 Suggested additional test case
t.Run("token steering sandbox override wins over positive max-ai-credits", func(t *testing.T) {
    disabled := false
    config := AWFCommandConfig{
        EngineName:     "copilot",
        AllowedDomains: "github.com",
        WorkflowData: &WorkflowData{
            EngineConfig: &EngineConfig{ID: "copilot", MaxAICredits: 500},
            NetworkPermissions: &NetworkPermissions{
                Firewall: &FirewallConfig{Enabled: true},
            },
            SandboxConfig: &SandboxConfig{
                Agent: &AgentSandboxConfig{TokenSteering: &disabled},
            },
        },
    }
    jsonStr, err := BuildAWFConfigJSON(config)
    require.NoError(t, err)
    assert.Contains(t, jsonStr, `"enableTokenSteering":false`)
})

@copilot please address this.

assert.Contains(t, jsonStr, `"enableTokenSteering":false`, "apiProxy should emit the sandbox token-steering override")
})

t.Run("token steering is disabled when max-ai-credits is negative", func(t *testing.T) {
config := AWFCommandConfig{
EngineName: "copilot",
Expand Down
8 changes: 8 additions & 0 deletions pkg/workflow/frontmatter_extraction_security.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,14 @@ func (c *Compiler) extractAgentSandboxConfig(agentVal any) *AgentSandboxConfig {
}
}

// Extract token-steering (AWF API proxy token steering enable/disable flag).
if tsVal, hasTS := agentObj["token-steering"]; hasTS {
if value, ok := tsVal.(bool); ok {
agentConfig.TokenSteering = &value
frontmatterExtractionSecurityLog.Print("Extracted sandbox.agent.token-steering")
}
}

// Extract targets (per-provider API proxy target overrides, e.g. authHeader, extraHeaders)
if targetsVal, hasTargets := agentObj["targets"]; hasTargets {
if targetsObj, ok := targetsVal.(map[string]any); ok {
Expand Down
22 changes: 22 additions & 0 deletions pkg/workflow/frontmatter_extraction_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,28 @@ func TestExtractAgentSandboxConfigModelFallback(t *testing.T) {
})
}

func TestExtractAgentSandboxConfigTokenSteering(t *testing.T) {
compiler := &Compiler{}

t.Run("extracts sandbox.agent.token-steering false", func(t *testing.T) {
config := compiler.extractAgentSandboxConfig(map[string]any{
"id": "awf",
"token-steering": false,
})

require.NotNil(t, config)
require.NotNil(t, config.TokenSteering)
assert.False(t, *config.TokenSteering)
})

t.Run("token-steering is nil when absent", func(t *testing.T) {
config := compiler.extractAgentSandboxConfig(map[string]any{"id": "awf"})

require.NotNil(t, config)
assert.Nil(t, config.TokenSteering)
})
}

func TestExtractAgentSandboxConfigMemory(t *testing.T) {
compiler := &Compiler{}

Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ type AgentSandboxConfig struct {
Mounts []string `yaml:"mounts,omitempty"` // Container mounts to add for AWF (format: "source:dest:mode")
Memory string `yaml:"memory,omitempty"` // Memory limit for the AWF container (e.g., "4g", "8g")
ModelFallback *TemplatableBool `yaml:"model-fallback,omitempty"` // AWF API proxy model fallback enable/disable flag (optional)
TokenSteering *bool `yaml:"token-steering,omitempty"` // AWF API proxy token steering enable/disable flag (optional)
Targets map[string]*AgentAPIProxyTargetConfig `yaml:"targets,omitempty"` // Per-provider API proxy target overrides keyed by provider name (e.g. "openai", "anthropic")
}

Expand Down
1 change: 1 addition & 0 deletions specs/awf-config-sources-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ The following fields previously existed in schema but were missed in spec CLI ma
| `apiProxy.models` | config-only (model alias rewriting) | `pkg/workflow/awf_config_test.go` |
| `apiProxy.modelMultipliers` | config-only (effective-token accounting) | `pkg/workflow/awf_config_test.go` |
| `apiProxy.modelFallback` | config-only (model fallback policy; set `sandbox.agent.model-fallback: false` to prevent deployment-name rewriting for BYOK Azure) | `pkg/workflow/awf_config_test.go` (`TestAWFConfig_ModelFallback*`) |
| `apiProxy.enableTokenSteering` | config-only (set `sandbox.agent.token-steering: false` to preserve the explicitly configured provider and model) | `pkg/workflow/awf_config_test.go` |
| `apiProxy.maxRuns` | config-only (LLM invocation hard cap) | `pkg/workflow/awf_config_test.go` |
| `apiProxy.auth.*` | config-only (maps to `AWF_AUTH_*` env vars) | `pkg/workflow/awf_config_test.go` |
| `apiProxy.targets.openai.authHeader` | `--openai-api-auth-header` (frontmatter: `sandbox.agent.targets.openai.authHeader`) | `pkg/workflow/awf_config_test.go` |
Expand Down
Loading