Skip to content

Commit 9d64ec5

Browse files
alicodingclaude
andauthored
feat: the guardrail gains its public request-an-action entry (ADR-0047 §5) (#508)
Extracts the guardrail's rule-evaluation into a callable core in guardrailsvc (EvaluateStep/EvaluateAction) that the execution gate and the new public RequestGuardedAction entry both call -- one rule plane, two entry points, never a second policy evaluation. RequestGuardedAction(ctx, GuardedAction{Kind, Attributes, Description, Source}) lets a non-workflow caller (an agent today, a plugin once the out-of-tree loader ships) submit an action for the same rules a workflow step already evaluates against. allow/deny resolve immediately; ask parks a PendingGuardedAction -- decoupled from composition.ExecContext, an in-memory analogue of executionsvc.PendingApproval -- and blocks until resolved, ctx-cancelled (a clean withdrawal), or the same 24h fail-safe every other park in this codebase already uses. Not Wails-bound this slice; the MCP rebase onto this entry and any Review/floating-prompt UI wiring for a non-workflow park are the next slice. Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8b25fd6 commit 9d64ec5

4 files changed

Lines changed: 445 additions & 1 deletion

File tree

internal/services/executionsvc/executionservice_guardrail.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,12 @@ const shellCommandNodeTypeID = "process-shell-command"
138138
// the approval ceremony entirely, and a block containing even one
139139
// unlisted or deny-listed line still asks, same as today.
140140
func (e *ExecutionService) evaluateVerdict(workflowID string, node composition.Node, ec composition.ExecContext, class guardrail.EffectClass) guardrail.Verdict {
141+
// One rule-evaluation core, two entry points (docs/adr/0047 §5):
142+
// EvaluateStep is the same core guardrailsvc.RequestGuardedAction's
143+
// EvaluateAction calls -- this is the execution gate's own call
144+
// site, never a second evaluation of these rules.
141145
if node.NodeTypeID != shellCommandNodeTypeID {
142-
return guardrail.Evaluate(e.guard.Rules(), guardrailsvc.GuardrailStep(workflowID, node, ec), class)
146+
return e.guard.EvaluateStep(guardrailsvc.GuardrailStep(workflowID, node, ec), class)
143147
}
144148
steps := composition.ParseShellCommandBlock(ec.Payload)
145149
if len(steps) == 0 {

internal/services/guardrailsvc/guardrailservice.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ type GuardrailService struct {
5858
store settings.Store
5959
rules []guardrail.Rule
6060
comp *compositionsvc.CompositionService
61+
// guardedActionsMu guards guardedActions (guardrailservice_request.go)
62+
// -- a separate lock from mu (rule CRUD): the two protect unrelated
63+
// state, and a long-parked guarded action must never block a rule
64+
// save/list.
65+
guardedActionsMu sync.Mutex
66+
guardedActions map[string]*guardedActionRecord
6167
}
6268

6369
func NewGuardrailService(store settings.Store, comp *compositionsvc.CompositionService) *GuardrailService {
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package guardrailsvc
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/alicoding/mill/internal/domain/guardrail"
9+
"github.com/google/uuid"
10+
)
11+
12+
// One rule-evaluation core, two entry points (docs/adr/0047 §5): every
13+
// verdict -- whether for an about-to-execute workflow step or a
14+
// non-workflow GuardedAction -- is decided by guardrail.Evaluate over
15+
// the SAME rule set (EvaluateStep below). The execution gate
16+
// (executionsvc.evaluateVerdict) and RequestGuardedAction are its two
17+
// callers; neither one owns a second, parallel policy plane.
18+
19+
// EvaluateStep is the guardrail's rule-evaluation core: judges a
20+
// fully-formed Step against the current rules with guardrail.Evaluate's
21+
// deny > ask > allow > class-default precedence. A thin wrapper by
22+
// design -- the extraction this pays for is a single call site every
23+
// caller (a workflow step, a generic action) shares, so they can never
24+
// silently diverge into two different evaluations of the same rules.
25+
func (g *GuardrailService) EvaluateStep(step guardrail.Step, class guardrail.EffectClass) guardrail.Verdict {
26+
return guardrail.Evaluate(g.Rules(), step, class)
27+
}
28+
29+
// EvaluateAction adapts a generic action's kind/attributes into a Step
30+
// and evaluates it through EvaluateStep. kind fills Step.NodeTypeID --
31+
// the same scope axis a workflow node's own NodeTypeID already targets
32+
// -- so a rule authored against a NodeTypeID scope also targets a
33+
// guarded action of that kind, by construction, with no separate rule
34+
// vocabulary to maintain.
35+
func (g *GuardrailService) EvaluateAction(kind string, attributes map[string]string, class guardrail.EffectClass) guardrail.Verdict {
36+
attrs := make(map[string]any, len(attributes))
37+
for k, v := range attributes {
38+
attrs[k] = v
39+
}
40+
step := guardrail.Step{
41+
NodeTypeID: kind,
42+
Env: guardrail.ConditionEnv("", attrs, nil),
43+
}
44+
return g.EvaluateStep(step, class)
45+
}
46+
47+
// guardedActionTimeout is the same §8 fail-safe every other park in
48+
// this codebase already resolves an unattended ask to
49+
// (guardrailApprovalTimeout in executionsvc, mcpWriteExpiry in
50+
// mcpsvc) -- an unresolved guarded action denies itself closed after a
51+
// day rather than blocking its caller forever.
52+
const guardedActionTimeout = 24 * time.Hour
53+
54+
// GuardedAction is what a non-workflow caller -- an agent today, a
55+
// plugin once the out-of-tree loader ships (docs/adr/0047 §5) -- asks
56+
// the guardrail to judge. Attributes is evaluated by the exact same
57+
// rule conditions (guardrail.ConditionEnv) a workflow step's own
58+
// Attributes already are.
59+
type GuardedAction struct {
60+
// Kind names the action class for rule targeting (fills
61+
// Step.NodeTypeID -- see EvaluateAction).
62+
Kind string
63+
// Attributes is the same map[string]string vocabulary a workflow
64+
// step's rules already evaluate.
65+
Attributes map[string]string
66+
// Description feeds the approval UI (Review, the floating prompt).
67+
Description string
68+
// Source names who's asking (an agent id, a future plugin id) --
69+
// carried onto the parked PendingGuardedAction's own Source field.
70+
Source string
71+
}
72+
73+
// Decision is RequestGuardedAction's outcome. Approved is what every
74+
// caller branches on; Effect/RuleID/RuleLabel identify what decided it
75+
// (the original verdict, even after a human resolves an ask -- Effect
76+
// stays "ask", Approved carries the human's actual answer).
77+
type Decision struct {
78+
Approved bool
79+
Effect guardrail.Effect
80+
RuleID string
81+
RuleLabel string
82+
}
83+
84+
// PendingGuardedAction is a parked, not-yet-resolved GuardedAction --
85+
// the non-workflow analogue of executionsvc.PendingApproval (docs/adr/0047
86+
// §5 point 3): additive, never reshaping the workflow park it is meant
87+
// to one day render alongside.
88+
type PendingGuardedAction struct {
89+
ID string
90+
Kind string
91+
Attributes map[string]string
92+
Description string
93+
Source string
94+
CreatedAt time.Time
95+
}
96+
97+
// guardedActionRecord is one parked action's live bookkeeping --
98+
// decision is unexported so it never round-trips through anything that
99+
// marshals PendingGuardedAction.
100+
type guardedActionRecord struct {
101+
PendingGuardedAction
102+
decision chan bool
103+
}
104+
105+
// RequestGuardedAction is the guardrail's public "submit an action for
106+
// evaluation" entry (docs/adr/0047 §5) -- the second caller of the
107+
// rule-evaluation core EvaluateStep/EvaluateAction above already share
108+
// with the execution gate. allow/deny resolve immediately; ask parks a
109+
// PendingGuardedAction and blocks until a human resolves it
110+
// (resolveGuardedAction), ctx is cancelled (a clean withdrawal -- the
111+
// pending record is removed either way, never left orphaned), or the
112+
// same 24h fail-safe every other park in this codebase already uses
113+
// elapses.
114+
//
115+
// class is always guardrail.ClassExternal: a guarded action is by
116+
// definition a request for a primitive the caller does not hold
117+
// directly (docs/adr/0047 §2), which is exactly what ClassExternal's
118+
// ask-by-default fail-safe already gates without a rule naming it.
119+
//
120+
// Not Wails-bound this slice -- a Go-internal entry for future
121+
// in-process consumers; binding it is deferred until a real caller
122+
// needs it from the frontend.
123+
//
124+
//wails:ignore
125+
func (g *GuardrailService) RequestGuardedAction(ctx context.Context, action GuardedAction) (Decision, error) {
126+
verdict := g.EvaluateAction(action.Kind, action.Attributes, guardrail.ClassExternal)
127+
switch verdict.Effect {
128+
case guardrail.EffectAllow:
129+
return Decision{Approved: true, Effect: verdict.Effect, RuleID: verdict.RuleID, RuleLabel: verdict.RuleLabel}, nil
130+
case guardrail.EffectDeny:
131+
return Decision{Approved: false, Effect: verdict.Effect, RuleID: verdict.RuleID, RuleLabel: verdict.RuleLabel}, nil
132+
}
133+
134+
rec := &guardedActionRecord{
135+
PendingGuardedAction: PendingGuardedAction{
136+
ID: uuid.NewString(),
137+
Kind: action.Kind,
138+
Attributes: action.Attributes,
139+
Description: action.Description,
140+
Source: action.Source,
141+
CreatedAt: time.Now(),
142+
},
143+
decision: make(chan bool, 1),
144+
}
145+
g.parkGuardedAction(rec)
146+
defer g.unparkGuardedAction(rec.ID)
147+
148+
select {
149+
case approve := <-rec.decision:
150+
return Decision{Approved: approve, Effect: verdict.Effect, RuleID: verdict.RuleID, RuleLabel: verdict.RuleLabel}, nil
151+
case <-ctx.Done():
152+
return Decision{}, ctx.Err()
153+
case <-time.After(guardedActionTimeout):
154+
return Decision{Approved: false}, fmt.Errorf("guardrail: guarded action approval timed out after %s", guardedActionTimeout)
155+
}
156+
}
157+
158+
// parkGuardedAction/unparkGuardedAction/resolveGuardedAction/
159+
// pendingGuardedActions are the in-process pending store: an in-memory
160+
// map, not settings-persisted (unlike mcpsvc's MCPWriteRecord) --
161+
// RequestGuardedAction has no real caller yet in this slice (the MCP
162+
// rebase is next), so there is nothing whose restart-survival matters
163+
// today; the record shape (PendingGuardedAction) carries everything a
164+
// future durable store would need to persist.
165+
166+
func (g *GuardrailService) parkGuardedAction(rec *guardedActionRecord) {
167+
g.guardedActionsMu.Lock()
168+
defer g.guardedActionsMu.Unlock()
169+
if g.guardedActions == nil {
170+
g.guardedActions = map[string]*guardedActionRecord{}
171+
}
172+
g.guardedActions[rec.ID] = rec
173+
}
174+
175+
func (g *GuardrailService) unparkGuardedAction(id string) {
176+
g.guardedActionsMu.Lock()
177+
defer g.guardedActionsMu.Unlock()
178+
delete(g.guardedActions, id)
179+
}
180+
181+
// resolveGuardedAction delivers a human decision to a parked action --
182+
// the Go-internal analogue of executionsvc.ResolveApproval. Returns
183+
// false when id names no currently-parked action (already resolved,
184+
// timed out, or never existed), mirroring ResolveApproval's own
185+
// unknown-id error path at the caller's discretion.
186+
func (g *GuardrailService) resolveGuardedAction(id string, approve bool) bool {
187+
g.guardedActionsMu.Lock()
188+
rec, ok := g.guardedActions[id]
189+
g.guardedActionsMu.Unlock()
190+
if !ok {
191+
return false
192+
}
193+
select {
194+
case rec.decision <- approve:
195+
default:
196+
}
197+
return true
198+
}
199+
200+
// pendingGuardedActions lists every currently-parked action -- the
201+
// listing half of the pending model a future Review/floating-prompt
202+
// wiring (or the MCP rebase) will read from.
203+
func (g *GuardrailService) pendingGuardedActions() []PendingGuardedAction {
204+
g.guardedActionsMu.Lock()
205+
defer g.guardedActionsMu.Unlock()
206+
out := make([]PendingGuardedAction, 0, len(g.guardedActions))
207+
for _, rec := range g.guardedActions {
208+
out = append(out, rec.PendingGuardedAction)
209+
}
210+
return out
211+
}

0 commit comments

Comments
 (0)