Skip to content

Commit ff5c6ab

Browse files
committed
fix(scope): add WAVE_SKIP_SCOPE_CHECK bypass + fix nil introspector violations (#1622)
- Add WAVE_SKIP_SCOPE_CHECK=1 env var to bypass all scope validation (fine-grained PATs, unsupported forges, air-gapped envs) - Fix nil introspector path: emit per-scope violations instead of warn+skip (completes Finding 2 fix) - Update fine-grained PAT hint to reference env var not nonexistent flag - Add TestValidatePersonas_SkipScopeCheckEnv test - Update TestValidatePersonas_UnknownForge to expect violations - Docs: environment.md, manifest.md, concepts/personas.md
1 parent 95b0849 commit ff5c6ab

5 files changed

Lines changed: 75 additions & 8 deletions

File tree

docs/concepts/personas.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,8 @@ personas:
313313

314314
Token scopes enforce **least-privilege API access** per persona. During preflight, Wave validates that the active forge token satisfies each persona's declared scopes before pipeline execution begins. This catches misconfigured credentials early rather than failing mid-pipeline.
315315

316+
Introspection failures (including fine-grained GitHub PATs, which lack readable scope headers) produce violations with remediation hints. Set `WAVE_SKIP_SCOPE_CHECK=1` to bypass scope validation when introspection is unavailable.
317+
316318
### Permission Hierarchy
317319

318320
Permissions are hierarchical: `admin` satisfies `write`, which satisfies `read`. Canonical resources include `issues`, `pulls`, `repos`, `actions`, and `packages`.

docs/reference/environment.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Reference for all environment variables that control Wave behavior, and the cred
1111
| `WAVE_MIGRATION_ENABLED` | `bool` | `true` | Enable the database migration system. |
1212
| `WAVE_AUTO_MIGRATE` | `bool` | `true` | Automatically apply pending migrations on startup. |
1313
| `WAVE_SKIP_MIGRATION_VALIDATION` | `bool` | `false` | Skip migration checksum validation (development only). |
14+
| `WAVE_SKIP_SCOPE_CHECK` | `bool` | `false` | Bypass token scope validation entirely. Use for fine-grained PATs (GitHub), unsupported forges, or air-gapped environments where token introspection is unavailable. |
1415
| `WAVE_MAX_MIGRATION_VERSION` | `int` | `0` | Limit migrations to this version (0 = unlimited). Useful for gradual rollout. |
1516
| `NO_COLOR` | `string` | _(unset)_ | Disable colored output. Any non-empty value disables color. Follows the [NO_COLOR](https://no-color.org) standard. |
1617

docs/reference/manifest.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,12 @@ If `token_scopes` is omitted for a persona, scope validation is skipped for that
251251

252252
Unknown resources produce warnings (not errors) to allow forward-compatible scope declarations.
253253

254+
**Introspection failures** (network errors, API errors) produce violations that block execution — the persona explicitly declared required scopes and those cannot be verified.
255+
256+
**Fine-grained GitHub PATs** lack the `X-OAuth-Scopes` response header used for introspection. Wave surfaces a violation with a remediation hint. Recreate the token as a classic PAT, or set `WAVE_SKIP_SCOPE_CHECK=1` to bypass scope validation for environments where introspection is unavailable.
257+
258+
**Unsupported forges** (e.g. Bitbucket) produce violations for each declared scope. Set `WAVE_SKIP_SCOPE_CHECK=1` to bypass.
259+
254260
Key sources: `internal/scope/scope.go`, `internal/scope/validator.go`, `internal/scope/resolver.go`
255261

256262
### Temperature Guidelines

internal/scope/validator.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package scope
22

33
import (
44
"fmt"
5+
"os"
56
"strings"
67

78
"github.com/recinq/wave/internal/forge"
@@ -82,12 +83,35 @@ func defaultTokenEnvVar(ft forge.ForgeType) string {
8283
// ValidatePersonas checks all personas' scope requirements against active tokens.
8384
// The personas argument maps persona name to its token_scopes slice.
8485
// Returns all violations aggregated (FR-006).
86+
// Set WAVE_SKIP_SCOPE_CHECK=1 to bypass all scope validation (fine-grained PATs, air-gapped envs).
8587
func (v *Validator) ValidatePersonas(personas map[string][]string) (*ValidationResult, error) {
8688
result := &ValidationResult{}
8789

88-
// If no introspector available (unknown forge), warn and skip
90+
if os.Getenv("WAVE_SKIP_SCOPE_CHECK") != "" {
91+
result.Warnings = append(result.Warnings, "WAVE_SKIP_SCOPE_CHECK set; token scope validation bypassed")
92+
return result, nil
93+
}
94+
95+
// If no introspector available (unsupported forge), emit violations per declared scope
8996
if v.introspector == nil {
90-
result.Warnings = append(result.Warnings, fmt.Sprintf("no token introspector available for forge type %q; skipping scope validation", v.forgeInfo.Type))
97+
for name, tokenScopes := range personas {
98+
for _, scopeStr := range tokenScopes {
99+
ts, _, err := Parse(scopeStr)
100+
if err != nil {
101+
continue
102+
}
103+
envVar := ts.EnvVar
104+
if envVar == "" {
105+
envVar = defaultTokenEnvVar(v.forgeInfo.Type)
106+
}
107+
result.Violations = append(result.Violations, ScopeViolation{
108+
PersonaName: name,
109+
MissingScope: scopeStr,
110+
EnvVar: envVar,
111+
Hint: fmt.Sprintf("token scope validation not supported for forge %q; set WAVE_SKIP_SCOPE_CHECK=1 to bypass", v.forgeInfo.Type),
112+
})
113+
}
114+
}
91115
return result, nil
92116
}
93117

@@ -156,7 +180,7 @@ func (v *Validator) ValidatePersonas(personas map[string][]string) (*ValidationR
156180
if tokenInfo.Error != nil {
157181
hint := fmt.Sprintf("token introspection failed: %v", tokenInfo.Error)
158182
if tokenInfo.TokenType == "fine-grained" {
159-
hint = "fine-grained PATs cannot be introspected; recreate as classic PAT or use --skip-scope-check"
183+
hint = "fine-grained PATs cannot be introspected; recreate as classic PAT or set WAVE_SKIP_SCOPE_CHECK=1 to bypass"
160184
}
161185
result.Violations = append(result.Violations, ScopeViolation{
162186
PersonaName: name,

internal/scope/validator_test.go

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,18 @@ func TestValidatePersonas_UnknownForge(t *testing.T) {
111111
if err != nil {
112112
t.Fatalf("unexpected error: %v", err)
113113
}
114-
if result.HasViolations() {
115-
t.Error("expected no violations for unknown forge (should warn and skip)")
114+
// Unknown forge + nil introspector → violation per declared scope (Finding 2)
115+
if !result.HasViolations() {
116+
t.Error("expected violations for unknown forge (nil introspector)")
116117
}
117-
if len(result.Warnings) == 0 {
118-
t.Error("expected warning for unknown forge type")
118+
found := false
119+
for _, v := range result.Violations {
120+
if v.PersonaName == "navigator" && v.MissingScope == "issues:read" {
121+
found = true
122+
}
123+
}
124+
if !found {
125+
t.Error("expected violation for navigator/issues:read on unknown forge")
119126
}
120127
}
121128

@@ -280,11 +287,38 @@ func TestValidatePersonas_FineGrainedPATHint(t *testing.T) {
280287
for _, violation := range result.Violations {
281288
if violation.PersonaName == "navigator" &&
282289
violation.EnvVar == "GH_TOKEN" &&
283-
violation.Hint == "fine-grained PATs cannot be introspected; recreate as classic PAT or use --skip-scope-check" {
290+
violation.Hint == "fine-grained PATs cannot be introspected; recreate as classic PAT or set WAVE_SKIP_SCOPE_CHECK=1 to bypass" {
284291
found = true
285292
}
286293
}
287294
if !found {
288295
t.Errorf("expected violation with fine-grained PAT remediation hint, got: %+v", result.Violations)
289296
}
290297
}
298+
299+
func TestValidatePersonas_SkipScopeCheckEnv(t *testing.T) {
300+
t.Setenv("WAVE_SKIP_SCOPE_CHECK", "1")
301+
302+
resolver := NewResolver(forge.ForgeGitHub)
303+
introspector := &mockIntrospector{
304+
results: map[string]*TokenInfo{
305+
"GH_TOKEN": {EnvVar: "GH_TOKEN", Error: fmt.Errorf("introspection failed")},
306+
},
307+
}
308+
v := NewValidator(resolver, introspector, forge.ForgeInfo{Type: forge.ForgeGitHub}, []string{"GH_TOKEN"})
309+
310+
personas := map[string][]string{
311+
"navigator": {"issues:read"},
312+
}
313+
314+
result, err := v.ValidatePersonas(personas)
315+
if err != nil {
316+
t.Fatalf("unexpected error: %v", err)
317+
}
318+
if result.HasViolations() {
319+
t.Error("expected no violations when WAVE_SKIP_SCOPE_CHECK set")
320+
}
321+
if len(result.Warnings) == 0 {
322+
t.Error("expected warning noting bypass is active")
323+
}
324+
}

0 commit comments

Comments
 (0)