|
| 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