Skip to content

Commit 7cc1ebd

Browse files
authored
Implement ResolveRisk and Authorizer.AuthorizeTool in pkg/intent (#53742)
1 parent 1605170 commit 7cc1ebd

5 files changed

Lines changed: 341 additions & 7 deletions

File tree

pkg/intent/README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Resolution is performed by a `Resolver`, which holds a label matcher function an
2121
|------|------|-------------|
2222
| `AttributionStatus` | string | Classifies the outcome of intent attribution |
2323
| `AttributionSource` | string | Identifies the data source used for attribution |
24-
| `IntentRecord` | struct | Holds the attribution result for a pull request or issue |
24+
| `IntentRecord` | struct | Holds the attribution result for a pull request or issue, including optional `Domains`, `Priority`, and `Risk` classification fields |
2525
| `RootReference` | struct | Represents a referenced issue or artifact root (node ID, type, URL, labels) |
2626
| `PullRequestData` | struct | Input data for pull request resolution (node ID, URL, labels, explicit intent, closing issues) |
2727
| `Resolver` | struct | Stateless resolver that maps labels to intent records |
@@ -70,7 +70,20 @@ Resolution is performed by a `Resolver`, which holds a label matcher function an
7070

7171
`PolicyRule` configures a single policy fragment. Its `ID` identifies the matched rule in compiled policy output, `Scope` records the rule level (`"organization"`, `"repository"`, `"intent"`, or `"workflow"`), `When` holds the match criteria, and `Set` holds the `ExecutionPolicy` fields to merge when the rule applies.
7272

73-
`PolicyCondition` matches rule criteria against an `IntentRecord` and `RepositoryContext`. Empty condition fields act as wildcards. `Domain`, `Priority`, and `Risk` match against intent labels; `Org` matches either the repository organization or owner.
73+
`PolicyCondition` matches rule criteria against an `IntentRecord` and `RepositoryContext`. Empty condition fields act as wildcards. `Domain`, `Priority`, and `Risk` match against `IntentRecord.Labels` (not the dedicated `Domains`/`Priority`/`Risk` fields below, which are used only by `ResolveRisk`); `Org` matches either the repository organization or owner.
74+
75+
### Risk classification
76+
77+
| Function | Signature | Description |
78+
|----------|-----------|-------------|
79+
| `ResolveRisk` | `func ResolveRisk(rec IntentRecord) string` | Returns `rec.Risk` when set; otherwise derives a risk level from `rec.Domains`/`rec.Priority`: `security`+`critical` and `production` resolve to `"high"`, `infrastructure` to `"medium"`, `documentation` to `"low"`, and anything else to `"unknown"` |
80+
81+
### Tool authorization
82+
83+
| Type/Method | Signature | Description |
84+
|-------------|-----------|-------------|
85+
| `Authorizer` | struct | Authorizes individual tool calls against a compiled `ExecutionPolicy` |
86+
| `AuthorizeTool` | `func (a Authorizer) AuthorizeTool(policy ExecutionPolicy, tool string) error` | Returns `ErrToolDenied` if `tool` is in `DeniedTools` (denial always wins), `ErrToolNotAllowed` if `AllowedTools` is non-nil and does not contain `tool`, or `nil` otherwise. A `nil` `AllowedTools` is unrestricted; a non-nil empty `AllowedTools` denies every tool. |
7487

7588
## Usage Examples
7689

pkg/intent/governance.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package intent
2+
3+
import (
4+
"errors"
5+
"slices"
6+
7+
"github.com/github/gh-aw/pkg/logger"
8+
)
9+
10+
var governanceLog = logger.New("intent:governance")
11+
12+
// ErrToolDenied is returned by Authorizer.AuthorizeTool when the tool appears in
13+
// the policy's DeniedTools list. A deny always wins, even if the same tool is
14+
// also present in AllowedTools.
15+
var ErrToolDenied = errors.New("intent: tool denied by policy")
16+
17+
// ErrToolNotAllowed is returned by Authorizer.AuthorizeTool when the policy's
18+
// AllowedTools is non-nil (restricted) and does not contain the requested tool.
19+
var ErrToolNotAllowed = errors.New("intent: tool not allowed by policy")
20+
21+
// ResolveRisk returns rec.Risk when explicitly set; otherwise it derives a risk
22+
// classification from rec.Domains and rec.Priority using deterministic,
23+
// precedence-ordered rules:
24+
//
25+
// security + critical priority -> high
26+
// production -> high
27+
// infrastructure -> medium
28+
// documentation -> low
29+
// anything else -> unknown
30+
//
31+
// An explicit Risk always wins over any derived value, even when the record's
32+
// domains or priority would otherwise match a different rule.
33+
func ResolveRisk(rec IntentRecord) string {
34+
if rec.Risk != "" {
35+
governanceLog.Printf("ResolveRisk: using explicit risk=%s", rec.Risk)
36+
return rec.Risk
37+
}
38+
39+
if slices.Contains(rec.Domains, "security") && rec.Priority == "critical" {
40+
governanceLog.Print("ResolveRisk: security+critical -> high")
41+
return "high"
42+
}
43+
if slices.Contains(rec.Domains, "production") {
44+
governanceLog.Print("ResolveRisk: production -> high")
45+
return "high"
46+
}
47+
if slices.Contains(rec.Domains, "infrastructure") {
48+
governanceLog.Print("ResolveRisk: infrastructure -> medium")
49+
return "medium"
50+
}
51+
if slices.Contains(rec.Domains, "documentation") {
52+
governanceLog.Print("ResolveRisk: documentation -> low")
53+
return "low"
54+
}
55+
56+
governanceLog.Print("ResolveRisk: no matching rule -> unknown")
57+
return "unknown"
58+
}
59+
60+
// Authorizer authorizes individual tool calls against a compiled ExecutionPolicy.
61+
type Authorizer struct{}
62+
63+
// AuthorizeTool reports whether tool may be called under policy. DeniedTools is
64+
// checked first and always wins, even if tool also appears in AllowedTools. A
65+
// nil AllowedTools means unrestricted (any tool not explicitly denied is
66+
// allowed); a non-nil AllowedTools (including an empty, non-nil slice) restricts
67+
// calls to the listed tools, so a non-nil empty slice denies every tool.
68+
func (a Authorizer) AuthorizeTool(policy ExecutionPolicy, tool string) error {
69+
if slices.Contains(policy.DeniedTools, tool) {
70+
return ErrToolDenied
71+
}
72+
if policy.AllowedTools != nil && !slices.Contains(policy.AllowedTools, tool) {
73+
return ErrToolNotAllowed
74+
}
75+
return nil
76+
}
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
//go:build !integration
2+
3+
package intent_test
4+
5+
import (
6+
"errors"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/github/gh-aw/pkg/intent"
13+
)
14+
15+
// Formal test suite derived from specs/intent-attribution-agent-governance.md,
16+
// focusing on the Risk classification (ResolveRisk) and Enforcement
17+
// (Authorizer.AuthorizeTool) sections, plus fail-closed policy compilation for
18+
// unlinked/ambiguous attribution. Each test corresponds to a named predicate or
19+
// invariant in the behavioral coverage map.
20+
21+
// TestResolveRisk_ExplicitOverride (P1/P2 — RiskExplicitOverride)
22+
// Invariant: an explicit intent.Risk always wins over derived rules, even with
23+
// conflicting domains/priority that would otherwise resolve differently.
24+
func TestResolveRisk_ExplicitOverride(t *testing.T) {
25+
rec := intent.IntentRecord{
26+
Risk: "low",
27+
Domains: []string{"security", "production"},
28+
Priority: "critical",
29+
}
30+
assert.Equal(t, "low", intent.ResolveRisk(rec),
31+
"P1/P2: explicit risk must win over derived rules")
32+
}
33+
34+
// TestResolveRisk_SecurityCriticalIsHigh (P3 — RiskSecurityCriticalHigh)
35+
// Invariant: domains contains security AND priority == critical => high.
36+
func TestResolveRisk_SecurityCriticalIsHigh(t *testing.T) {
37+
rec := intent.IntentRecord{
38+
Domains: []string{"security"},
39+
Priority: "critical",
40+
}
41+
assert.Equal(t, "high", intent.ResolveRisk(rec),
42+
"P3: security+critical must resolve to high")
43+
}
44+
45+
// TestResolveRisk_ProductionIsHigh (P4 — RiskProductionHigh)
46+
// Invariant: domains contains production => high, independent of priority.
47+
func TestResolveRisk_ProductionIsHigh(t *testing.T) {
48+
cases := []string{"", "low", "critical", "unrecognized"}
49+
for _, priority := range cases {
50+
t.Run("priority="+priority, func(t *testing.T) {
51+
rec := intent.IntentRecord{
52+
Domains: []string{"production"},
53+
Priority: priority,
54+
}
55+
assert.Equal(t, "high", intent.ResolveRisk(rec),
56+
"P4: production domain must resolve to high regardless of priority")
57+
})
58+
}
59+
}
60+
61+
// TestResolveRisk_InfrastructureIsMedium (P5 — RiskInfrastructureMedium)
62+
// Invariant: domains contains infrastructure => medium.
63+
func TestResolveRisk_InfrastructureIsMedium(t *testing.T) {
64+
rec := intent.IntentRecord{Domains: []string{"infrastructure"}}
65+
assert.Equal(t, "medium", intent.ResolveRisk(rec),
66+
"P5: infrastructure domain must resolve to medium")
67+
}
68+
69+
// TestResolveRisk_DocumentationIsLow (P6 — RiskDocumentationLow)
70+
// Invariant: domains contains documentation => low.
71+
func TestResolveRisk_DocumentationIsLow(t *testing.T) {
72+
rec := intent.IntentRecord{Domains: []string{"documentation"}}
73+
assert.Equal(t, "low", intent.ResolveRisk(rec),
74+
"P6: documentation domain must resolve to low")
75+
}
76+
77+
// TestResolveRisk_UnknownDefault (P7 — RiskUnknownDefault)
78+
// Invariant: no matching rule (empty, unrecognized domain, security without
79+
// critical priority) => unknown.
80+
func TestResolveRisk_UnknownDefault(t *testing.T) {
81+
cases := []struct {
82+
name string
83+
rec intent.IntentRecord
84+
}{
85+
{"empty", intent.IntentRecord{}},
86+
{"unrecognized_domain", intent.IntentRecord{Domains: []string{"marketing"}}},
87+
{"security_without_critical", intent.IntentRecord{Domains: []string{"security"}, Priority: "low"}},
88+
{"security_no_priority", intent.IntentRecord{Domains: []string{"security"}}},
89+
}
90+
for _, tc := range cases {
91+
t.Run(tc.name, func(t *testing.T) {
92+
assert.Equal(t, "unknown", intent.ResolveRisk(tc.rec),
93+
"P7: non-matching input must resolve to unknown")
94+
})
95+
}
96+
}
97+
98+
// TestResolveRisk_PrecedenceOrder (P8 — RiskPrecedenceOrder)
99+
// Invariant: security+critical takes precedence when multiple domains overlap.
100+
func TestResolveRisk_PrecedenceOrder(t *testing.T) {
101+
rec := intent.IntentRecord{
102+
Domains: []string{"documentation", "infrastructure", "production", "security"},
103+
Priority: "critical",
104+
}
105+
assert.Equal(t, "high", intent.ResolveRisk(rec),
106+
"P8: security+critical must take precedence over other overlapping domains")
107+
}
108+
109+
// TestAuthorizeTool_DeniedWins (P9 — AuthorizeToolDeniedWins)
110+
// Invariant: a tool in DeniedTools is rejected even if it also appears in
111+
// AllowedTools.
112+
func TestAuthorizeTool_DeniedWins(t *testing.T) {
113+
policy := intent.ExecutionPolicy{
114+
AllowedTools: []string{"read", "write"},
115+
DeniedTools: []string{"write"},
116+
}
117+
err := intent.Authorizer{}.AuthorizeTool(policy, "write")
118+
require.Error(t, err, "P9: denied tool must be rejected")
119+
assert.True(t, errors.Is(err, intent.ErrToolDenied),
120+
"P9: denied tool must return ErrToolDenied")
121+
}
122+
123+
// TestAuthorizeTool_AllowlistGate (P10 — AuthorizeToolAllowlistGate)
124+
// Invariant: a non-nil allow list rejects tools not listed.
125+
func TestAuthorizeTool_AllowlistGate(t *testing.T) {
126+
policy := intent.ExecutionPolicy{AllowedTools: []string{"read"}}
127+
err := intent.Authorizer{}.AuthorizeTool(policy, "exec")
128+
require.Error(t, err, "P10: tool absent from a restricted allow list must be rejected")
129+
assert.True(t, errors.Is(err, intent.ErrToolNotAllowed),
130+
"P10: tool absent from allow list must return ErrToolNotAllowed")
131+
132+
require.NoError(t, intent.Authorizer{}.AuthorizeTool(policy, "read"),
133+
"P10: tool present in the allow list must be authorized")
134+
}
135+
136+
// TestAuthorizeTool_UnrestrictedWhenAllowedToolsNil (P11 — AuthorizeToolUnrestricted)
137+
// Invariant: nil AllowedTools means unrestricted (except explicit denies).
138+
func TestAuthorizeTool_UnrestrictedWhenAllowedToolsNil(t *testing.T) {
139+
policy := intent.ExecutionPolicy{AllowedTools: nil, DeniedTools: []string{"exec"}}
140+
141+
require.NoError(t, intent.Authorizer{}.AuthorizeTool(policy, "read"),
142+
"P11: nil AllowedTools must permit any tool that isn't denied")
143+
require.NoError(t, intent.Authorizer{}.AuthorizeTool(policy, "anything"),
144+
"P11: nil AllowedTools must permit any tool that isn't denied")
145+
146+
err := intent.Authorizer{}.AuthorizeTool(policy, "exec")
147+
require.Error(t, err, "P11: an explicit deny must still be rejected even when unrestricted")
148+
assert.True(t, errors.Is(err, intent.ErrToolDenied))
149+
}
150+
151+
// TestAuthorizeTool_EmptyAllowedToolsDeniesAll (P12 — AuthorizeToolEmptyDenyAll)
152+
// Invariant: a non-nil, empty AllowedTools denies every tool, distinct from nil.
153+
func TestAuthorizeTool_EmptyAllowedToolsDeniesAll(t *testing.T) {
154+
policy := intent.ExecutionPolicy{AllowedTools: []string{}}
155+
err := intent.Authorizer{}.AuthorizeTool(policy, "read")
156+
require.Error(t, err, "P12: non-nil empty AllowedTools must deny all tools")
157+
assert.True(t, errors.Is(err, intent.ErrToolNotAllowed))
158+
}
159+
160+
// TestSafestDefaultPolicy_FailClosedForIndeterminateStatus (P13 — SafestDefaultFailClosed)
161+
// Invariant: unlinked/ambiguous status forces the safest policy regardless of
162+
// configured rules.
163+
func TestSafestDefaultPolicy_FailClosedForIndeterminateStatus(t *testing.T) {
164+
autoMerge := true
165+
permissive := intent.PolicyRule{
166+
ID: "wildcard-permissive",
167+
Set: intent.ExecutionPolicy{
168+
Autonomy: "bounded",
169+
WriteScope: "any_branch",
170+
HumanApprovalRequired: false,
171+
AutoMergeAllowed: &autoMerge,
172+
MaxAttempts: 10,
173+
},
174+
}
175+
compiler := intent.PolicyCompiler{Rules: []intent.PolicyRule{permissive}}
176+
repo := intent.RepositoryContext{Owner: "owner", Name: "repo"}
177+
178+
for _, status := range []intent.AttributionStatus{intent.AttributionUnlinked, intent.AttributionAmbiguous} {
179+
t.Run(string(status), func(t *testing.T) {
180+
rec := intent.IntentRecord{Status: status}
181+
policy := compiler.Compile(rec, repo)
182+
183+
assert.Equal(t, "propose_only", policy.Autonomy, "P13: indeterminate status must force propose_only")
184+
assert.Equal(t, "none", policy.WriteScope, "P13: indeterminate status must force no write scope")
185+
assert.True(t, policy.HumanApprovalRequired, "P13: indeterminate status must force human approval")
186+
require.NotNil(t, policy.AutoMergeAllowed)
187+
assert.False(t, *policy.AutoMergeAllowed, "P13: indeterminate status must force auto-merge denial")
188+
assert.Equal(t, 1, policy.MaxAttempts, "P13: indeterminate status must force a single attempt")
189+
})
190+
}
191+
}
192+
193+
// TestEdgeCase_EmptyDomainsAndPriority validates that a fully empty intent
194+
// record resolves to unknown, not a panic or empty string.
195+
func TestEdgeCase_EmptyDomainsAndPriority(t *testing.T) {
196+
risk := intent.ResolveRisk(intent.IntentRecord{})
197+
assert.Equal(t, "unknown", risk, "edge case: fully empty record must resolve to unknown")
198+
assert.NotEmpty(t, risk, "edge case: ResolveRisk must never return an empty string")
199+
}
200+
201+
// TestEdgeCase_NilDeniedAndAllowedTools validates that AuthorizeTool does not
202+
// panic on a zero-value policy.
203+
func TestEdgeCase_NilDeniedAndAllowedTools(t *testing.T) {
204+
require.NotPanics(t, func() {
205+
err := intent.Authorizer{}.AuthorizeTool(intent.ExecutionPolicy{}, "read")
206+
assert.NoError(t, err, "edge case: zero-value policy (nil AllowedTools/DeniedTools) must be unrestricted")
207+
})
208+
}
209+
210+
// TestEdgeCase_MultipleMatchingRulesPreserveStricterConstraint validates that a
211+
// stricter constraint from an earlier rule isn't overridden by a later, more
212+
// lenient rule.
213+
func TestEdgeCase_MultipleMatchingRulesPreserveStricterConstraint(t *testing.T) {
214+
strict := intent.PolicyRule{
215+
ID: "strict-first",
216+
Set: intent.ExecutionPolicy{
217+
Autonomy: "propose_only",
218+
WriteScope: "none",
219+
},
220+
}
221+
lenient := intent.PolicyRule{
222+
ID: "lenient-second",
223+
Set: intent.ExecutionPolicy{
224+
Autonomy: "bounded",
225+
WriteScope: "any_branch",
226+
},
227+
}
228+
compiler := intent.PolicyCompiler{Rules: []intent.PolicyRule{strict, lenient}}
229+
rec := intent.IntentRecord{Status: intent.AttributionMapped, Labels: []string{"security"}}
230+
repo := intent.RepositoryContext{Owner: "owner", Name: "repo"}
231+
232+
policy := compiler.Compile(rec, repo)
233+
234+
assert.Equal(t, "propose_only", policy.Autonomy,
235+
"edge case: a later lenient rule must not override an earlier stricter autonomy constraint")
236+
assert.Equal(t, "none", policy.WriteScope,
237+
"edge case: a later lenient rule must not override an earlier stricter write-scope constraint")
238+
}

pkg/intent/resolver.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ type IntentRecord struct {
3939

4040
Labels []string `json:"labels,omitempty"`
4141

42+
// Domains, Priority, and Risk are optional classification dimensions used by
43+
// ResolveRisk to derive a risk level when Risk is not explicitly set. They are
44+
// distinct from Labels, which PolicyCondition matches against directly.
45+
Domains []string `json:"domains,omitempty"`
46+
Priority string `json:"priority,omitempty"`
47+
Risk string `json:"risk,omitempty"`
48+
4249
Rule string `json:"rule,omitempty"`
4350
ResolverVersion string `json:"resolver_version,omitempty"`
4451
}

specs/intent-attribution-agent-governance.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -937,12 +937,12 @@ The agent must not be able to modify or expand its own policy.
937937

938938
### `Authorizer.AuthorizeTool` Implementation Audit
939939

940-
The `AuthorizeTool` function as specified in this section is **not yet implemented** in the Go orchestrator. The following table documents which fields of `ExecutionPolicy` are wired to runtime enforcement and which remain unused.
940+
`Authorizer.AuthorizeTool` and `ResolveRisk` are implemented in `pkg/intent` (see `pkg/intent/governance.go`), but neither is yet called by the Go orchestrator. The following table documents which fields of `ExecutionPolicy` are wired to runtime enforcement and which remain unused.
941941

942942
| `ExecutionPolicy` field | Wired to enforcement? | Notes |
943943
|---|---|---|
944-
| `AllowedTools` | **Not wired** | The `pkg/intent` package implements `PolicyCompiler.Compile()` and `mergePolicy()` for this field, but no orchestrator calls `AuthorizeTool` at tool-call time. |
945-
| `DeniedTools` | **Not wired** | Same as `AllowedTools`present in the spec and policy model, not enforced at runtime. |
944+
| `AllowedTools` | **Implemented, not wired into orchestrator** | `pkg/intent` implements `PolicyCompiler.Compile()`, `mergePolicy()`, and `Authorizer.AuthorizeTool()` for this field, but no orchestrator calls `AuthorizeTool` at tool-call time yet. |
945+
| `DeniedTools` | **Implemented, not wired into orchestrator** | Same as `AllowedTools``Authorizer.AuthorizeTool()` checks this field, but it is not yet invoked from the execution path. |
946946
| `Autonomy` | **Not wired** | The autonomy level is compiled into the policy but not checked against actual workflow capabilities at execution time. |
947947
| `WriteScope` | **Not wired** | Defined in the policy model; no runtime enforcement in the Go orchestrator. |
948948
| `HumanApprovalRequired` | **Not wired** | Defined in policy model; human approval gates are not currently tied to `ExecutionPolicy`. |
@@ -951,9 +951,9 @@ The `AuthorizeTool` function as specified in this section is **not yet implement
951951
| `MaxAttempts` | **Not wired** | Not enforced at the orchestrator level. |
952952
| `RuleIDs` | **Provenance only** | Recorded in the policy for auditing; not used to gate execution. |
953953

954-
**Risk**: Policy constraints defined in `.github/intent-policy.json` (or the equivalent `rules` array) have no runtime effect until the orchestrator is wired to call `AuthorizeTool` and enforce `WriteScope`, `HumanApprovalRequired`, and `RequiredChecks`. Any policy compiled by `PolicyCompiler.Compile()` today is purely advisory.
954+
**Risk**: Policy constraints defined in `.github/intent-policy.json` (or the equivalent `rules` array) have no runtime effect until the orchestrator calls `Authorizer.AuthorizeTool` and enforces `WriteScope`, `HumanApprovalRequired`, and `RequiredChecks`. Any policy compiled by `PolicyCompiler.Compile()` today is purely advisory.
955955

956-
**Required follow-up**: Implement `Authorizer.AuthorizeTool` in `pkg/intent` or a new `pkg/intent/authz` sub-package and wire it into the execution path. Gate enforcement behind a feature flag until the policy model is validated in production.
956+
**Required follow-up**: Wire the now-implemented `Authorizer.AuthorizeTool` (in `pkg/intent`) into the execution path. Gate enforcement behind a feature flag until the policy model is validated in production.
957957

958958

959959
Initial observable rules:

0 commit comments

Comments
 (0)