iop/packages/go/agentpolicy/decision.go
toki 3c48879c44 feat(agent-runtime): 독립 Agent CLI 런타임 기반을 구현한다
독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
2026-07-29 13:12:21 +09:00

398 lines
13 KiB
Go

// Package agentpolicy implements the deterministic target policy evaluator for
// the IOP agent CLI runtime. It consumes an immutable agentconfig runtime
// snapshot and a runtime selection context to produce a single, durable
// RouteDecision.
package agentpolicy
import (
"bytes"
"crypto/sha256"
"crypto/subtle"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"time"
)
const (
decisionVersion = "1"
CandidateSourceRule = "rule"
CandidateSourceDefault = "default"
CandidateReasonMatched = "matched"
CandidateReasonDefault = "default"
CandidateReasonNotEvaluatedAfterFirstHit = "not evaluated after first match"
)
// RouteDecision is the durable, single-target result of evaluating an ordered
// selection policy. Candidate evidence preserves the complete ordered policy
// traversal, and History records every route that was actually used.
type RouteDecision struct {
ProviderID string `json:"provider_id"`
ModelID string `json:"model_id"`
ProfileID string `json:"profile_id"`
ProfileRevision string `json:"profile_revision"`
ConfigRevision string `json:"config_revision"`
SelectionRevision string `json:"selection_revision"`
SelectedRuleID string `json:"selected_rule_id"`
SelectedReason string `json:"selected_reason"`
Candidates []CandidateEvaluation `json:"candidates"`
History RouteHistory `json:"history"`
}
// CandidateEvaluation records one ordered rule or default candidate. Rules
// after the first match remain explicit but unevaluated.
type CandidateEvaluation struct {
Source string `json:"source"`
RuleID string `json:"rule_id"`
ProviderID string `json:"provider_id"`
ModelID string `json:"model_id"`
ProfileID string `json:"profile_id"`
ProfileRevision string `json:"profile_revision"`
Evaluated bool `json:"evaluated"`
Eligible bool `json:"eligible"`
Used bool `json:"used"`
Reason string `json:"reason"`
}
// RouteHistory is the immutable, revision-pinned record of a persisted route.
type RouteHistory struct {
DecisionID string `json:"decision_id"`
SelectedAt time.Time `json:"selected_at"`
UsedRoutes []UsedRoute `json:"used_routes"`
}
// UsedRoute records one route that was actually selected for execution.
type UsedRoute struct {
ProviderID string `json:"provider_id"`
ModelID string `json:"model_id"`
ProfileID string `json:"profile_id"`
ProfileRevision string `json:"profile_revision"`
RuleID string `json:"rule_id"`
Reason string `json:"reason"`
UsedAt time.Time `json:"used_at"`
}
// DecisionExpectation pins both configuration and effective selection policy
// revisions when a durable decision is decoded or resumed.
type DecisionExpectation struct {
ConfigRevision string
SelectionRevision string
}
// decisionEnvelope is a strict versioned wrapper. Integrity covers
// length-prefixed version, revisions, and canonical decision bytes.
type decisionEnvelope struct {
Version string `json:"version"`
ConfigRevision string `json:"config_revision"`
SelectionRevision string `json:"selection_revision"`
Decision json.RawMessage `json:"decision"`
Integrity string `json:"integrity"`
}
// EncodeDecision serialises and integrity-seals one structurally valid
// RouteDecision.
func EncodeDecision(decision RouteDecision) ([]byte, error) {
if err := validateDecision(decision); err != nil {
return nil, err
}
decisionData, err := json.Marshal(decision)
if err != nil {
return nil, fmt.Errorf("agentpolicy: encode decision payload: %w", err)
}
envelope := decisionEnvelope{
Version: decisionVersion,
ConfigRevision: decision.ConfigRevision,
SelectionRevision: decision.SelectionRevision,
Decision: decisionData,
Integrity: decisionIntegrity(
decisionVersion,
decision.ConfigRevision,
decision.SelectionRevision,
decisionData,
),
}
data, err := json.Marshal(envelope)
if err != nil {
return nil, fmt.Errorf("agentpolicy: encode decision envelope: %w", err)
}
return data, nil
}
// DecodeDecision strictly decodes, integrity-checks, and revision-pins one
// durable decision. It never returns an unchecked payload.
func DecodeDecision(data []byte, expected DecisionExpectation) (RouteDecision, error) {
if expected.ConfigRevision == "" || expected.SelectionRevision == "" {
return RouteDecision{}, fmt.Errorf(
"%w: both expected config and selection revisions are required",
ErrRevisionMismatch,
)
}
var envelope decisionEnvelope
if err := decodeStrictJSON(data, &envelope); err != nil {
return RouteDecision{}, fmt.Errorf("%w: envelope: %v", ErrCorruptDecision, err)
}
if envelope.Version != decisionVersion {
return RouteDecision{}, fmt.Errorf(
"%w: unsupported version %q",
ErrCorruptDecision,
envelope.Version,
)
}
if envelope.ConfigRevision == "" || envelope.SelectionRevision == "" ||
len(envelope.Decision) == 0 || envelope.Integrity == "" {
return RouteDecision{}, fmt.Errorf("%w: incomplete envelope", ErrCorruptDecision)
}
wantIntegrity := decisionIntegrity(
envelope.Version,
envelope.ConfigRevision,
envelope.SelectionRevision,
envelope.Decision,
)
if !constantBytesEqual(envelope.Integrity, wantIntegrity) {
return RouteDecision{}, fmt.Errorf("%w: integrity mismatch", ErrCorruptDecision)
}
var decision RouteDecision
if err := decodeStrictJSON(envelope.Decision, &decision); err != nil {
return RouteDecision{}, fmt.Errorf("%w: decision: %v", ErrCorruptDecision, err)
}
canonicalDecision, err := json.Marshal(decision)
if err != nil {
return RouteDecision{}, fmt.Errorf("%w: canonical decision: %v", ErrCorruptDecision, err)
}
if !bytes.Equal(envelope.Decision, canonicalDecision) {
return RouteDecision{}, fmt.Errorf("%w: decision payload is not canonical", ErrCorruptDecision)
}
if envelope.ConfigRevision != decision.ConfigRevision ||
envelope.SelectionRevision != decision.SelectionRevision {
return RouteDecision{}, fmt.Errorf(
"%w: envelope and decision revisions disagree",
ErrCorruptDecision,
)
}
if err := validateDecision(decision); err != nil {
return RouteDecision{}, err
}
if envelope.ConfigRevision != expected.ConfigRevision ||
envelope.SelectionRevision != expected.SelectionRevision {
return RouteDecision{}, fmt.Errorf(
"%w: got config=%q selection=%q, want config=%q selection=%q",
ErrRevisionMismatch,
envelope.ConfigRevision,
envelope.SelectionRevision,
expected.ConfigRevision,
expected.SelectionRevision,
)
}
return decision, nil
}
func validateDecision(decision RouteDecision) error {
if decision.ProviderID == "" || decision.ModelID == "" ||
decision.ProfileID == "" || decision.ProfileRevision == "" {
return fmt.Errorf("%w: selected target identity is incomplete", ErrCorruptDecision)
}
if decision.ConfigRevision == "" || decision.SelectionRevision == "" {
return fmt.Errorf("%w: decision revisions are incomplete", ErrCorruptDecision)
}
if decision.SelectedReason == "" || len(decision.Candidates) == 0 {
return fmt.Errorf("%w: selection evidence is incomplete", ErrCorruptDecision)
}
usedIndex := -1
defaultIndex := -1
for index, candidate := range decision.Candidates {
if candidate.Source != CandidateSourceRule &&
candidate.Source != CandidateSourceDefault {
return fmt.Errorf(
"%w: candidate %d has unknown source %q",
ErrCorruptDecision,
index,
candidate.Source,
)
}
if candidate.Source == CandidateSourceRule && candidate.RuleID == "" {
return fmt.Errorf("%w: candidate %d rule id is empty", ErrCorruptDecision, index)
}
if candidate.Source == CandidateSourceDefault {
if candidate.RuleID != "" || defaultIndex >= 0 ||
index != len(decision.Candidates)-1 {
return fmt.Errorf("%w: default candidate ordering is invalid", ErrCorruptDecision)
}
defaultIndex = index
}
if candidate.ProviderID == "" || candidate.ModelID == "" ||
candidate.ProfileID == "" || candidate.ProfileRevision == "" ||
candidate.Reason == "" {
return fmt.Errorf(
"%w: candidate %d identity or reason is incomplete",
ErrCorruptDecision,
index,
)
}
if !candidate.Evaluated && (candidate.Eligible || candidate.Used) {
return fmt.Errorf(
"%w: unevaluated candidate %d is eligible or used",
ErrCorruptDecision,
index,
)
}
if candidate.Eligible != candidate.Used {
return fmt.Errorf(
"%w: candidate %d eligibility and usage disagree",
ErrCorruptDecision,
index,
)
}
if usedIndex < 0 && !candidate.Evaluated {
return fmt.Errorf(
"%w: candidate %d was skipped before the first match",
ErrCorruptDecision,
index,
)
}
if usedIndex >= 0 && candidate.Evaluated {
return fmt.Errorf(
"%w: candidate %d was evaluated after the first match",
ErrCorruptDecision,
index,
)
}
if !candidate.Evaluated &&
candidate.Reason != CandidateReasonNotEvaluatedAfterFirstHit {
return fmt.Errorf(
"%w: candidate %d has invalid unevaluated reason",
ErrCorruptDecision,
index,
)
}
if candidate.Used {
if usedIndex >= 0 ||
(candidate.Reason != CandidateReasonMatched &&
candidate.Reason != CandidateReasonDefault) {
return fmt.Errorf("%w: used candidate evidence is invalid", ErrCorruptDecision)
}
if (candidate.Source == CandidateSourceRule &&
candidate.Reason != CandidateReasonMatched) ||
(candidate.Source == CandidateSourceDefault &&
candidate.Reason != CandidateReasonDefault) {
return fmt.Errorf(
"%w: used candidate source and reason disagree",
ErrCorruptDecision,
)
}
usedIndex = index
}
}
if usedIndex < 0 {
return fmt.Errorf("%w: no used candidate", ErrCorruptDecision)
}
selected := decision.Candidates[usedIndex]
if selected.ProviderID != decision.ProviderID ||
selected.ModelID != decision.ModelID ||
selected.ProfileID != decision.ProfileID ||
selected.ProfileRevision != decision.ProfileRevision ||
selected.RuleID != decision.SelectedRuleID ||
selected.Reason != decision.SelectedReason {
return fmt.Errorf("%w: selected candidate and decision disagree", ErrCorruptDecision)
}
if decision.History.DecisionID == "" || decision.History.SelectedAt.IsZero() ||
len(decision.History.UsedRoutes) == 0 {
return fmt.Errorf("%w: route history is incomplete", ErrCorruptDecision)
}
for index, route := range decision.History.UsedRoutes {
if route.ProviderID == "" || route.ModelID == "" ||
route.ProfileID == "" || route.ProfileRevision == "" ||
route.Reason == "" || route.UsedAt.IsZero() {
return fmt.Errorf(
"%w: used route %d identity or reason is incomplete",
ErrCorruptDecision,
index,
)
}
}
used := decision.History.UsedRoutes[len(decision.History.UsedRoutes)-1]
if used.ProviderID != decision.ProviderID ||
used.ModelID != decision.ModelID ||
used.ProfileID != decision.ProfileID ||
used.ProfileRevision != decision.ProfileRevision ||
used.RuleID != decision.SelectedRuleID ||
used.Reason != decision.SelectedReason ||
!used.UsedAt.Equal(decision.History.SelectedAt) {
return fmt.Errorf("%w: latest used route and decision disagree", ErrCorruptDecision)
}
return nil
}
func decodeStrictJSON(data []byte, destination any) error {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return err
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return errors.New("multiple JSON documents")
}
return err
}
return nil
}
func decisionIntegrity(
version, configRevision, selectionRevision string,
decisionData []byte,
) string {
hash := sha256.New()
writeLengthPrefixed(hash, []byte(version))
writeLengthPrefixed(hash, []byte(configRevision))
writeLengthPrefixed(hash, []byte(selectionRevision))
writeLengthPrefixed(hash, decisionData)
return "sha256:" + hex.EncodeToString(hash.Sum(nil))
}
func digestParts(parts ...string) string {
hash := sha256.New()
for _, part := range parts {
writeLengthPrefixed(hash, []byte(part))
}
return "sha256:" + hex.EncodeToString(hash.Sum(nil))
}
func writeLengthPrefixed(destination io.Writer, value []byte) {
var length [8]byte
binary.BigEndian.PutUint64(length[:], uint64(len(value)))
_, _ = destination.Write(length[:])
_, _ = destination.Write(value)
}
func constantBytesEqual(left, right string) bool {
return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
}
// Typed errors for decision encode/decode and evaluation.
var (
ErrCorruptDecision = errors.New("agentpolicy: corrupt route decision")
ErrRevisionMismatch = errors.New("agentpolicy: route decision revision mismatch")
ErrNoMatch = errors.New("agentpolicy: no matching selection rule and no default target")
ErrUnknownProject = errors.New("agentpolicy: unknown project")
ErrUnknownProfile = errors.New("agentpolicy: unknown profile in selection target")
ErrTargetIdentityMismatch = errors.New("agentpolicy: selection target identity mismatch")
ErrInvalidSelectionContext = errors.New("agentpolicy: invalid selection context")
)